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。协议大致如下:
- 连接后服务端发:
prefix: <string> - 客户端回:
nonce: <u128>\n(满足sha256(prefix + nonce)前若干位为 0) - 服务端发:
program len: - 客户端回:
<len>\n+ 紧接着发送len字节的solve.so - 服务端输出挑战初始化信息(
admin/user/fee_mint/faucet_mint) - 服务端读取:
u64 little-endian长度 + JSON(表示Vec<Instruction>) - 以
payer=user、signers=[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_vaultmint50 * 10 = 500
所以一开始 vault 总额是 600,而不是 500。
这一点很关键:只要能从真实 vault 一次性转走
600,就能同时让 “fee_vault” 和 “faucet_vault” 变为 0,从而通过服务端校验。
漏洞点
1) CreateClaimRequest 没检查 faucet_mint == faucet.mint
在 launchpad/programs/launchpad/src/instructions/claim.rs 的 CreateClaimRequest 中,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_mint 来 init_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.faucet 和 claim_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=true,claim_batch 会对每个 (claim_request, receipt) 做:
let receipt_ta = TokenAccount::try_deserialize(receipt.data)?;
require!(is_on_curve(&receipt_ta.owner), FaucetError::PdaNotAllowed);
is_on_curve 在 launchpad/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 之前:
- 用 SPL Token
SetAuthority把 token account 的 owner 从auth_pda改成外部user(on-curve) claim_batch通过 on-curve 检查并完成转账- 再把 owner 改回
auth_pda
因为 SetAuthority 需要当前 owner 签名,所以当 owner 为 PDA 时,必须在 solve 程序里用 invoke_signed 才能完成。
另一个细节:
simple_claim的no_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。
- 外部
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
- 把质押的 0.7 SOL 拿回来(为后续创建 PDA/账户提供资金):
create_user 把 global.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 余额又充足,能继续为后续账户创建付费。
-
由于 ClaimRequest PDA =
PDA(["claim_request", faucet, user_pda]),同一个 authority 对同一个 faucet 只能创建一次 ClaimRequest(要么 init 冲突,要么 AlreadyClaimed/claimed==true),因此需要第二个 authority 来再领一次 50。 -
在 solve 程序内构造
auth_pda = PDA(["auth"])(第二身份):
- 由外部
user给auth_pda转一些 SOL(让它能作为 payer) auth_pda通过invoke_signed调用create_user创建auth_user_pda
- 让
auth_pda走 create_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_minttoken::authority = authority(此处 authority =auth_pda)
虽然 global.claim_fee = 0 导致这笔费用不会真的被转账,但 Anchor 仍会检查账户约束。
因此实现里额外准备了一个稳定的“辅助 token account”(例如 auth_fee_aux_ta),一直保持 owner=auth_pda,避免和 receipt(要反复改 owner)互相干扰。
- 把这 50 再
Transfer给外部user,并把ATA(auth_pda, fee_mint)owner 改回auth_pda(为后续继续用 PDA 操作做准备)。
效果总结:
- 外部
user一共持有100个fee_mint - vault 一共被领走
100:600 → 500
Step B:创建恶意 faucet,把 vault 补回 600
- 外部
user创建一套自己控制的 mint + faucet:
- 创建
my_mint(mint authority = user) - 创建
my_faucet = PDA(["faucet", my_mint]) - 调用
CreateFaucet,传入:per_user_amount = 600max_claim_cnt = 1stake_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 转空
- 生成一个“看似合法但实际跨 faucet”的 ClaimRequest:
对 my_faucet 调 CreateClaimRequest,但故意把 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 = 600receipt是一个 fee_mint 的 token account
-
再次
SetAuthority把ATA(auth_pda, fee_mint)owner 临时改成外部user,准备通过真实 faucet 的no_pda检查。 -
调用真实 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。
- 服务端检查
fee_vault和faucet_vault(两者同一个账户)余额为 0,通过并输出 flag。
实现方式:本地/远程均可复现
1) 上传的 solve 程序
路径:launchpad/client/solve/programs/solve/src/lib.rs
solve 程序做了三件事:
- 通过 CPI 调用 launchpad 程序的:
create_user / close_user / simple_claim / create_claim_request / claim_batch / create_faucet / tweak_emulate_clock
- 使用 PDA(
auth_pda = PDA(["auth"]))在程序内invoke_signed扮演第二个用户身份 - 使用 SPL Token 指令
SetAuthority临时修改 receipt token account 的 owner,以绕过no_pda的 on-curve 检查
solve 程序的账户列表是“静态的”(由脚本提前推导好并传入),例如:
global/emulate_clock/real_faucet/vault- 外部
user的user_pda、ATA(user, fee_mint)、以及它对应的 ClaimRequest PDA auth_pda及其对应的auth_user_pda、ATA(auth_pda, fee_mint)等my_mint/my_faucet/my_claim_request等
这些 PDA/ATA 都能通过 seeds 在链下确定性计算,因此脚本可以完整构造 Instruction.accounts。
solve 的账户列表:与脚本一一对应
solve 程序 Solve<'info> 的 accounts(顺序固定):
user(外部 signer)launchpad_program(launchpad program id)token_program(SPL Token program id)associated_token_program(ATA program id)system_programglobal(PDA)emulate_clock(PDA, mut)fee_mintvault = ATA(global, fee_mint)(mut)real_faucet = PDA(["faucet", fee_mint])(mut)user_pda = PDA(["user", user])(mut)user_claim_request = PDA(["claim_request", real_faucet, user_pda])(mut)user_fee_ta = ATA(user, fee_mint)(mut)auth_pda = PDA(["auth"])(mut,程序内 invoke_signed 当 signer 用)auth_user_pda = PDA(["user", auth_pda])(mut)auth_claim_request = PDA(["claim_request", real_faucet, auth_user_pda])(mut)auth_fee_ta = ATA(auth_pda, fee_mint)(mut,receipt,用于被 SetAuthority 改 owner)auth_fee_aux_ta = PDA(["aux"])(mut,稳定的 token account,用作 user_fee_ta 满足约束)my_mint = PDA(["mint"])(mut)my_faucet = PDA(["faucet", my_mint])(mut)my_faucet_vault = ATA(global, my_mint)(mut)my_refund_ta = PDA(["refund"])(mut,token account for my_mint,用作 refund_account)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 基本对应):
step_user_claim:usersimple_claim拿到第一笔 50step_close_user:推进时间 +close_user退回 0.7 SOLstep_prepare_auth:创建auth_pda,创建auth_user_pda,准备各类 token accountstep_auth_claim_50:用auth_pda走create_claim_request + claim_batch拿第二笔 50,并转给 userstep_create_mint_and_faucet:创建my_mint+my_faucet(支付 create_fee=100,使 vault 回到 600)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)
复现与利用
本地复现
- 下载题目并解压:
curl -L -o launchpad.tar.gz https://i.cauchy.top/_p/67efe178c3919b1ea379da0869983aa081d7769aaac76d1a073926a489beca49.tar.gz
tar -xzf launchpad.tar.gz
cd launchpad
- 运行本地服务端(默认
0.0.0.0:1337):
cd server
cargo run -r --bin server
- 构建
solve.so(产物路径见脚本默认值):
cd ../client/solve
cargo build-sbf
如果最后提示找不到
strip.sh,但target/sbpf-solana-solana/release/solve.so已生成,通常仍然可用(本题环境可直接上传)。
- 运行 exploit:
cd ../../
python3 solve_remote.py --host 127.0.0.1 --port 1337 --action exploit
远程拿 flag
python3 solve_remote.py --action exploit
修复建议:出题方视角
至少需要补两处校验(两者任意一处修掉都能堵住本解):
- 在
CreateClaimRequest的faucet_mint上加约束:
address = faucet.mint
- 在
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}