天山固网 2026 个人赛部分题目 Writeup
本文整理了本次个人赛中部分题目的解题思路、关键步骤与复现代码,同时记录了比赛中的尝试与复盘。赛后借助 AI 完成的题目已在标题中注明。
简单加解密
题目给出的字符串为:
aGVsbG9fZ3diXzIwMjY=
对其进行 Base64 解码,即可得到结果。
PNG 隐写
图片显示不完整,题目提示也指向“内容并非完整”。结合这两点,可以考虑 PNG 高度字段被修改的情况。
使用 010 Editor 打开图片,修改 PNG 文件头中的高度字段后,隐藏的图像区域便会显示出来,从中可以读出 flag。
打不开的压缩包
对压缩包进行纯数字密码枚举,得到密码后即可解压。
比赛时,我最初怀疑这是压缩包伪加密,在这个方向上花了不少时间。这道题也提醒我:遇到加密压缩包时,可以先尝试常见的弱口令和简单密码规则,再排查其他情况。
WebShell
解压附件后,使用 D 盾扫描文件,定位到木马文件 .shell.php。查看该文件可得到 WebShell 的连接密码,该密码即为本题的 flag。
简单 Python 逆向
附件是一个 .pyc 文件,即 Python 编译后的字节码文件。
如果有匹配版本的反编译工具,可以将其反编译为 Python 代码,再分析其中的处理逻辑。
没有反编译工具时的解法
比赛时没有找到合适的反编译工具,因此我尝试通过修改输入、观察输出的方式,建立明文字符与密文字节之间的映射。
以文本方式查看文件,可以发现其中包含用于测试的 flag 字符串。文件名中的 310 提示其对应 Python 3.10,使用该版本的解释器即可运行。
随后,将用于测试的一段字符替换为 aaaa,再次运行程序。修改时应进行等长替换,保持文件总字节数不变,避免破坏字节码文件结构。
输出中对应的密文为 55 55 55 55,因此可以确定:明文字符 a 对应密文字节 0x55。
按同样的方法,可以编写脚本遍历 a-z、A-Z 和 0-9,收集字符映射。本题 flag 花括号内的内容仅涉及十六进制字符,因此实际只需测试
0-9 和 a-f。
还原 flag
得到映射关系后,将 out.txt 中的密文按字节拆分,再逐一映射回明文即可。
固定前缀 flag{ 对应 54 5a 55 53 4f,右花括号 } 对应 49。其余字符的映射如下:
| 明文字符 |
0 |
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
a |
b |
c |
d |
e |
f |
| 密文字节(十六进制) |
06 |
05 |
08 |
07 |
02 |
01 |
04 |
03 |
0e |
0d |
55 |
58 |
57 |
52 |
51 |
54 |
待还原的密文为:
545a55534f520305550305520105050e55510757025152025457020251065852055507070249
还原脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| m = "flag{0123456789abcdefg}"
mc = "545a55534f06050807020104030e0d5558575251545349"
c = "545a55534f520305550305520105050e55510757025152025457020251065852055507070249"
mcl = [mc[i:i + 2] for i in range(0, len(mc), 2)]
cl = [c[i:i + 2] for i in range(0, len(c), 2)]
for i in range(0, len(cl)): print(m[mcl.index(cl[i])], end="")
|
最终得到:
flag{d71a71d5118ae3c4ed4fc44e0bd1a334}
简单 PCAP 分析
使用 CTF-NatA 或 Wireshark 分析流量,导出 FTP 传输的文件。
检查导出的 sgcc1486 文件,发现其文件头符合 ZIP 格式。将扩展名改为 .zip,解压后得到一串编码文本。
依次进行 Base32、Base85 解码,即可得到 flag。
神奇的图片(赛后 AI 解题)
本题的隐藏数据位于像素的不同颜色通道中。下面的脚本从图片第一行提取数据,具体步骤如下:
- 从左到右读取第一行像素。
- 对每个像素依次提取
R & 1、(G >> 1) & 1、(B >> 2) & 1,拼接为位流。
- 每 8 位按高位在前的顺序转换为一个字节。提取结果以
50 4b 03 04 开头,说明其中包含 ZIP 数据。
- 定位 ZIP 结束记录,截取完整压缩包,再读取其中的
flag.txt。
本题 ZIP 文件没有注释,因此脚本在找到结束记录标记后,继续保留 22 字节即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| from PIL import Image
from io import BytesIO
import zipfile
img = Image.open(r"C:\Users\jack8\Downloads\神奇的图片 (1)\chall1.png")
bits = ""
for x in range(img.width): r, g, b, a = img.getpixel((x, 0))
bits += str(r & 1)
bits += str((g >> 1) & 1)
bits += str((b >> 2) & 1)
data = bytearray()
for i in range(0, len(bits) - 7, 8): byte = int(bits[i:i + 8], 2)
data.append(byte)
end = data.find(b"PK\x05\x06")
if end == -1: raise ValueError("没有找到 ZIP 结束标记")
zip_data = data[:end + 22]
with zipfile.ZipFile(BytesIO(zip_data)) as z: flag = z.read("flag.txt").decode()
print(flag.strip())
|
Redacted(赛后 AI 解题)
比赛期间,我主要发现了以下线索。
附件中有一份 PDF。使用编辑工具打开后,移开遮挡文字的黑色矩形,即可看到第一部分内容。
此外,附件中还有一个 .bin 文件和两个密码。我当时也沿着这些线索进行了尝试,但赛后确认它们都是干扰项。
比赛期间未能找到第二部分内容。回头复盘,还是对已有线索检查得不够细致,相关截图记录如下。
Dex in the Box(赛后 AI 解题)
反编译 APK 后,可以看到程序的加载与校验逻辑。比赛时,我已经查看了相关代码,但由于逻辑较复杂,未能完成逆向推导。赛后借助 AI
进一步梳理,得到以下解题流程。
- 从 APK 中读取
assets/stage1.bin 和 assets/payload2.bin,使用对应密码派生密钥,完成 3DES-CBC 解密。
- 按照加载器的处理方式,修复解密结果的 DEX 文件头及校验值,得到
stage2.dex 和 checker.dex。
- 从最终校验器
Checker 的静态初始化方法中提取 K、S、M、T 四个数组。
- 对每个目标数据块逆序执行 16 轮运算:异或
M、循环右移 13 位、减去 S,再异或 K。
- 将恢复的数据按小端序拼接,去掉末尾 6 个零字节,得到长度为 42 的 flag;最后按原始校验逻辑正向计算,核对全部 6 个数据块。
完整脚本如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136
| """用法:python solve.py mob1.apk
依赖:pip install pycryptodome androguard
"""
import hashlib
from pathlib import Path
import struct
import sys
import zipfile
import zlib
from Crypto.Cipher import DES3
from Crypto.Util.Padding import unpad
from loguru import logger
logger.remove()
from androguard.core.dex import DEX
OUTPUT = Path(__file__).resolve().parent
MASK = (1 << 64) - 1
def unpack_dex(data, password, magic):
assert data[:4] == magic
key = hashlib.sha256(password).digest()[:24]
cipher = DES3.new(key, DES3.MODE_CBC, data[4:12])
dex = bytearray(unpad(cipher.decrypt(data[12:]), 8))
dex[:8] = b'dex\n035\x00'
dex[12:32] = hashlib.sha1(dex[32:]).digest()
dex[8:12] = struct.pack('<I', zlib.adler32(dex[12:]) & 0xffffffff)
return bytes(dex)
with zipfile.ZipFile(sys.argv[1]) as apk: stage2 = unpack_dex(apk.read('assets/stage1.bin'), b'd3x::b0x', b'STG1')
checker = unpack_dex(apk.read('assets/payload2.bin'), b'st4g3::tw0', b'STG2')
(OUTPUT / 'stage2.dex').write_bytes(stage2)
(OUTPUT / 'checker.dex').write_bytes(checker)
dex = DEX(checker)
cls = next(c for c in dex.get_classes() if c.get_name() == 'Lcom/ctf/mob1/deep/Checker;')
init = next(m for m in cls.get_methods() if m.get_name() == '<clinit>')
arrays = []
for instruction in init.get_code().get_bc().get_instructions():
if instruction.get_name() == 'fill-array-data-payload': raw = instruction.get_data()
arrays.append(struct.unpack('<' + 'Q' * (len(raw) // 8), raw))
K, S, M, T = arrays
assert [len(a) for a in arrays] == [16, 16, 16, 6]
plaintext = bytearray()
for target in T:
x = target
for i in range(15, -1, -1): x ^= M[i]
x = ((x >> 13) | (x << 51)) & MASK
x = (x - S[i]) & MASK
x ^= K[i]
plaintext.extend(x.to_bytes(8, 'little'))
assert plaintext[42:] == bytes(6)
flag = plaintext[:42].decode('ascii')
assert len(flag) == 42
for block in range(6):
x = int.from_bytes(plaintext[block * 8:block * 8 + 8], 'little')
for i in range(16): x = ((x ^ K[i]) + S[i]) & MASK
x = ((x << 13) | (x >> 51)) & MASK
x ^= M[i]
assert x == T[block]
(OUTPUT / 'flag.txt').write_text(flag + '\n', encoding='utf-8')
print(flag)
print('全部 6 个数据块校验通过。')
|
TitanCard(赛后 AI 解题)
附件中的 task.py 用于生成具有 ROCA 类结构的 RSA 素数。虽然题目更换了素数表,但并未消除这种结构;指数范围只有 20
位,也使枚举成为可行方案。本题可以结合指数枚举与二维格的 Gauss 约化恢复 RSA 因子,无需使用通用 Coppersmith 实现。
1. 分析素数结构与目标证书
设 G = 65537,M 为区间 [3, 157] 内全部素数的乘积,则生成的素数满足:
1 2 3 4 5 6 7 8 9
| M = 17687583496858747420317883543975872106028785323944988711214935
p = kM + r, r = G^a mod M
q = lM + s, s = G^b mod M
0 <= a,b < 2^20
|
日志提到了 RTC 回退种子,但源码实际使用 secrets.randbelow(1 << 20) 选取两个指数,因此不应根据时间戳猜测种子。
目标证书为 titan_card_9f3a.pem,另一张为厂商参考证书。两张证书的模数最大公约数为 1,无法通过公因子直接分解。
2. 恢复指数和并枚举候选
由 n mod M = G^(a+b) mod M,在区间 [0, 2^21-2] 内递推枚举,得到 a+b = 1350089。
交换 p、q 不影响解密,因此只需考虑 a <= b。结合指数范围,可将 a 的枚举区间缩小为 [301514, 675044],共 373531 个候选。
3. 利用二维格恢复因子
M 为 204 位,p、q 为 256 位,因此 k、l 仅约 53 位。对每个候选指数计算 r、s,展开 n = (kM+r)(lM+s),可得到:
1 2 3 4 5 6 7 8 9
| s*k + r*l = (n-r*s)/M (mod M)
alpha = r * inverse(s,M) mod M
t = ((n-r*s)/M) * inverse(s,M) mod M
k + alpha*l = t (mod M)
|
格基 (M, 0)、(-alpha, 1) 生成的格向量满足 x + alpha*y = 0 mod M。若找到接近 (t, 0) 的格向量,则两者之差就是目标同余式的一个小解。
脚本使用整数 Gauss 约化与基坐标舍入寻找候选,再通过 (kM+r)(lM+s) == n 严格验证,确保恢复出的数值确实是 RSA 因子。
本实例恢复出的参数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13
| a = 447070
b = 903019
k = 6342863582949564
l = 4223322583458320
p = 112189929232605062223764618423686861575249167550919345498217056147595727475219
q = 74700370829088239242212381105434424988695453732719318678637387233318078261663
|
4. 解密并验证结果
计算私钥指数 d = inverse(65537, lcm(p-1, q-1)),解密 flag.enc,再按照 00 02 || PS || 00 || flag 的结构去除 PKCS#1
v1.5 type-2 填充。本实例中,PS 的长度为 28 字节。
原解题记录中的验证结果包括:
p、q 均为素数,且 p*q == n。
- 将解密得到的明文块重新加密,结果与原密文一致。
- 目标证书的 SHA-256 RSA 自签名通过独立验证。
- 对原始附件进行全文搜索,未发现直接暴露的 flag 字符串;结果来自密文解密。
本题未进行线上提交。
5. 完整复现脚本
原记录的运行环境为 Python 3.13,依赖 pycryptodome,本实例实测耗时约 4 秒。
安装依赖并运行:
1 2 3 4 5
| python -m pip install pycryptodome
python solve_titancard.py "C:/Users/jack8/Desktop/tsgw2026 - 副本/TitanCard"
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150
| """Usage: python solve_titancard.py <TitanCard directory>
Dependency: pip install pycryptodome
"""
import math
import sys
from pathlib import Path
from Crypto.PublicKey import RSA
from Crypto.Util.number import isPrime
def nearest(a, b): if b < 0: a, b = -a, -b
return (2 * a + b) // (2 * b)
def small_solution(alpha, target, modulus):
ux, uy = modulus, 0
vx, vy = -alpha, 1
un, vn = ux * ux, vx * vx + 1
while True:
if un < vn: ux, vx = vx, ux
uy, vy = vy, uy
un, vn = vn, un
mu = nearest(ux * vx + uy * vy, vn)
if mu == 0: break
ux, uy = ux - mu * vx, uy - mu * vy
un = ux * ux + uy * uy
det = ux * vy - uy * vx
c = nearest(target * vy, det)
d = nearest(-target * uy, det)
return target - c * ux - d * vx, -c * uy - d * vy
def solve(directory): key = RSA.import_key((directory / 'titan_card_9f3a.pem').read_bytes())
n, e = key.n, key.e
primes = [v for v in range(3, 158)
if all(v % d for d in range(2, math.isqrt(v) + 1))]
M, G, bound = math.prod(primes), 65537, 1 << 20
residue = 1
for total in range(2 * bound - 1):
if residue == n % M: break
residue = residue * G % M
else:
raise ValueError('No exponent sum found')
print(f'a+b = {total}; M bits = {M.bit_length()}')
lo, hi = max(0, total - bound + 1), min(bound - 1, total // 2)
r, s = pow(G, lo, M), pow(G, total - lo, M)
si, gi = pow(s, -1, M), pow(G, -1, M)
for a in range(lo, hi + 1):
alpha = r * si % M
target = ((n - r * s) // M) * si % M
k, l = small_solution(alpha, target, M)
if 0 < k < (1 << 256) // M + 1 and 0 < l < (1 << 256) // M + 1:
p, q = k * M + r, l * M + s
if p * q == n: break
r, s, si = r * G % M, s * gi % M, si * G % M
else:
raise ValueError('No factor found')
assert isPrime(p) and isPrime(q)
assert pow(G, a, M) == p % M
assert pow(G, total - a, M) == q % M
d = pow(e, -1, math.lcm(p - 1, q - 1))
ciphertext = int.from_bytes((directory / 'flag.enc').read_bytes(), 'big')
em = pow(ciphertext, d, n).to_bytes((n.bit_length() + 7) // 8, 'big')
assert pow(int.from_bytes(em, 'big'), e, n) == ciphertext
assert em[:2] == b'\x00\x02'
separator = em.index(b'\x00', 2)
assert separator >= 10
print(f'a = {a}; b = {total - a}\np = {p}\nq = {q}')
print(em[separator + 1:].decode())
if __name__ == '__main__':
if len(sys.argv) != 2: raise SystemExit(__doc__)
solve(Path(sys.argv[1]))
|
最终得到:
1 2 3
| flag{C0PP3R5M1TH_r3turn5_2_T1T4N}
|