2025 “泉城杯” 济南市网络安全大赛 题目复现

2025 “泉城杯” 济南市网络安全大赛 题目复现
Xiaozhi_z其实就是去年大概七月的事情 感觉时间好快 马上又要比CTF了 准备拿一些之前没做出来的逆向密码深入学习一下下(
2025 “泉城杯” 济南市网络安全大赛 题目分享:https://blog.x-z-z.com/article/2025-07-26-19-30
Misc
b64
1 | 题目名称:b64 |
打开压缩包 内容如下
1 | REFTQ1RGe0hlbGxvX1dvcmxkfQ== |
暗示Base64解码 进行解码 得到答案

misc-pic-1-2
1 | 题目名称:misc-pic-1-2 |
解压压缩包 找到图片文件 右键点击属性

复制备注 进行解码 解题成功

ezusb
1 | 题目名称:ezusb |
这道题 打开发现是一个sercet.txt和flag.pcapng

打开后发现协议都为usb 用UsbKeyboardDataHacker解密

用UsbKeyboardDataHacker解密数据包
1 | python3 UsbKeyboardDataHacker.py --input ~/Downloads/ezusb/flag.pcapng |

1 | congratulations,you<SPACE>finlly<SPACE>find<SPACE>me,but<SPACE>what<SPACE>i<SPACE>want<SPACE>to<SPACE>tell<SPACE>you<SPACE>is<SPACE>that<SPACE>roman<SPACE>roland<SPACE>once<SPACE>said<SPACE>thar<SPACE>there<SPACE>is<SPACE>only<SPACE>one<SPACE>kind<SPACE>of<SPACE>heroism<SPACE>in<SPACE>the<SPACE>worlld,that<SPACE>is<SPACE>to<SPACE>know<SPACE>the<SPACE>cruelty<SPACE>of<SPACE>the<SPACE>life<SPACE>but<SPACE>still<SPACE>love<SPACE>it.<CAP><CAP>ok,<CAP><CAP>get<SPACE>to<SPACE>the<SPACE>point:the<SPACE>{}_<SPACE>three<SPACE>symbols<SPACE>were<SPACE>added<SPACE>to<SPACE>the<SPACE>front<SPACE>of<SPACE>the<SPACE>base64<SPACE>table<SPACE>and<SPACE>handed<SPACE>to<SPACE>caesar.if<SPACE>you<SPACE>can<SPACE>decrypto<SPACE>the<SPACE>sercet<SPACE>you<SPACE>can<SPACE>get<SPACE>the<SPACE>half<SPACE>of<SPACE>flag. |
可以得到 base64的编码表前需要加入 “{}_” 而且之前正好提供了一个密文

用随波逐流 再配合表前加 “{}” 得到flag前段 DASCTF{JUST_3aSY_Ca3SaR_aND_MaUSE

提取设备的按键码
1 | tshark -r flag.pcapng -T fields -e usb.capdata > usbdata.txt |
用python脚本解析里面不同的位 并翻译成二进制 最后转ascii码
1 | with open('usbdata.txt', 'r') as f: |
最后getflag的尾段 Keyb0rad_@nd_USB!}

将flag组合一下可得 DASCTF{JUST_3aSY_Ca3SaR_aND_MaUSEKeyb0rad_@nd_USB!}
Reverse
ezre_1
1 | 题目名称:ezre_1 |
先查一下有没有壳 发现有upx壳

用upx直接脱壳即可
1 | .\upx.exe -d ezre.exe |

丢到ida 看样子反编译成功啦

点进check 看一下函数逻辑
1 | int check() |
预设正确的密文是 Str2 = "aOYanlkVkemSmRgYlWi0Nc1P3JPIfoMoQJ2I20w="
对输入进行 Base64 编码 v8 = base64_encode(Str, v7, v0)
再经过 chang() 函数处理 Str1 = chang(v8) 最后有个比较
这样的话再进入chang函数看逻辑即可

1 | char *__fastcall chang(const char *a1) |
大概含义是把输入字符串中的字符按照“奇偶位置”分成两段,然后拼接在一起 写一个简单的脚本来解密一下就好
我直接跑了一次 发现怎么样都是乱码

把思路再回到ida 去跟进base64 encode函数

找到这个base64表再跟进 发现不是标准的表

再次编写脚本 这一次带上自定义base64表再进行解码
1 | import base64 |
成功getflag!

go_bytes
1 | 题目名称:go_bytes |
ida 打开 main的伪代码如下
1 | // main.main |
v13 是程序里写死的常量表 tmp 是一个不断更新的 16 位数 enc[j] 是你真正要还原出来的中间结果
1 | main_tmp = (unsigned __int16)(291 * main_tmp + 1110); |
先从程序常量里拿到 v13 关键看以下伪代码
1 | ((void (__fastcall *)(_QWORD *, void *))loc_45F168)(v13, &unk_4DE3D0); |
说明 v13 这张表的数据,就放在 .rdata 里的 unk_4DE3D0 这个位置。
进入之后 每 8 个 db 看成一组 取前两个有效字节 按小端序倒过来写成十六进制数

然后将v13转换成的十六进制数先写出来
1 | v13 = [ |
前面已经从 .rdata 里把 v13 提出来了,后半段校验逻辑是:
1 | main_tmp = (unsigned __int16)(291 * main_tmp + 1110); |
简单变形一下就是
1 | tmp = (291 * tmp + 1110) & 0xffff |
v13[j] 是程序里存好的常量 tmp 每轮按固定公式更新 enc[j] 就是这一轮真正应该得到的中间字节
enc[i] 的高四位,来自当前字符 enc[i] 的低四位,来自下一个字符
1 | inp[i] = ((enc[(i-1)%40] & 0x0f) << 4) | ((enc[i] >> 4) & 0x0f) |
所以接下来用 v13 和 tmp 更新公式算出 enc 再用 enc 反推出原始输入
1 | v13 = [ |
getflag

测试一下也是正确的flag

Crypto
Old_story
1 | 题目名称:Old_story |
这道题题目上说的挺清楚的 用Atbash和Cot13加上栅栏提示里面有三 作为key解码即可
这道题不要用随波逐流 我当时用这个解码出不来 不知道为什么

魔棒点一下 base64解密 得到flag

withoutN
1 | 题目名称:withoutN |
解压后打开如下
1 | from Crypto.Util.number import * |
之前打CTF密码学一般就AI一把嗦了 但线下赛的本地小模型还是有点太笨了 发现自己一点密码都不会(这一次主要是看一下逻辑 等有机会打线下至少有点思路
将hint转换成n
原本的常规RSA密码学解题思路是 知道 N 把 N 分成 p*q 然后就能推出私钥进行解密
这道题就给了hint 把思路放在生成hint的代码
1 | hint = [] |
一个简单的循环语句 三个hint实际上就是
1 | hint[0] = 2^e mod N |
意思就是三个hint 分别是2 4 16的e次方对N取模
但是因为2 4 16 本身就是次方关系 是有递推关系
2的二次方是4 4的二次方是16
1 | hint[0]^2 ≡ hint[1] (mod N) |
现在算一个小学数学题( 假设 N = 5 我们看两个数:12 和 7
它们分别模 5:
1 | 12 mod 5 = 2 |
也就是说,12 和 7 除以 5 的余数一样。 那它们一减 刚好就是 5 的倍数。
1 | 12 - 7 = 5 |
再看一个 还是 N = 5
1 | 22 mod 5 = 2 |
余数还是一样 那它们相减 10 也是 5 的倍数。
1 | 22 - 12 = 10 |
可以得到规律 如果两个数除以 N 的余数相同,那么它们的差一定能被 N 整除。
hint[0]^2和hint[1]除以N的余数一样,所以 它们的差 是N的倍数
1 | hint[0]^2 ≡ hint[1] (mod N) |
同理hint[1]^2 和 hint[2] 除以 N 的余数一样,所以 它们的差 也是 N 的倍数
1 | hint[1]^2 ≡ hint[2] (mod N) |
既然这两个大数里都藏着 N,那最自然的想法就是去求它们的最大公约数,也就是 gcd
1 | from math import gcd |
这题实际算出来的是 3N,所以再除以 3 就能恢复真正的模数
1 | N = g // 3 |
Pollard p-1分解n
原本题目没有直接给出的 N 就找到啦 去看一下p和q的生成原理
意思大概是从 2 开始,不停随机找一些小素数,然后把它们一个一个乘起来,直到这个乘积差不多够大为止。
1 | def get_special_prime(state, bits, imbalance=19): |
也就是说,这里先得到的还不是最终的素数,而是一个由很多小素数乘出来的大整数。
后面代码又会再乘两个素数,并检查 tmpp + 1 是否为素数。如果是执行以下代码
1 | p = tmpp + 1 |
转一下符号可以得到
1 | p - 1 = tmpp |
也就是说,p-1 其实是由很多小素数再乘上两个中等素数构成的,q-1 也是同样的结构。
这就说明,这题的 p 和 q 不是普通随机生成的素数,而是故意让 p-1、q-1 带有很多小因子。这样的结构正适合用 Pollard p-1 去分解。
得到 p 和 q 后,后续就回到了普通 RSA 的解题流程:先求欧拉函数 phi,再求私钥 d,最后对密文 c 进行解密。
1 | from math import gcd |
getflag

babysm2
1 | 题目名称:babysm2 |
解压后打开如下
1 | from Crypto.Util.number import * |
分析代码
flag 被切了一部分作为 SM2 私钥 sk
1 | flag = b"xxx" |
整个 flag 被 RSA 加密,指数 e=3
1 | p = getPrime(512) |
SM2 签名函数 签名 S 同时依赖 私钥 d 和 随机数 k
1 | def sign(self, data, K): |
两次签名使用的 k 有关系
1 | msg1 = getrandbits(250) % int(ecc_table['n'], 16) |
解题思路
SM2 部分
1 | msg1 = getrandbits(250) % int(ecc_table['n'], 16) |
两次签名的随机数 k 存在线性关系
S 同时依赖 k 和私钥 d
1 | k2 = 1234*k1 + 56789 |
利用两组签名 (R1,S1), (R2,S2) k2 与 k1 的线性关系 联立方程消掉 k 解出私钥 d(sk) 即可得到flag高位
1 | sk = flag #flag高位部分 |
RSA 部分
指数 e = 3 RSA低指数攻击即可
1 | p = getPrime(512) |
编写脚本
导入工具并读取题目给出的所有参数
1 | from Crypto.Util.number import * |
把签名拆成 (R, S)
1 | def split_sig(sig): |
利用 k 的关系解出 SM2 私钥 d
1 | A = (S2 + R2) - 1234 * (S1 + R1) |
RSA e=3,直接开三次方恢复 flag
1 | flag_int, _ = iroot(c, 3) |
最终的完整脚本如下
1 | from Crypto.Util.number import * |
Getflag!

