做Web方向CTF的朋友,对“攻防世界”这个靶场应该不陌生。我是一路从新手题刷过来的,印象最深的倒不是那些一两步就能出flag的送分题,而是像web2这样“把源码甩在你脸上,让你逆向出flag”的题目。它不考SQL注入,也不考XSS,就考你面对一段陌生PHP代码时能不能沉住气做代码审计,能不能把加密逻辑一步步剥开。这篇文章就把我做攻防世界web2这道题的完整过程、踩过的坑以及背后的审计思路写下来,适合刚接触CTF、准备打比赛、或者想系统学PHP代码审计的朋友。
我当时看到题目页面的第一反应是:整个页面只有一个PHP文件,没有输入框,没有URL参数,没有任何传统意义上的漏洞点。页面顶部是一串长长的密文,下面是highlight_file(FILE)打印出来的源码,以及一行注释“逆向上述算法”。这种题目在CTF里非常典型,核心不是“找漏洞”,而是“读代码、逆算法”。把这一道吃透,后面再碰到给源码的加密题,基本都能举一反三。
1. 拿到题目后的第一眼:源码审计与考点拆解
1.1 题目给到的原始代码
我复刻的版本大致是这样:
<?php $miwen="a1zLbgQsCESEIqRLwuQAyMwLyq2L5VwWxLlwiE4pqJZiCsJ41gwLhQrB8YkyCCH5Gx0VvMxzG5VtXyFzN1c="; function encode($str){ $_o = strrev($str); for($_0=0; $_0<strlen($_o); $_0++){ $_c = substr($_o, $_0, 1); $__ = ord($_c) + 1; $_c = chr($__); $_ = $_c . $_; } return str_rot13(strrev(base64_encode($_))); } highlight_file(__FILE__); /* 逆向上述算法 */不同平台的复刻版本在变量名上可能有出入,但算法骨架是一致的。页面顶部那一串密文放在$miwen变量里,它是我们最终要还原的目标;encode函数负责把任意字符串加密成密文;highlight_file(__FILE__)把当前文件源码直接输出到浏览器,所以整段逻辑在页面上暴露无遗;最后的注释说得很明白——要你把算法逆向回去。这种情况下,题目的信息量其实非常大:密文有了,算法有了,甚至连方向都告诉你了,剩下的就是阅读代码和推导逆运算。
这种给源码的题目,出题人通常不会故意把代码写得看不清,真正难的是阅读逻辑和还原编码链路。很多新手一看到str_rot13、ord这些函数就慌,其实拆开看,每个函数单独拎出来都很基础。关键在于能不能沉住气,把函数调用链一步步捋清楚。
1.2 逐行拆解encode函数
把encode函数拆开看,它是五个步骤的流水线。
第一步,strrev($str)把字符串反转。比如abc变cba。这一步很关键,因为后面所有处理都基于反转后的字符串,逆向的时候要特别注意把它还原。
第二步,进入for循环。循环从字符串索引0开始,逐个取出字符,用ord()取到ASCII码值,加1,再用chr()转回字符。这步的本质就是“原字符串每个字符的ASCII码加1”。可能有人会疑惑,循环里有个拼接操作$_ = $_c . $_,它把新字符放在已有字符串前面,这样顺序会不会乱?我用abc验证过:反转后是cba,字符加1后逐个往前拼,最后得到bcd,顺序恰好又回到了初始顺序。所以可以放心把它理解为逐字符加1。
第三步,对结果做base64_encode。得到一串包含大小写字母、数字、+、/和末尾可能带=的base64字符串。
第四步,再次strrev,把base64字符串整体反转。
第五步,执行str_rot13。这是ROT13加密,一种凯撒位移,英文字母位移13位。因为字母正好26个,ROT13自逆,连续执行两次等于没处理。它只影响字母,数字和符号保持不变。
整条链路就是:字符串逐字符ASCII加1,转base64,反转,再做ROT13。
1.3 这道题到底在考什么
有人可能会问,这道题里没有任何注入点,也没有疑似漏洞的函数,它想考什么?我整理了一张考点表。
| 考点 | 考察目的 |
|---|---|
| PHP字符串函数 | 是否熟悉strrev、ord、chr、substr等基础函数的行为边界 |
| 编码算法识别 | 能否识别出base64、ROT13、字符位移这些常见编码 |
| 逆向思维 | 能否根据正向算法推导出完整的逆运算顺序 |
| 代码审计习惯 | 面对陌生函数时,能不能逐行读、逐行验证,而不是只看个大概 |
这张表放在一起看就清楚了,这道题不是要你利用漏洞,而是要你具备“把代码当作数据处理流程”的能力。CTF的Web题目并不只有SQL注入和XSS,大量源码审计题都在考察这种基本功。刷题时如果只看现成exp而不自己走一遍,这道题的价值就浪费了一大半。
2. 逆推思路与准备工具
2.1 先画逆向流程,不要急着写脚本
我见过不少朋友拿到这类题第一反应就是找在线解密工具,把密文丢进去碰运气。我不推荐这么干,因为在线工具很难一次猜中编码组合。更稳妥的做法是把整条编码链画成一张流程表,然后从最后一个操作开始逐个逆推。
一个很合适的类比是穿衣服:早上穿衣服是从内到外,晚上脱衣服就得从外到内。编码也是这样,str_rot13是最后一步,逆向时就要最先处理。我习惯在纸上画两列,左边是正向顺序,右边是逆向顺序,中间用箭头连起来。画完之后思路会非常清晰,也不容易漏掉反转操作。
2.2 工具选择:Python脚本比PHP调试更顺手
解这类题,我推荐直接写Python脚本。第一,Python对base64、ROT13都有现成库支持,代码量很少;第二,Python交互式环境可以一行一行验证中间结果,比在PHP里反复跑函数方便得多。
当然,如果你手头有PHP环境,也可以把源码复制下来直接执行,尤其适合观察原函数每一步的输出。但最终逆向脚本我建议用Python,因为它的字符串和字节处理逻辑更直观,调试成本更低。在线站点最多作为辅助验证,不建议作为主要解法,尤其是密文可能被第三方获取,而且在线工具很难处理多步自定义算法。
2.3 用短字符串先验证逻辑
在真正解密之前,强烈建议先用短字符串把“正向加密-逆向解密”的闭环跑通。我用的是test:用题目的正向算法加密得到密文,再用自己写的逆向逻辑解密,如果最终回到test,说明逆向流程没搞反。
这一步很多人会省略,结果就是一解真实密文就乱码,然后怀疑人生。其实只要花一分钟先自测一把,顺序问题立刻暴露。我写脚本时先用这个测试用例跑通了,才去处理题目密文。这个习惯后来帮我解决了很多变体题,比盲目改参数高效得多。
3. 完整解密实操
3.1 逆运算的具体步骤
现在正式逆向。按照“后进先出”的原则,decode过程分四步:
- 执行
str_rot13,还原到反转前的结果。ROT13是自逆算法,直接对密文操作即可。 - 执行
strrev,把base64字符串的顺序恢复原样。 - 执行
base64_decode,得到被ASCII+1处理过的那段字符串。 - 遍历每个字符,把ASCII码值减1,还原原始明文。
这里唯一的坑是很多人容易纠结第一步和第二步之间要不要先反转。我的判断方法很简单:看正向算法最后一个操作是str_rot13,那逆向第一步就是str_rot13;正向倒数第二步是strrev,那逆向第二步就是strrev。逐个对位,很难出错。
3.2 一跑到底的Python脚本
下面是我实际使用的解密脚本:
import base64 import codecs miwen = "a1zLbgQsCESEIqRLwuQAyMwLyq2L5VwWxLlwiE4pqJZiCsJ41gwLhQrB8YkyCCH5Gx0VvMxzG5VtXyFzN1c=" # 第一步:ROT13解码 tmp = codecs.decode(miwen, 'rot13') # 第二步:反转 tmp = tmp[::-1] # 第三步:base64解码 tmp = base64.b64decode(tmp).decode() # 第四步:每个字符ASCII码减1 flag = ''.join(chr(ord(ch) - 1) for ch in tmp) print(flag)脚本逻辑很直白:先做codecs.decode(miwen, 'rot13')完成ROT13解码,再反转,再base64解码,最后逐字符减1。要注意base64.b64decode返回的是bytes对象,所以加.decode()转成字符串方便遍历。
把题目里的密文填进miwen变量,运行脚本后,终端直接输出原始flag,常见格式是cyberpeace{...}。我在自己的环境里跑完直接拿到了flag,整个过程不超过一分钟。如果读者在其他复现站点里密文不同,把变量替换掉就行。
3.3 分步打印与中间结果验证
为了让过程更直观,我把同样的逻辑分步打印出来,这里用测试明文test做演示:
import base64 import codecs def encode(s): tmp = s[::-1] res = '' for ch in tmp: res = chr(ord(ch) + 1) + res res = base64.b64encode(res.encode()).decode() return codecs.encode(res[::-1], 'rot13') def decode(s): tmp = codecs.decode(s, 'rot13') tmp = tmp[::-1] tmp = base64.b64decode(tmp).decode() return ''.join(chr(ord(ch) - 1) for ch in tmp) plain = "test" secret = encode(plain) print("加密结果:", secret) print("解密结果:", decode(secret)) print("是否还原:", decode(secret) == plain)我本地跑出来的输出很有意思:test被加密成==Dq0MJq,再解密回去仍然得到test,是否还原显示True。这就证明了整套逆运算顺序没有错。如果中间任何一步处理顺序反了,这个测试会直接给出乱码。所以以后遇到类似题目,完全可以把这段测试脚本当模板,先自测再解真实密文。
3.4 用PHP再跑一遍确认
虽然Python脚本已经够用,但有的朋友觉得PHP环境更顺手,毕竟源码本身就是PHP。我用PHP也写了一个版本:
<?php $miwen = "a1zLbgQsCESEIqRLwuQAyMwLyq2L5VwWxLlwiE4pqJZiCsJ41gwLhQrB8YkyCCH5Gx0VvMxzG5VtXyFzN1c="; $tmp = str_rot13($miwen); $tmp = strrev($tmp); $tmp = base64_decode($tmp); $flag = ''; for ($i = 0; $i < strlen($tmp); $i++) { $flag .= chr(ord($tmp[$i]) - 1); } echo $flag; ?>这个脚本和Python版本完全等价。base64_decode在PHP里直接返回字符串,不需要额外转码。用PHP执行一次,输出结果和Python版本一致。两个脚本我都跑过,可以互相验证,也算是一种交叉确认。尤其是当你对某个平台的执行结果存疑时,换个语言跑一遍往往能更快定位问题。
4. 常见问题与排查技巧实录
4.1 解密顺序搞反怎么办
我踩过最深的坑就是把反转和base64解码的顺序搞反。当时我拿到密文直接先base64解码,得到一串看起来有规律的字母,再反转再减1,结果全是乱码。后来我用test做测试,把正向加密每一步的中间结果都打印出来,才发现base64解码必须紧跟在反转之后。
如果遇到乱码,不要改参数瞎试,先回到测试明文验证。用assert decode(encode(明文)) == 明文这种方式跑一遍,如果返回False,说明逆向顺序有问题。剩下的就是逐个调整步骤顺序,直到断言通过为止。这是效率最高的排查方式,比盯着密文猜测强太多。
4.2 base64解码报错或输出bytes
Python的base64.b64decode返回bytes对象,直接按字符遍历会得到数字,所以必须加.decode()。但解密过程中如果出现不可见字符,直接用utf-8解码可能抛异常。这时候可以改用latin1解码,或者干脆不转字符串,直接遍历bytes做ASCII减1操作。
raw = base64.b64decode(tmp) flag = bytes([b - 1 for b in raw]).decode()这种做法更健壮,因为bytes本身就是字节数组,每个元素对应一个ASCII码值,减1之后用bytes()重新组装,最后再统一解码。我后来处理带特殊字符的密文时优先用这个写法,基本不会翻车。如果密文里本身包含非ASCII字符,按字节处理还能避免编码猜测带来的偏差,可控性会好很多。
4.3 密文变量从哪里拿
不同版本的web2题目,密文位置可能不太一样。有的放在页面顶部,有的放在PHP注释里,还有的需要打开浏览器开发者工具,在源码中找$miwen的赋值位置。如果页面引入了外部文件,比如include了某个配置脚本,密文也可能藏在那个文件里。
遇到这种情况,别只看当前文件,要多点几步把PHP文件都下载下来,搜索miwen或者相似关键词。我在刷题时习惯给每道题建立单独的目录,遇到关键源码就留档,方便复盘和对比版本差异。这个习惯看起来简单,等你要写题解或者复现旧题时就知道多省事了。
4.4 其他类型的web2变体
攻防世界平台上的web2并不只有我上面讲的一种,不同赛季、不同复刻站里可能被改成别的考点。我见过比较常见的有:把ROT13换成凯撒位移、把ASCII+1换成一串自定义字符替换表、在base64之前混入URL编码、甚至把明文拆成两段分别处理再拼接。
不管变体怎么改,解题流程都一样:先把源码里每一步操作画出来,标注输入输出格式;从最后一步逆向推导到第一步;用短字符串做闭环验证。这四步走完,绝大多数变形都能解。如果题目涉及自定义字符替换,无非是在逆运算里加一张反向映射表而已,思路并没有变化。
5. 从web2延伸出来的Web审计能力
5.1 给源码题为什么高频出现
不少新手以为CTF Web就是注入和XSS,实际上源码审计题在各大平台里占比非常高。原因很简单:真实业务里的安全问题,大多藏在一堆业务代码里,不会像靶场那样给你一个明显的注入参数。出题人用web2这种“给源码、逆向算法”的形式,训练的就是选手面对未知代码时的处理能力。
这种能力到了复杂题目里依然有用。比如攻防世界后续的web3、web4中,经常出现加密混在业务逻辑里的情况,你不把算法读明白,连参数传什么都搞不清楚。所以web2虽然难度不大,但它建立的读码方法,是可以直接迁移到更高级别题目上的。
5.2 相关考点延伸与自查清单
刷完web2之后,建议按这张清单自查:
| 自查点 | 具体内容 |
|---|---|
| PHP字符串函数 | 能否准确说出strrev、substr、ord、chr的行为 |
| base64特征 | 能否一眼看出末尾=和字符集特征 |
| ROT13特征 | 能否识别只有字母变化、符号不变的加密结果 |
| 逆向顺序 | 能否画出正向和逆向的完整流程图 |
| 脚本验证 | 能否用短字符串闭环测试,确保脚本逻辑正确 |
这五点都能做到,基本就合格了。如果还有不熟的地方,可以自己改造encode函数,比如把ROT13改成ROT5、把ASCII+1改成ASCII-1,再写一遍解密脚本,练到完全流畅为止。这种主动给自己出变体的练习方式,比刷十道重复题都管用。
5.3 刷题建议与脚本库沉淀
最后给正在刷题的朋友一个很实用的建议:把常用解码脚本沉淀成本地工具库。我会在本地维护一个solve.py,里面包含base64、URL编码、ROT13、十六进制、字符位移等常见编解码的封装函数,每次遇到编码题直接调用,省去重复造轮子。
做web2这道题时,我的脚本库已经写好了base64和rot13函数,所以整个解密脚本只有十几行。如果刚开始刷题,不要急着追求效率,先把每个函数的实现原理搞清楚,再逐步积累。刷到二三十道Web题之后,你会发现很多题目都是在组合使用同一个工具箱,到时候速度自然就上来了。
最后说一点个人体会。做完web2之后,我给自己定了一个规矩:凡是看到“给源码,求flag”的题,先在纸上把函数调用链画出来,标清楚哪一步是正向、哪一步要逆向,再用短字符串验证一遍。这个习惯确实是靠这道题养成的,后来刷攻防世界的后续Web题,遇到变体就没再在顺序上栽过跟头。希望这篇记录能帮你少走几步弯路。