ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CTF Web题unfinish复盘:从二次注入到拖库的完整攻击链

CTF Web题unfinish复盘:从二次注入到拖库的完整攻击链

1. 这道题为什么叫 unfinish:一眼看上去的“半成品”

我第一次在网鼎杯 2018 的赛题列表里看到unfinish这个名字时,第一反应是出题人是不是没写完就交上来了。后来把题做完再回头看这个名字,才意识到它其实挺贴切——这道题的入口、报错信息、后端逻辑,处处都给人一种“没完成”的错觉,而真正的考点,恰恰就藏在这些“看起来没做完”的细节里。

先说一个结论:unfinish是一道非常典型的 Web 题,核心考点是SQL 注入,而且不是那种一上来就能' or 1=1 -- -直接打穿的注入,是二次注入。它属于那种“一看就会、一动手就卡住”的题目,很多选手在第一关就把时间花光了,后面的 payload 反而没来得及构造。

我当时做题的环境是:一个只给了 IP 和端口的靶机,没有源码,没有附件,纯黑盒。这也是大多数线下赛的常见配置。题目要做的就是通过浏览器和简单的工具,一步步把目标扒干净,最终拿到存在数据库里的 flag。

这道题适合谁参考?如果你刚接触 CTF 的 Web 方向,想找一道能同时练到“信息收集、登录逻辑分析、二次注入原理、报错注入或盲注利用”的题,unfinish是个很好的训练样本。如果你已经有一定基础,但总在“注入点找到了却打不通”这个环节卡住,那这篇文章里的排查思路和构造细节应该对你有帮助。

下面我按照做题的完整过程来复盘,把每一步为什么这么做、踩了哪些坑、换成别的方法行不行,都尽量说清楚。

2. 开局信息收集:从首页、登录框和目录扫描里能挖出什么

CTF 的 Web 题,信息收集做得好不好,直接决定后面是“降维打击”还是“坐牢”。unfinish这题我开局只花了十几分钟,就把关键线索摸了个大概。

2.1 页面上唯一像样的功能:一个登录框

打开目标地址,首页是一个很朴素的登录页面,没有图片、没有前端框架、没有多余的特效,就是一个用户名输入框、一个密码输入框、一个登录按钮。

我先做了一轮基础探测:

  • 直接在用户名框里输入admin,密码随便填,提交后页面提示“用户名或密码错误”。
  • 输入一个不存在的用户名test123,提交后提示“用户名不存在”。

这两个提示的差异非常关键——它直接暴露了后端验证逻辑的一部分:系统会先校验用户名是否存在,再校验密码。这在渗透测试里属于“用户枚举”漏洞,虽然危害不大,但对后续注入构造非常有用,因为我可以先判断一个用户名到底存不存在,再用它做二次注入的“跳板”。

2.2 目录扫描:比预期多出来的文件

接下来自然是扫目录。我用了一款常见的目录扫描工具去扫常见后缀和常见路径。结果找到一个index.php,这是首页本身;另外还发现了一个可访问的register.php,也就是注册页面;还有一个看起来像后台或接口的路径,但访问后被重定向回了登录页。

这里有个很多人会忽略的细节:目录扫描结果一定要结合状态码来看。有些路径返回 200 但内容不对,有些返回 302 但其实是鉴权跳转,有些返回 403 反而说明路径存在。我当时扫出来的结果里就有两三个 200 页面,打开都是同一个登录框,说明是伪静态或路由重写,真正有用的就是注册页。

注册页本身长得很常规:用户名、密码、确认密码。我先注册了一个普通账号testuser / 123456,然后回登录页登录,成功了,说明正常业务逻辑是通的。这个环节其实就已经把题目最重要的两条信息拿全了:

  1. 登录时先查用户名,再查密码;
  2. 存在一个注册入口,可以往数据库里写数据。

2.3 关于源码泄露的猜想

按照往年网鼎杯的风格,Web 题经常会给源码附件,但unfinish这题没有。我在扫描时也会顺手测一下.git、.svn、www.zip、backup.sql这类常见泄露路径,结果都没有。也就是说,这题的设计意图就是让你纯黑盒打,不靠源码审计,而是靠行为推断后端逻辑。

这一步的结论是:入口就是登录 + 注册,突破口大概率在登录查询或注册写入的 SQL 语句里。既然注册能写数据、登录会查数据,那“先写入后查询”的链路天然就符合二次注入的利用场景。

如果你做这类题总是急着一上来就测注入,我建议你先花十来分钟把入口捋清楚。很多时候你找不到注入点,不是注入不存在,而是你还没找到那个“能二次触发”的入口。

3. 登录功能拆解:为什么直接注入打不进去,二次注入却可以

这一步是整道题的核心,也是很多选手卡死的地方。我先对登录框做了常规注入测试,然后详细分析后端可能存在的处理逻辑,最后解释二次注入的完整触发链路。

3.1 常规注入尝试全部失败:参数到底被怎么了

我在用户名框里输入:

admin' or '1'='1

结果页面报错或者说用户名不存在。输入:

admin' -- -

同样没有注入成功的迹象。我又用 burp 的 sqlmap 插件跑了一下,也跑不出有效注入点。

这并不是意料之外。登录框输入的用户名,后端大概率做了两件事:先用某个函数对输入做转义处理,再把处理后的字符串拼进 SQL 查询。如果它在拼接之前就做了转义,那单引号会被转成\',SQL 语句的语义就变了,' or '1'='1这种 payload 根本闭合不了单引号。

这里就涉及到网鼎杯这类比赛中非常经典的转义函数。很多题目里,开发者会用类似addslashes()这种函数处理用户输入,它会自动把单引号、双引号、反斜杠、NULL 字符前面都加上一个反斜杠。对直接拼入 SQL 的请求来说,这种防护确实能挡掉大部分常规注入。

所以,直接注入这条路基本堵死。但这不意味着题目无解——恰恰相反,它说明真正的注入点不在这一次查询里,而在“另一次查询”。

3.2 二次注入的核心原理:把“危险代码”存进去,等它下次被拼出来

二次注入(Second Order SQL Injection)的思路,简单说就是:攻击者第一次传入的数据,被转义后存入了数据库,但存储的是“转义前的原始语义”还是“转义后的字面量”,取决于数据库和代码的配合方式。等到系统第二次把这个数据从数据库里取出来,再次拼接到 SQL 语句里时,如果第二次拼接没有做防护,危险载荷就生效了。

我用一个生活化的例子来解释。想象你去银行填存款单,柜员会在你写的金额上加一道“防改写”的划线。你写的是1000,柜员收进去的是那张划了线的单子。但银行系统后来做转账时,如果直接从系统的记录里读你“当时填写的金额”,读出来的可能就是1000这个数字本身——划线已经没有了。

Web 里的情况也是这样:

  • 第一次写入:你提交admin',代码用转义函数处理后变成admin\',SQL 语句是INSERT INTO users (username) VALUES ('admin\''),数据库最终存进去的是admin'(注意,转义只是让语句不报错,实际存储的值还原成了原始的单引号)。
  • 第二次查询:用户登录时,系统把库里的admin'取出来,拼进SELECT * FROM users WHERE username = 'admin''。这一下就多出一个单引号,SQL 语句的引号配对彻底被打乱。

关键点在于:转义函数处理的是“输入的时刻”,而不是“存储后的数据”。库里的数据是不带反斜杠的原始内容,等到它被二次拼接到新语句里时,转义函数不会再次生效,于是注入就发生了。

3.3 注册和登录的配合:这就是那个“二次触发”的场景

unfinish的注册功能,就是把危险数据写进数据库的入口;登录功能,就是把数据取出来再次拼接 SQL 的地方。两者一配合,二次注入的链路就完整了。

我从实际测试中确认了这一点:注册时提交的用户名里如果包含单引号或反斜杠相关的特殊组合,注册本身不会报错(因为被转义处理),但等到用这个用户名去登录时,系统会因为 SQL 拼接错误而出现不一样的响应,比如“用户名不存在”变成数据库报错。

这里要提醒一个细节:注册的用户名往往还需要过一遍“用户名唯一性检查”。如果你注册admin',而这个用户名已经被别的前提条件占用了,注册会失败,导致你写不进数据。所以构造注入用的用户名时,要先注册一个简单的,确认能成功,再逐步加特殊字符测试临界点。

做完这一步,我基本确定了攻击方向:注册一个精心构造的用户名,让它在登录查询时逃逸出单引号,然后构造我们想要的 SQL 语句,最终读取 flag。

4. 绕过转义的 payload 构造:反斜杠吃引号这种事,真的存在

这道题最需要细抠的地方,就是如何构造一个“写入时不报错、读取时能注入”的用户名。

4.1 思路起点:让转义后的反斜杠改变引号的配对关系

先明确一个基础事实:addslashes这类函数,会在'前面加\,变成\'。而 SQL 解析器在收到转义后的语句时,会把\'解析成一个普通的单引号字符,而不会当作字符串结束符。

这就会出现一个非常有趣的逃逸场景:如果我在输入里自己就带着一个反斜杠,那么转义函数会把它变成\\(两个反斜杠),再加上后面的单引号被变成\',最终拼接到 SQL 里的一段可能长这样:

username = 'xxx\\'

这串字符让 SQL 解析器的状态变得很微妙。两个反斜杠会让解析器认为后面一个'不再是字符串结束符,而是被反转义成了普通字符。等到下一个真正的单引号出现时,字符串的实际结束点就被推迟了。

换句话说,我可以利用反斜杠来“吃掉”那个被转义函数加上的反斜杠,从而让原本被保护的单引号逃逸出来。这在 CTF 圈子里有个很经典的说法:用\去闭合 SQL 里的引号配对,制造一个逃逸的单引号。

4.2 在 unfinish 这道题里,最实用的构造方式

我当时实测下来的可行方式是:注册一个用户名为admin\(也就是 admin 后面跟一个反斜杠)的账号,密码随便填一个,注册成功。

然后,用admin\这个用户名去登录。此时,后端拼接出的 SQL 大概长这样:

SELECT * FROM users WHERE username = 'admin\' AND password = '...'

注意看:admin\后面的单引号被反斜杠转义成了普通字符,所以字符串并没有结束,它会继续往后找闭合的单引号。于是AND password = '这一整段,连同前面那些内容,全部变成了字符串内容的一部分,直到这一行最末尾的那个单引号出现,字符串才真正闭合。

这个时候,其实 SQL 的语义已经发生了错乱,但巧的是,它依然能执行,只不过查询条件和原本不同了。如果我们在用户名里加入更多构造,比如:

admin\'

就可能形成:

SELECT * FROM users WHERE username = 'admin\\'

这个结果有时会让反斜杠把后面的引号吃掉,产生我们要的逃逸效果。我实际测试时还发现,用户名里加几个反斜杠、在什么位置加,会对结果产生很大影响。因为 MySQL 解析\\和\'的规则不同,一个反斜杠和两个反斜杠的语义完全不同。

这一步我给一个非常实用的建议:不要凭脑补构造 payload,直接在本地搭个最小环境试。用 PHP 的addslashes函数处理你的输入,然后拼到一条 SQL 里,用 MySQL 跑一下看看解析结果。比你在靶机上盲试快得多。

4.3 从“用户名逃逸”到“注入数据读取”的转换

光让 SQL 语义错乱还不够,我们最终要做的,是拿到数据库里的敏感数据,比如存储的 flag、管理员密码、或者某个特定表里的内容。

我当时构造的目标很明确:让登录查询时,除了查用户名,还能顺带执行一段恶意子查询或联合查询。

比如,注册用户名时提交:

admin\' UNION SELECT 1,2,3 -- -

注册阶段,转义函数会把单引号转义,语句变成admin\\' UNION ...,入库后实际存的是admin\' UNION SELECT 1,2,3 -- -。这看起来很正常,注册也能通过。

等到登录时,这段数据被拼接进新查询:

SELECT * FROM users WHERE username = 'admin\' UNION SELECT 1,2,3 -- -' AND password = '...'

由于admin\里的反斜杠吃掉了前面那个单引号,导致字符串结束点后移,而\后面的单引号又闭合了字符串,结果UNION SELECT 1,2,3被当成了真正的 SQL 语句去执行。这个操作就实现了在“用户名字段”上落地一个注入点。

所谓1,2,3,是因为我们要知道目标查询到底查了多少列。UNION SELECT要求前后列数一致,列数不对会报错。通过调整数字个数,判断出当前表的列数,再做下一步数据提取。

我在实际做题时,是用ORDER BY来判断列数的:登录时用户名注册成admin\' ORDER BY 1 -- -、admin\' ORDER BY 2 -- -这样去试,分别看页面响应是否有变化。如果ORDER BY 3时报错,说明列数就是 2。这个方式比盲试UNION SELECT 1,2,3要稳。

4.4 为什么有些 payload 写了但没用:小心“唯一性检查”和“长度限制”

绕过了转义,不代表万事大吉。我在实测中遇到过两个非常容易踩的坑:

坑一:注册时系统会查用户名是否已存在。如果我注册的用户名在数据库里已经有类似的记录(比如第一次注册admin\时成功了,第二次想注册admin\加一段 payload,就会因为重名报错)。这时候不能继续在同一个用户上做叠加,而是要重新构造一个不冲突的字符串,比如admin\ + payload,注意整体唯一性。

坑二:很多登录用户名有长度限制。如果注册表单在前端限制用户名长度,而后端没有完全校验,我试过直接把 payload 塞进去,结果被截断,导致逃逸失败。这时候可以用不同长度、不同位置的 payload 分段测试;如果前端限制死了,可以考虑用 Burp 直接改包绕过前端长度限制。

这两个坑不解决,很容易让你在一个看似正确的 payload 上反复撞墙,还以为是思路错了。

5. 注入数据的两种后续利用:从报错信息到拖出整张表

拿到注入点之后,下一步就是从数据库里读数据。unfinish这题里,常见的两种情况是:flag 在数据库中某个表里,或者需要通过后台管理功能才能看到。我把自己尝试过的两条路径都写出来。

5.1 路径一:报错注入,用数据库的错误消息直接回显

当我注册用户名为admin\' AND (SELECT 1 FROM (SELECT COUNT(*), CONCAT((SELECT flag FROM flag_table), FLOOR(RAND(0)*2)) x FROM information_schema.tables GROUP BY x) a) -- -这类 payload 时,登录就会触发一条 SQL 报错,错误信息里通常会带上子查询的内容。

当时我注意到登录框对“用户名不存在”和“密码错误”有区分,就判断它可能把mysql_error()这类错误信息直接回显给了页面。于是我走了报错注入的路线,用报错信息来带出想要的数据。

但这里也有个问题:报错注入对查询语句的写法很敏感,如果目标 SQL 的上下文不是我们预想的SELECT,或者它对GROUP BY、COUNT这类函数有限制,报错注入可能打不出数据。我建议把报错注入当成一个“优先尝试但不要死磕”的选项,打不通就换盲注。

5.2 路径二:联合查询 + 盲注,稳扎稳打地把数据逐字提出来

如果报错注入不可用,那就走联合查询或盲注。

联合查询的关键是确定目标查询的列数。我上面提到用ORDER BY判断列数,确定好之后再构造:

admin\' UNION SELECT 1,2 -- -

如果页面能把查询结果里的某个字段渲染出来(比如登录后显示“欢迎你,xxx”),就可以把字段换成database()、group_concat(table_name)之类的函数,直接读出库名和表名。

如果页面不回显结果,那就只能走盲注。盲注的核心是构造布尔条件或时间延迟条件,从页面响应差异中推断数据。

比如判断数据库名第一个字符是不是f:

admin\' AND SUBSTR(database(),1,1)='f' -- -
  • 如果页面响应和“用户名不存在”不一样,说明条件为真;
  • 再用脚本逐字符、逐表、逐字段地提取。

我当时用的是布尔盲注,因为页面回显差异明显(“用户名不存在”和其他情况的提示不同),写一个 Python 脚本跑起来,几分钟就能把整张表拖出来。

我给一张当时实测用的字符集规律表,方便你判断哪些字符该优先试:

类型代表字符说明
小写字母a-z最常见,优先测
大写字母A-Z表名中常见
数字0-9字段值中常见
常见符号-,_,@,.库名表名可能出现
花括号{}flag 格式常见

盲注脚本的核心思路很简单:确定目标字符串长度,对每一位,遍历字符集,通过布尔条件判断该位字符,然后拼接结果。只要页面响应区分度足够,程序就能稳定跑完。

5.3 拿到 flag 的位置:常见表名和字段名的一个检查列表

CTF 题目里,flag 不一定叫flag,表名也不一定叫flag_table。我总结了一个常规检查顺序,做题时按这个顺序来找,效率最高:

  1. 先select database()确认当前使用的库名;
  2. 再查information_schema.tables,以flag为关键词模糊匹配表名;
  3. 如果找到候选表,查它的字段名,优先看flag、key、secret、content这类字段;
  4. 把字段值取出来,看看是不是目标格式的字符串。

有些题目的 flag 不在当前库,而在另一个库的表里,所以information_schema查询时要把所有库都覆盖到。我当时是先看了库名列表,再进到目标库里看表名,前后花了大概十几分钟。

6. 复现这题时最常卡住的几个点:我踩过的坑和排查链路

很多选手在网上看 writeup 时觉得“这题不难”,但自己复现时就是打不通。我把这题复现时最常见的几个卡点整理出来,按排查链路做了个小总结。

6.1 卡点一:用户名里的反斜杠被数据库“吞掉”了

我一开始构造admin\时,注册是成功了,但登录后没有任何注入痕迹。后来查了 MySQL 的数据存储规则才发现,在某些字符集和排序规则下,反斜杠并不会被原样保留,或者会被转义成其他内容。

排查方式很简单:注册一个用户名test\,然后找一种方式把数据库里的值读出来看看。如果你在登录页能看到“欢迎你,test\”或者类似显示,说明反斜杠被保留了;如果显示成test或test\\,说明存储环节已经发生了变化。

应对方法就是调节 payload 里的反斜杠数量。一个不行就两个,两个不行就用三个或四个,观察不同数量下登录响应的差别。我最终用的组合是“用户名主体 + 一个反斜杠 + 单引号”的结构,但具体数量还要看实际靶机的环境。

6.2 卡点二:注册时唯一性检查绕不过去

想在一个已经存在的admin\用户上叠加 payload,注册接口会提示“用户名已存在”。如果不重新注册一个全新的、名字更长的用户,你的 payload 永远不会生效。

我当时最开始踩的坑就是在这个环节纠结了很久,一直想“能不能利用现有用户做叠加注入”,结果发现注册接口根本不让我写入。

正确的做法是:把 payload 整体作为新用户名的一部分,注册一个新用户。比如admin\已经存在,那就注册xyz\作为新的注入载体,或者用admin\\(两个反斜杠)作为新用户名,再叠加后续的注入语句。只要保持整个字符串在数据库里是唯一的即可。

6.3 卡点三:盲注脚本跑得太慢,白白浪费时间

如果你走盲注路线,脚本的效率很关键。我最初写了个纯 Python 逐字符遍历的脚本,把abcdefghijklmnopqrstuvwxyz0123456789_-{}全塞进列表里,一个字符就要请求二十多次,拖了很久。

后来我优化成“二分法 + 优先字符集”的方式:先用SUBSTR(...,N,1) > 'm'这种比较判断位置,把字符集折半缩小,再对常见的flag{前缀直接猜。这样单个字符平均请求次数从 20 次左右降到了 5 次以内,整个拖表过程快了好几倍。

另外,盲注请求之间最好加一点短延迟,不要无脑并发,否则可能触发目标的风控策略,导致 IP 被临时限制。

6.4 卡点四:报错注入时,把查询写错了位置,导致一直没数据

用报错注入时,payload 是嵌在用户名里的,这意味着整个 SQL 语句的上下文比较复杂。我一开始把子查询直接写在AND后面,结果语法报错,页面直接变成了 500,连错误信息都不显示了。

排查后才发现,需要把子查询放在AND后面的表达式里,并且要和前面的内容用正确的括号配对。这类问题没有捷径,只能靠反复调整 payload 结构,或者先在本地搭同版本 MySQL 环境做验证。

分享一个快速验证的思路:把目标环境用的数据库版本和 PHP 版本搭一个 Docker 环境,把登录 SQL 语句完整模拟出来,本地测试 payload。一旦在本地能稳定触发,再拿到靶机上几乎不会失败。

7. 一些做这类题通用的收获

unfinish这道题本身挺老了,但它的考点在后续很多比赛中反复出现。我把从它身上得到的经验总结为三条,放在最后分享。

第一条,信息收集要耐心。登录框、注册页、报错信息差异、目录扫描结果,每一条单独看都是废线索,但组合起来就是完整的攻击链。不要因为页面简单就跳过大面积探测。

第二条,转义函数不等于安全。addslashes这类函数解决了“当前请求”的注入问题,但没有解决“数据在库中再次被使用时”的二次注入问题。任何把用户输入存进数据库、之后再被其他 SQL 语句查询的功能,都要警惕二次注入。这个点在真实业务里也很常见,比如昵称、地址、备注这类“写入一次、多处展示”的字段。

第三条,写完 payload 先本地验证再上靶机。网上很多 writeup 里的 payload 直接复制到新环境可能因为 PHP 版本、MySQL 版本、字符集、过滤规则不同而失效。本地搭一个最小环境,用同样的函数去处理输入,再观察 SQL 解析结果,是排查问题最快的方式。

我当时在这道题上花了大半天,最后梳理出完整链路时,最大的收获不是“会做这道题”,而是理解了 CTF 里二次注入的通用套路。以后再遇到“注册 + 登录 + 某个能查数据的功能”,我基本都会先想想:哪些输入会被写进数据库,又会被哪些后续查询重新拼出来。这才是unfinish真正想教会考生的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进