☰
从Fakebook看CTF综合题:SQL注入与PHP反序列化链式利用实战复盘
2026/10/20 12:28:27 网站建设 项目流程

今年网鼎杯的 Web 题里,Fakebook 这道题给我留下了很深的印象。表面上看是一个模仿主流社交平台的站点,注册、登录、发动态、看主页,功能一应俱全,长得人畜无害。但实际上它把信息收集、源码审计、SQL 注入、反序列化、任意文件读取这几个考点串成了一条完整的攻击链,难度不算顶高,但每一步都卡得很到位,综合性很强。如果你是刚开始打 CTF 的 Web 选手,或者想过几道像样的综合题来练手,这道题非常适合拿来做复盘素材。

网上已经有不少关于这道题的 writeup,但大多数只写了“这里注入、那里反序列化”,没把为什么要这么做、每一步的依据是什么讲清楚。这篇文章我从头到尾把完整的做题过程梳理一遍,把踩坑和排查思路也带上,尽量让话捡干货说,看完能直接上手复现。

1. 环境侦察与谜题拆解

1.1 从首页开始摸清功能面

拿到题目第一件事永远不是急着点页面,而是先想清楚:这个站点到底有哪些功能入口可以用。Fakebook 的首页看起来就是一个标准社交网站的样子,有登录框、有注册入口、有几个公开的博主信息列表,底下还分页。

基本功能面很清晰:游客能看公开用户列表和公开的个人主页;注册登录后能创建账号,发布自己的博客内容,访问自己的个人主页;站点存在分页查询逻辑,说明大概率有数据库交互;头像和博客内容如果存在上传或展示逻辑,就要额外关注文件处理和序列化存储的情况。

第二步我会顺手看一顿页面上暴露的静态文件和响应头。大多数时候真正的突破口藏在容易被忽略的文本里,比如注释、框架指纹、Cookie 的序列化痕迹。Fakebook 的 Cookie 里有个显眼的 user 字段,值是一长串看起来很像 PHP 序列化的字符串,具体是a:3:{s:2:"id";s:1:"1";...}这种形态。看到这个基本可以确定两件事:后端是 PHP,而且把用户数据序列化后写进了 Cookie。这就为后面的反序列化埋下了重要的伏笔。

1.2 顺手扫一波目录与备份文件

在确认功能面之后,常规思路是先跑一遍目录扫描,看看有没有隐藏管理入口、备份文件、源码包之类的东西。工具我用的是常见扫描工具,字典选的是带 php 备份文件特征的常用路径。

扫到的结果里有一个很有价值的入口:/robots.txt。很多比赛进去第一件事就该看它,虽然里面不一定会直接给 flag,但经常会间接给线索。Fakebook 的 robots 文件内容并不复杂,但有两条路径直接暴露了敏感文件的存在:

  • /user.php.bak
  • /view.php.bak

.bak 后缀说明是编辑器或人为备份的 PHP 源码文件。这类文件如果直接能被访问下载,就等于把源码送给了参赛者。访问/robots.txt里暴露的那两个 .bak 文件,可以拉到两份关键源码:user.php和view.php。从这里开始,题目就正式从“黑盒猜谜”转入了“白盒审计”阶段。

1.3 源码泄露后的文件结构分析

拿到user.php.bak和view.php.bak之后,先不着急逐行看,先观察整体文件结构和引用关系。两个文件代码量都不大,但信息密度很高。

user.php里面定义了一个User类,私有属性有$id、$name、$blog和$avatar,构造方法把接收到的数据赋给对应属性,还实现了魔术方法__toString()。这个方法打印用户信息,同时里面用了一个file_get_contents去读取图片路径并输出。看到对象里有file_get_contents并且参数是用户可控的,就要警觉,这就是潜在的任意文件读取点,或者更准确说是一个可以被反序列化触发的敏感操作点。

view.php主要负责渲染用户主页。它从 Cookie 里取user字段,反序列化成一个User对象,然后调用__toString()把信息输出到页面。问题很明显了:Cookie 是客户端可控的,后端直接对 Cookie 内容做unserialize(),没有任何过滤或签名校验。这就像把保险柜钥匙放在了密码锁旁边,只要你能构造合法结构的序列化字符串,让反序列化出来的对象符合预期,__toString()里的file_get_contents就会按照你指定的路径去读文件。

这里插一句个人经验:拿到源码别急着把所有函数都盯一遍。第一轮先看数据流,从入口参数一路追到危险函数,凡是“用户输入 → 存储 → 取出后再使用”的路径都要理清楚。第二轮再针对危险函数找触发条件。Fakebook 的核心数据流非常短:Cookie 里取序列化数据 →unserialize()生成对象 → 对象输出时自动调用__toString()→file_get_contents读取路径。理解这条链路,后面的利用就顺理成章了。

2. 数据库层的突破口:SQL 注入定位与利用

2.1 注入点到底藏在哪里

反序列化链虽然已经看到了,但光靠这一个点还不够,因为要构造出有效的序列化对象,前提是知道代码里真实存在哪些可用的类、类的属性名是什么,以及要读取的目标文件路径。这些信息一部分在源码里能直接看到,另一部分需要靠别的漏洞去“撬”出来。

Fakebook 首页的用户列表带分页参数,URL 大概长这样:/index.php?page=1。第一个想到的当然是尝试对分页参数做 SQL 注入测试。经验上来说,分页、排序、过滤这类参数是注入高发区,因为开发者容易直接拿参数拼 SQL。用一个单引号去试,页面直接报了数据库错误。能在页面上看到原始报错信息,这本身就是个很好的信号,意味着写注入脚本时可以直接采用报错注入,不用盲注一点点猜,效率能翻好几倍。

具体报错长这样,You have an error in your SQL syntax...。不用关心完整报错内容,看关键点就够:传入的单引号被拼进了 SQL 语句,导致语法错误,而且错误信息被直接回显。这基本坐实了GET请求的page参数存在字符型 SQL 注入。

2.2 判断注入类型与绕过思路

继续往下测,我用order by逐列判断字段数量。order by 3页面正常,order by 4直接报错,说明查询只有 3 列。这里顺便说一句,order by报错时不需要纠结具体报什么,只要页面行为发生变化就够了,这个方法是判断列数最稳的手段。

然后测试注入类型。在page参数后面拼接and 1=1和and 1=2,一个正常一个空内容,确认是数字型或字符型。这里有个小细节,做题时最好多试几种闭合方式,包括不加引号、加单引号、加双引号、加括号等。Fakebook 这题实际过滤不算严格,常规 payload 就能打通,但不要因为一次探索顺利就掉以轻心,很多题会卡在过滤绕过上。

利用报错注入的方式,我用updatexml(1,concat(0x7e,(select ...),0x7e),1)把查询结果带出来。这个是比赛里最常用的报错注入手法,配合concat拼接输出。为了让拿到的信息更完整,我把查询目标定在information_schema上,逐步获取库名、表名和字段名。

第一步先拿库名,payload 大概长这样:

page=1 and updatexml(1,concat(0x7e,(select database()),0x7e),1)

返回结果里能看到当前使用的库名,继续下一步,从information_schema.tables里找当前库下有几张表:

page=1 and updatexml(1,concat(0x7e,(select table_name from information_schema.tables where table_schema=database() limit 0,1),0x7e),1)

这里注意limit的使用,一次只能取一行,要循环换offset把表名逐个拉出来。拿到的表名里有几个和用户、博客相关的表,还有一个表名一看就跟 flag 气质贴合。按照题目的常见套路,flag 要么藏在传说中的 flag 表里,要么放在某个用户的高权限私有博客里,所以先不急着动 flag 表,而是先看用户表,因为用户表里的数据往往是后续反序列化利用的跳板。

2.3 从数据表里挖出序列化内容

沿着注入点把用户表的数据拖出来看,发现表结构里有一个字段专门存“个人介绍”或“博客信息”这类长文本内容。关键来了:这个字段里存的不是普通字符串,而是以O:4:"User":开头的序列化数据。

这里解释一下为什么它是关键,在反序列化漏洞里,如果数据表里存储的内容本身是序列化对象,并且某个功能在取出内容后会重新执行unserialize(),那么只要我们能通过注入把恶意构造的序列化对象写进数据库,当功能触发时就会自动反序列化执行我们的恶意对象。这就是著名的“二次注入与反序列化组合利用”。

Fakebook 实际结构差不多是这样:注册用户时,后端把一个User对象序列化后存入数据库;查看主页时,取出字段内容,一部分用于渲染,一部分交给unserialize()重新还原成对象。这就给了一条路,通过 SQL 注入往这个字段里写入我们自己的序列化数据,然后触发前端渲染,让代码主动调用__toString()。

但这里有一个现实问题:如果直接通过注入修改数据库里的字段,字段长度可能受限,而且有些字符会被转义,特别是引号和反斜杠。实际操作中,比较稳的方式是注册一个新的测试账号,把恶意序列化内容拼到可控输入点里(比如博客地址、昵称、头像路径),再利用注入把其他字段顺带改掉,或者干脆借助注册功能本身的数据流让恶意数据进库。具体用哪种方式,取决于题目过滤规则和功能设计。Fakebook 这里我用的是注册功能结合字段内容拼接的方式,把构造好的序列化字符串当作博客地址内容提交,然后触发查看主页。

3. 反序列化利用与源码级利用链组装

3.1 为什么数据表里躺着一串序列化字符串

要理解反序列化利用,先得理解序列化存储的初衷。PHP 的serialize()把内存中的对象转成可存储传输的字符串,unserialize()再把它还原成对象。在 Web 开发里,很多老项目喜欢把对象直接存进数据库字段,省事,但代价是安全边界很模糊。

Fakebook 的User类就是典型例子。它的__toString()魔术方法本来是为了方便直接echo $user,但方法内部却调用了file_get_contents并把结果拼进输出。这意味着,只要我们能控制对象中的某个属性值,file_get_contents的路径就完全被我们拿捏了。再配合服务端直接对 Cookie 或数据表内容进行unserialize(),漏洞就串起来了。

用生活化的类比讲:序列化数据就像一张快递单,上面写着收件人、地址、物品名称。正常情况下你按单子发货没问题,但如果你能偷偷改单子上的字段,比如把“地址”改成“服务器上的 /flag”,那快递员就会把不该发的包裹送到不该去的地方。这里的快递员就是unserialize()和__toString()的组合。

3.2 反序列化漏洞的核心机制

PHP 反序列化漏洞的本质在于两个环节的信任错位:一个环节信任数据内容是安全的,不去校验就还原对象;另一个环节信任对象属性是合法范围内的值,拿着属性值就直接去执行敏感操作。

常见的危险魔术方法有几个:

  • __destruct():对象销毁时触发,适合执行清理或恶意命令
  • __toString():对象被当作字符串输出时触发,适合输出文件内容
  • __call()/__get():访问不存在的方法或属性时触发,可用来劫持调用
  • __wakeup():反序列化时触发,常用来绕过限制或初始化恶意属性

Fakebook 里核心触发点是__toString()。所以利用条件很明确:第一步,确认存在可控的file_get_contents调用,且路径由对象属性决定;第二步,找到能把这个对象写入存储或 Cookie 的途径;第三步,在访问页面时让字符串输出方式触发__toString()。

这里有一个细节会被很多新手忽略:__toString()只在对象被当成字符串使用的时候才会调用。如果你只是把对象赋给变量,没有任何输出或拼接操作,它不会触发。所以看源码时要注意上下文,看看目标对象到底是被echo、拼接还是传给字符串函数用了。

3.3 构造序列化 Payload 的正确姿势

构造 Payload 之前,要先确认目标类定义里的属性名。从user.php.bak里能看到User类有$name、$blog、$avatar等属性,其中$blog很可能就是触发file_get_contents的那个点。

序列化 Payload 的基本格式是:

O:4:"User":2:{s:4:"name";s:4:"test";s:4:"blog";s:18:"/flag_xxx/flag.txt";}

展开解释一下:

  • O:4:"User"表示这是一个类名为 User 的对象,类名长度是 4
  • 2表示有两个属性
  • s:4:"name"表示属性名为 name,长度 4
  • s:4:"test"表示属性值为字符串 test
  • s:4:"blog"和后面的路径同理

构造时最容易被坑的是长度计数。PHP 反序列化对字符串长度的要求非常严格,长度不对会直接报错或者导致后面的解析错位。如果字符串里有中文,要注意中文字符的字节数,长度必须以字节为单位计算。

我之前踩过不少次这种坑,所以每次构造完都会用serialize()函数跑一遍,确认格式无误再传给靶机。写个本地小脚本,用和目标一样的类定义去serialize(),生成的数据最可靠,不要手拼字符串。

不过这里还有一个微妙之处,如果目标是让$blog指向某个远程地址或者内网地址,那file_get_contents能不能成功,还要看 PHP 配置里是否开启了allow_url_fopen。大多数情况下默认是开启的,但读本地文件基本不受影响。所以第一个尝试目标应该放在服务器本地的敏感文件上,比如 flag 文件、/etc/passwd,通过这些内容反推服务器的目录结构。

4. 完整攻击链实现:从浏览器到 flag

4.1 注册测试账号,采集关键数据

接下来把攻击链完整走一遍。第一步先注册一个普通测试账号,填入可控内容的时候就要有意识地往“会影响序列化属性”的方向去构造。

注册页面的表单字段不复杂,但每填一项都要想清楚它会被拼进哪个属性。比如博客地址或主页 URL 这个字段,如果你在源码里看到它最终被赋值给$blog,那恭喜,这里就是我们要塞恶意内容的位置。实践里我一般会先用一个正常值注册,比如blog=hello,然后在查看主页时观察页面输出了什么,确认字段和__toString()的映射关系。

确认之后,重新注册一个账号,把博客字段填成反序列化 Payload。注意这里的输入长度一般没有限制,所以序列化对象可以写得很完整。注册完成,数据库里对应记录的存储内容实际就是序列化后的对象。

4.2 让目标对象在页面上“复活”

注册成功之后,访问这个账号的主页,服务端会把数据库里取到的内容交给unserialize()还原成User对象,然后在模板里输出。只要输出操作触发__toString(),对象里的$blog值就会被当作路径传给file_get_contents,最终读到的内容直接拼在页面上回显。

如果第一次读取失败,页面可能会留白或者直接报错,这时候别慌,先确认几件事:对象属性名是不是拼错了,路径对不对,目标文件是否存在。实战里面,过程往往不会那么顺利,所以要用 SQL 注入继续收集信息。比如先通过注入读服务器配置文件、看 Web 目录路径、确认 flag 文件名。

Fakebook 里 flag 的路径不会写死在题目描述里,而是放在某个不容易猜到名字的目录中。用注入去读information_schema已经拿不到这个文件系统的信息了,所以还得靠反序列化链读文件。再配合一个常见技巧,如果file_get_contents("/flag")读不出来,可以先读/proc/self/cmdline之类的文件,确认目标进程的启动路径,也能看到 Web 目录的物理位置。或者读/etc/nginx/nginx.conf找到 Web 根目录和站点配置,再从配置文件里找 flag 的存储位置。

实际操作中我经常这样层层推进:先读/etc/passwd确认文件读取成功,再读 Web 目录下的index.php看实际代码,确认数据库账号密码,然后尝试用数据库客户端连接进去,直接从数据库里掏 flag。这种思路比盲目猜文件路径要稳妥得多。

4.3 从文件读取到最终 getflag

如果题目环境允许,反序列化读文件已经足够拿到 flag。但如果遇到读文件路径被限制的情况,比如file_get_contents只能读取固定前缀的路径,那就需要转换思路,利用 PHP 的流协议来绕过,比如用php://filter/convert.base64-encode/resource=index.php读取源码。这是非常实用的一招,可以把源码用 base64 方式带出来,避免直接读 PHP 文件时被服务器执行而看不到内容。

Fakebook 的场景里,$blog最终指向的文件路径被拼进file_get_contents,所以读 flag 的直接姿势是:

O:4:"User":2:{s:4:"name";s:4:"test";s:4:"blog";s:18:"/var/www/html/flag.php";}

这里要注意路径长度是 18,构造时一定要数清楚。如果 class 的私有属性名带了空字节或其他特殊字符,序列化格式会略有不同,但一般情况下构造void。实际上用 PHP 脚本去serialize()一个真实对象,最不容易出错。

真正拿到 flag 的内容之后,我一般会顺手验证一下是不是题目要求的格式。很多比赛要求提交的 flag 是特定格式,比如flag{...},只要内容完整复制即可。同时建议把完整攻击链的每一步截图保存,方便赛后复盘或者写 writeup 时参考。这里不只是为了交差,更是为了下次遇到同类题目时能快速定位问题出在哪个环节。

4.4 攻击链全景回顾

把整条链路串起来看,Fakebook 的精髓就很清楚了。它不是考某一个孤立的漏洞,而是考你怎么把多个漏洞点串成一跳链路。单独的 SQL 注入只能拿到数据库内容,单独的反序列化也不一定能构造出合法的对象,但二者结合后,注入负责把恶意序列化内容送进数据库,反序列化负责把它还原成危险对象,__toString()负责真正执行文件读取,最终把 flag 带回页面。

这就是 CTF 里常说的“链式思维”。做题时不要只盯着某个字段发呆,要跳出来看整个流程:数据从哪里进来,经过了哪些存储,在哪里被取出,最终在哪里触发了敏感操作。只要这条数据流里有任何一个地方对输入不够警惕,整条链就能被打通。

5. 踩坑记录与排查思路复盘

5.1 实验过程中最容易翻车的三个点

这题我做了不止一遍,每次都能碰到不一样的小坑。最典型的有三个,基本可以覆盖大多数人会遇到的卡点。

第一个坑是序列化字符串长度错误。这是新手翻车率最高的地方。PHP 的序列化格式里,每个字符串字段都要标注字节长度,长度不对,unserialize()会直接返回 false,后面的逻辑全部失效。很多情况下你构造的 Payload 看起来没毛病,但页面就是不回显任何变化,极大概率就是长度没对上。解决办法很直接,用 PHP 官方函数serialize()生成数据,不要手拼。

第二个坑是没搞清楚触发时机。__toString()不是反序列化时自动触发的,而是对象被当成字符串使用时才触发。如果你写入数据库之后只是简单查询验证,没有触发对象输出,页面自然不会出现预期效果。一定要确认前端渲染逻辑里确实有echo $user或echo $user->blog之类的地方。如果没有触发点,就要另找别的魔术方法或代码逻辑。

第三个坑是路径抓瞎。直接猜 flag 文件路径成功率很低。正确做法是先利用任意文件读取读几个基础文件,判断服务器操作系统、Web 目录结构和运行权限,再顺着配置文件去找 flag。哪怕题目提示 flag 在根目录,也要先试/flag,不行再试/var/www/html/flag,不要一棵树上吊死。如果路径有限制,优先尝试php://filter流协议读源码,再根据源码内容定位 flag 位置。

5.2 FAQ 速查表

问题现象可能原因排查方法
unserialize()报错或返回 false序列化字符串长度不对,属性名/类名拼写错误用本地 PHP 脚本 serialize() 生成合法 Payload
页面内容无变化没有触发__toString(),或对象没被当作字符串输出检查前端模板是否有 echo 对象变量的代码
file_get_contents读不到目标文件路径写错、目标文件权限不足先读/etc/passwd、/proc/self/cmdline判断环境
反序列化 Payload 全部失败类属性是 private/protected,格式和 public 不同检查源码里属性可见性,按可见性调整序列化格式
文件名不确定flag 文件没放在常规位置读配置文件,找 Web 根目录和站点物理路径,再逐层找
PHP 文件读出来被解析没法看内容直接读 PHP 文件被服务端执行了改用php://filter/convert.base64-encode/读取源码

5.3 综合题方法论:先画数据流,再找拼接点

复盘完整道题,我最想分享的一点其实是“怎么做综合题”的方法论。不要一上来就盯着某个参数猛怼,也不要看到一个漏洞就急着打,先把整个应用的数据流画出来。从用户可控输入开始,跟踪到存储层,再从存储层跟踪到使用层,重点观察三个东西:输入有没有校验、存储有没有转义、使用有没有危险函数。

Fakebook 的数据流是一条线:注册参数 → 序列化写入数据库 → 前端取数据 →unserialize()→__toString()→file_get_contents。任何环境下,这种链路都要作为重点排查对象。尤其是 PHP 题目,序列化字段出现在数据库或 Cookie 里,基本等于把门牌号写在了脸上。

再补一个实战技巧:构造 Payload 时养成脚本化的习惯。本地放一个和目标类同名的 PHP 测试文件,用serialize()直接生成序列化数据。一是省去手工数长度的痛苦,二是验证序列化格式的过程本身就是对类结构的二次确认。我之前看别人代码审计时经常卡在属性可见性上,public、private、protected 的序列化格式不一样,用脚本生成可以彻底避开这些记忆负担。

这个思路还能延伸到哪里

Fakebook 这种“多漏洞点串链”的思路,放到真实项目审计里同样适用。渗透测试时拿到的往往不是单一漏洞直通服务器,而是几个中低危问题组合起来突破边界,比如一个存储型 XSS 配合 CSRF 改管理员密码,再配合后台文件上传拿权限,本质上都是链式思维。做题和实战并没有分界线,差别只在于题目有标准答案,而实际系统千奇百怪。

以这道题为引子,建议你接下来可以做两件事:一是找几道同样涉及序列化 + 注入组合的题目练手,最好是自己搭环境,改几行代码换换路径和属性名,再打一遍,看看能不能适应变化;二是把本地 PHP 环境里常见的危险函数触发场景全列一遍,做一张自己的速查表,打题时翻起来效率极高。

我在复现这道题的过程里最大的收获不是最后拿到 flag 那一瞬间,而是中途被序列化长度卡了两个小时后,突然意识到用脚本生成 Payload 有多省事。后来每道 PHP 反序列化题我都坚持这个习惯,再也没在格式上栽过跟头。希望这篇复盘能帮你少踩几个我没必要的坑,把时间花在更关键的思路上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询