HTB Cyberapocalypse 2025 - EldoriaGate

Difficulty : Medium
Team : Phreaks 2600
Description : At long last, you stand before the EldoriaGate, the legendary portal, the culmination of your perilous journey. Your escape from this digital realm hinges upon passing this final, insurmountable barrier. Your fate rests upon the passage through these mythic gates. These are no mere gates of stone and steel. They are a living enchantment, a sentinel woven from ancient magic, judging all who dare approach. The Gate sees you, divining your worth, assigning your place within Eldoria’s unyielding order. But you seek not a place within their order, but freedom beyond it. Become the Usurper. Defy the Gate’s ancient magic. Pass through, yet leave no trace, no mark of your passing, no echo of your presence. Become the unseen, the unwritten, the legend whispered but never confirmed. Outwit the Gate. Become a phantom, a myth. Your escape, your destiny, awaits.
Topics : Assembly, EVM storage
Author : PerryThePwner
TL;DR
To solve, our address (the player address) need to have an empty role which is supposed to be impossible.
Recover the passphrase from the EldoriaGateKernel smart contract storage.
Enter the Gate with a contribution of 255 wei resulting in giving an empty role.
Get the flag.
Introduction
For this challenge, there are three solidity smart contracts deployed. The “EldoriaGate” smart contract (which I will call "Gate"
from now). And this gate rely on another smart contract “EldoriaGateKernel” (we will call it "Kernel"
) with low level functions using assembly. This part is responsible for checking a role, authenticate or giving a role.
Recon
Smart contracts
Here are the contracts.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import { EldoriaGate } from "./EldoriaGate.sol";
contract Setup {
EldoriaGate public TARGET;
address public player;
event DeployedTarget(address at);
constructor(bytes4 _secret, address _player) {
TARGET = new EldoriaGate(_secret);
player = _player;
emit DeployedTarget(address(TARGET));
}
function isSolved() public returns (bool) {
return TARGET.checkUsurper(player);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
/***
Malakar 1b:22-28, Tales from Eldoria - Eldoria Gates
"In ages past, where Eldoria's glory shone,
Ancient gates stand, where shadows turn to dust.
Only the proven, with deeds and might,
May join Eldoria's hallowed, guiding light.
Through strict trials, and offerings made,
Eldoria's glory, is thus displayed."
ELDORIA GATES
*_ _ _ _ _ _ *
^ | `_' `-' `_' `-' `_' `| ^
| | | |
| (*) | .___________ | \^/ |
| _<#>_ | // \ | _(#)_ |
o+o \ / \0 || ===== || 0/ \ / (=)
0'\ ^ /\/ || || \/\ ^ /`0
/_^_\ | || --- || | /_^_\
|| || | || || | || ||
d|_|b_T____||___________||___T_d|_|b
***/
import { EldoriaGateKernel } from "./EldoriaGateKernel.sol";
contract EldoriaGate {
EldoriaGateKernel public kernel;
event VillagerEntered(address villager, uint id, bool authenticated, string[] roles);
event UsurperDetected(address villager, uint id, string alertMessage);
struct Villager {
uint id;
bool authenticated;
uint8 roles;
}
constructor(bytes4 _secret) {
kernel = new EldoriaGateKernel(_secret);
}
function enter(bytes4 passphrase) external payable {
bool isAuthenticated = kernel.authenticate(msg.sender, passphrase);
require(isAuthenticated, "Authentication failed");
uint8 contribution = uint8(msg.value);
(uint villagerId, uint8 assignedRolesBitMask) = kernel.evaluateIdentity(msg.sender, contribution);
string[] memory roles = getVillagerRoles(msg.sender);
emit VillagerEntered(msg.sender, villagerId, isAuthenticated, roles);
}
function getVillagerRoles(address _villager) public view returns (string[] memory) {
string[8] memory roleNames = [
"SERF",
"PEASANT",
"ARTISAN",
"MERCHANT",
"KNIGHT",
"BARON",
"EARL",
"DUKE"
];
(, , uint8 rolesBitMask) = kernel.villagers(_villager);
uint8 count = 0;
for (uint8 i = 0; i < 8; i++) {
if ((rolesBitMask & (1 << i)) != 0) {
count++;
}
}
string[] memory foundRoles = new string[](count);
uint8 index = 0;
for (uint8 i = 0; i < 8; i++) {
uint8 roleBit = uint8(1) << i;
if (kernel.hasRole(_villager, roleBit)) {
foundRoles[index] = roleNames[i];
index++;
}
}
return foundRoles;
}
function checkUsurper(address _villager) external returns (bool) {
(uint id, bool authenticated , uint8 rolesBitMask) = kernel.villagers(_villager);
bool isUsurper = authenticated && (rolesBitMask == 0);
emit UsurperDetected(
_villager,
id,
"Intrusion to benefit from Eldoria, without society responsibilities, without suspicions, via gate breach."
);
return isUsurper;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
contract EldoriaGateKernel {
bytes4 private eldoriaSecret;
mapping(address => Villager) public villagers;
address public frontend;
uint8 public constant ROLE_SERF = 1 << 0;
uint8 public constant ROLE_PEASANT = 1 << 1;
uint8 public constant ROLE_ARTISAN = 1 << 2;
uint8 public constant ROLE_MERCHANT = 1 << 3;
uint8 public constant ROLE_KNIGHT = 1 << 4;
uint8 public constant ROLE_BARON = 1 << 5;
uint8 public constant ROLE_EARL = 1 << 6;
uint8 public constant ROLE_DUKE = 1 << 7;
struct Villager {
uint id;
bool authenticated;
uint8 roles;
}
constructor(bytes4 _secret) {
eldoriaSecret = _secret;
frontend = msg.sender;
}
modifier onlyFrontend() {
assembly {
if iszero(eq(caller(), sload(frontend.slot))) {
revert(0, 0)
}
}
_;
}
function authenticate(address _unknown, bytes4 _passphrase) external onlyFrontend returns (bool auth) {
assembly {
let secret := sload(eldoriaSecret.slot)
auth := eq(shr(224, _passphrase), secret)
mstore(0x80, auth)
mstore(0x00, _unknown)
mstore(0x20, villagers.slot)
let villagerSlot := keccak256(0x00, 0x40)
let packed := sload(add(villagerSlot, 1))
auth := mload(0x80)
let newPacked := or(and(packed, not(0xff)), auth)
sstore(add(villagerSlot, 1), newPacked)
}
}
function evaluateIdentity(address _unknown, uint8 _contribution) external onlyFrontend returns (uint id, uint8 roles) {
assembly {
mstore(0x00, _unknown)
mstore(0x20, villagers.slot)
let villagerSlot := keccak256(0x00, 0x40)
mstore(0x00, _unknown)
id := keccak256(0x00, 0x20)
sstore(villagerSlot, id)
let storedPacked := sload(add(villagerSlot, 1))
let storedAuth := and(storedPacked, 0xff)
if iszero(storedAuth) { revert(0, 0) }
let defaultRolesMask := ROLE_SERF
roles := add(defaultRolesMask, _contribution)
if lt(roles, defaultRolesMask) { revert(0, 0) }
let packed := or(storedAuth, shl(8, roles))
sstore(add(villagerSlot, 1), packed)
}
}
function hasRole(address _villager, uint8 _role) external view returns (bool hasRoleFlag) {
assembly {
mstore(0x0, _villager)
mstore(0x20, villagers.slot)
let villagerSlot := keccak256(0x0, 0x40)
let packed := sload(add(villagerSlot, 1))
let roles := and(shr(8, packed), 0xff)
hasRoleFlag := gt(and(roles, _role), 0)
}
}
}
Code reading
When facing a blockchain challenge, I like to read the contracts in order to understand with is the workflow behind the challenge and what we are supposed to do. With this in mind, the setup contract help a lot with that because it directly tell the condition to solve.
Setup.sol
Let’s start with the setup. It contains two public variables: player
(us) and TARGET
.
Reading the constructor
which is called once only to deploy the contract, we read that two parameters need to be supplied, a secret
and an address
:
constructor(bytes4 _secret, address _player) {
TARGET = new EldoriaGate(_secret);
player = _player;
emit DeployedTarget(address(TARGET));
}
It simply deploys an EldoriaGate
contract with the submited secret
(of type bytes4, which are just 4 bytes hex encoded) in argument and store it in TARGET
.
And finally the isSolved
function :
function isSolved() public returns (bool) {
return TARGET.checkUsurper(player);
}
This function return either True or False, so we assume it have to returns True in order to solve the challenge, so the player needs to be an Usurper.
Let’s dive in the deployed contract by the setup, the EldoriaGate contract.
EldoriaGate.sol
constructor
First, let’s read the constructor. It takes one parameter and it’s a _secret
of types bytes4 again.
Since this contract is deployed using the setup secret
, they share the same secret :
constructor(bytes4 _secret) {
kernel = new EldoriaGateKernel(_secret);
}
It deploys the Kernel with the same secret again. So, the same secret value is shared between the three contracts.
enter
Next, we got the enter()
function :
function enter(bytes4 passphrase) external payable {
bool isAuthenticated = kernel.authenticate(msg.sender, passphrase);
require(isAuthenticated, "Authentication failed");
uint8 contribution = uint8(msg.value);
(uint villagerId, uint8 assignedRolesBitMask) = kernel.evaluateIdentity(msg.sender, contribution);
string[] memory roles = getVillagerRoles(msg.sender);
emit VillagerEntered(msg.sender, villagerId, isAuthenticated, roles);
}
It takes a bytes4 passphrase
in parameter which is, we suppose, related to the secret we have seen before.
First, the kernel is called with the submitted passphrase and returns a bool identified as “isAuthenticated”.
Without reading the kernel code we can understand that, if the submitted passphrase is equal to the secret, the return value is True :
bool isAuthenticated = kernel.authenticate(msg.sender, passphrase);
require(isAuthenticated, "Authentication failed");
Also, if the passphrase is wrong the execution is reverted, but otherwise, the execution flow continues.
Then,
uint8 contribution = uint8(msg.value);
(uint villagerId, uint8 assignedRolesBitMask) = kernel.evaluateIdentity(msg.sender, contribution);
string[] memory roles = getVillagerRoles(msg.sender);
The contribution is taken from msg.value
, and call evaluateIdentity()
from the kernel with the contribution and msg.sender in arugment.
So, without reading the code, we assume our role is set according to the contribution we give.
And it retrieves the roles we have got with getVillagerRoles
which we will read now :
getVillagerRoles
function getVillagerRoles(address _villager) public view returns (string[] memory) {
string[8] memory roleNames = [
"SERF",
"PEASANT",
"ARTISAN",
"MERCHANT",
"KNIGHT",
"BARON",
"EARL",
"DUKE"
];
(, , uint8 rolesBitMask) = kernel.villagers(_villager);
uint8 count = 0;
for (uint8 i = 0; i < 8; i++) {
if ((rolesBitMask & (1 << i)) != 0) {
count++;
}
}
string[] memory foundRoles = new string[](count);
uint8 index = 0;
for (uint8 i = 0; i < 8; i++) {
uint8 roleBit = uint8(1) << i;
if (kernel.hasRole(_villager, roleBit)) {
foundRoles[index] = roleNames[i];
index++;
}
}
return foundRoles;
}
It makes a call to the kernel :
Retrive the
rolesBitMask
variable by callingkernel.villagers(_villager)
to recover roles of a villager formated in a 8 bits numbre.Then
count
the “1”’s in it.Then declare a string array with a length of the previous count.
Recover each player role as string with
kernel.hasRole
and fill the string array.
checkUsurper
And for the last function, also the one called to check if we have solved challenge, it’s pretty short.
function checkUsurper(address _villager) external returns (bool) {
(uint id, bool authenticated , uint8 rolesBitMask) = kernel.villagers(_villager);
bool isUsurper = authenticated && (rolesBitMask == 0);
emit UsurperDetected(
_villager,
id,
"Intrusion to benefit from Eldoria, without society responsibilities, without suspicions, via gate breach."
);
return isUsurper;
}
It checks two values :
bool isUsurper = authenticated && (rolesBitMask == 0);
If we had successfully entered the village.
If we have a
rolesBitMask
equal to zero (so no role).Returns True if those conditions are satisfied, else, False.
This done, it’s time to dig into the kernel part with many assembly line to read.
EldoriaGateKernel.sol
constructor
The kernel is also deployed with a secret
, here is the constructor :
constructor(bytes4 _secret) {
eldoriaSecret = _secret;
frontend = msg.sender;
}
Stored in eldoriaSecret
.
Also, the roles are defined in the kernel as uint values :
bytes4 private eldoriaSecret;
mapping(address => Villager) public villagers;
uint8 public constant ROLE_SERF = 1 << 0;
uint8 public constant ROLE_PEASANT = 1 << 1;
uint8 public constant ROLE_ARTISAN = 1 << 2;
uint8 public constant ROLE_MERCHANT = 1 << 3;
uint8 public constant ROLE_KNIGHT = 1 << 4;
uint8 public constant ROLE_BARON = 1 << 5;
uint8 public constant ROLE_EARL = 1 << 6;
uint8 public constant ROLE_DUKE = 1 << 7;
Having the lowest role being SERF
with a value of $1$.
Then the PEASANT
with a value of $1 « 1 = 2$ and so on.
If we think of the function reading the roles as bit mask earlier, we can represent the roles like that :
Let’s see the first function.
authenticate
Before reading assembly, there are plenty of resources like the solidity docs and this openzeppelin walkthrough about digging in smart contracts.
function authenticate(address _unknown, bytes4 _passphrase) external onlyFrontend returns (bool auth) {
assembly {
let secret := sload(eldoriaSecret.slot)
auth := eq(shr(224, _passphrase), secret)
mstore(0x80, auth)
mstore(0x00, _unknown)
mstore(0x20, villagers.slot)
let villagerSlot := keccak256(0x00, 0x40)
let packed := sload(add(villagerSlot, 1))
auth := mload(0x80)
let newPacked := or(and(packed, not(0xff)), auth)
sstore(add(villagerSlot, 1), newPacked)
}
}
This function is called by the enter function.
In shorts, it reads the content from the eldoriaSecret
variable.
Then compares it with the submitted passphrase
.
And stores the _unknown
address as authenticated or not (according to the result of the comparison) in the villagers mapping.
evaluateIdentity
This function is called after the authentication has succeeded.
require(isAuthenticated, "Authentication failed");
uint8 contribution = uint8(msg.value);
(uint villagerId, uint8 assignedRolesBitMask) = kernel.evaluateIdentity(msg.sender, contribution);
It looks like it returns an VillagerId
as an uint and its roles assignedRolesBitMask
as an uint8 value.
Here is the code :
function evaluateIdentity(address _unknown, uint8 _contribution) external onlyFrontend returns (uint id, uint8 roles) {
assembly {
mstore(0x00, _unknown)
mstore(0x20, villagers.slot)
let villagerSlot := keccak256(0x00, 0x40)
mstore(0x00, _unknown)
id := keccak256(0x00, 0x20)
sstore(villagerSlot, id)
let storedPacked := sload(add(villagerSlot, 1))
let storedAuth := and(storedPacked, 0xff)
if iszero(storedAuth) { revert(0, 0) }
let defaultRolesMask := ROLE_SERF
roles := add(defaultRolesMask, _contribution)
if lt(roles, defaultRolesMask) { revert(0, 0) }
let packed := or(storedAuth, shl(8, roles))
sstore(add(villagerSlot, 1), packed)
}
}
For the first part, it stores the _unknown
address (which is msg.sender, so the player address if done correctly) at address 0.
mstore(0x00, _unknown)
Then the villagers
mapping storage slot in the EVM is stored at address 0x20 (32 bytes further) which use an address as key, and a Villager
variable as value :
mstore(0x20, villagers.slot)
let villagerSlot := keccak256(0x00, 0x40)
Then it computes the villagerSlot
by computing the keccak256 of 64 bytes counting from the address 0. Resulting in hashing the concatenation of _unknown
and the villagers
mapping slot.
This operation is used to compute the storage location of a key in a mapping. Learn more here.
After that,
mstore(0x00, _unknown)
id := keccak256(0x00, 0x20)
sstore(villagerSlot, id)
The _unknown
is stored at address 0x0, then it is hashed with keccak256 and stored in the id
variable. Finally, the result is stored at the villagerSlot
computed previously.
So, the id is actually the _unknown
address hashed with keccak256 and casted in uint (i guess ??).
let storedPacked := sload(add(villagerSlot, 1))
storedPacked
store the value at villagerSlot + 1
which reads the next values in the Villager
struct, the authenticated
and roles
variables :
struct Villager {
uint id;
bool authenticated;
uint8 roles;
}
And an operation to check the value is made :
let storedAuth := and(storedPacked, 0xff)
if iszero(storedAuth) { revert(0, 0) }
A bitwise AND operation with 0xff
and storedAuth
(either 1 or 0).
The result of this operation is zero if the villager is not authenticated, and one if yes.
But at this stage the villager, can only be authenticated because the function is reverted before getting here if the villager is not authenticated.
Now, the part setting the role begin.
let defaultRolesMask := ROLE_SERF
roles := add(defaultRolesMask, _contribution)
The defaultRolesMask
is a number initally set to 1 (SERF), meaning we first start at the SERF role.
Then, the contribution is added to our role (with a contribution of 0, the role is still to a value of 1) and stored in roles
.
if lt(roles, defaultRolesMask) { revert(0, 0) }
The operation is reverted if roles < 1
, but it’s not supposed to happen as roles
is initially set at 1.
Finally,
let packed := or(storedAuth, shl(8, roles))
sstore(add(villagerSlot, 1), packed)
The roles value is shifted 8 bytes to the left and combined with the auth bit with a bitwise OR.
Then, packed
(actually containing the concatenation of roles
followed by the auth bit) is stored at villagerSlot + 1
, where we initially read the auth bit.
To wrap up,
The location for our address in the villager mapping is computed.
Then the authenticated bit is read from here on the slot just after.
Finally, our role is computed by
1 + contribution
, shifted by 8, concatenated with the auth bit and then stored in the struct.
hasRole
This last function is called by getVillagerRoles to check from the roles mask, which bits are set to know which roles are present for a villager.
function hasRole(address _villager, uint8 _role) external view returns (bool hasRoleFlag) {
assembly {
mstore(0x0, _villager)
mstore(0x20, villagers.slot)
let villagerSlot := keccak256(0x0, 0x40)
let packed := sload(add(villagerSlot, 1))
let roles := and(shr(8, packed), 0xff)
hasRoleFlag := gt(and(roles, _role), 0)
}
}
Again, the routine to compute the location of the villager in the villagers
mapping inside the EVM storage and store it in villagerSlot
:
mstore(0x0, _villager)
mstore(0x20, villagers.slot)
let villagerSlot := keccak256(0x0, 0x40)
Then the value at villagerSlot, 1
is loaded, which contains in the lower bit the auth bit (see the villager struct).
To better understand this, let’s review the storage in the EVM.
In the EVM, a variable value is stored on what his called “a slot”. And a slot have a size of 32 bytes.
- If a value have a length of 32, it takes th full slot
- If a value have a length of 20 bytes and is followed by a value of 4 bytes for instance, they take the same slot in order to save EVM storage.
In the Villager struct there are three values:
uint id
->uint256
-> 32 bytes -> first slotbool authenticated
-> 1 byte -> second slotuint8 roles
-> 8 byte -> second slot
Meaning the authenticated
variable is sharing the same slot as the roles
variable. See more.
Let’s see the next assembly code :
let packed := sload(add(villagerSlot, 1))
let roles := and(shr(8, packed), 0xff)
hasRoleFlag := gt(and(roles, _role), 0)
The second storage slot in the struct of the villager
value is loaded, and roles
is shifted to the right by 8. It was shifted left beforehand in evaluateIdentity, so it allow us to retrieve the roles
.
After that, a bitwise AND is made with roles
and 0xff
.
Finally, it checks if a 1 is present at the position of each role checked in the roles
value for the current villager :
Then returns hasRoleFlag
with the value.
Solving
Get authenticated
So, to solve we have to be authenticated and have a role equal to zero, meaning not having any role at all.
First to be authenticated we have to find the passphrase.
This passphrase is stored on the EldoriaGateKernel contract but with the visibility private
.
But even if it’s private, we can read it directly from the storage as it is public on the blockchain. (see cast storage)
To know the storage slot to look at we can use forge inspect :
forge inspect EldoriaGateKernel.sol:EldoriaGateKernel --root . storage
╭---------------+-------------------------------------------------------+------+--------+-------+
| Name | Type | Slot | Offset | Bytes |
+================================================================================================
| eldoriaSecret | bytes4 | 0 | 0 | 4 |
|---------------+-------------------------------------------------------+------+--------+-------+
| villagers | mapping(address => struct EldoriaGateKernel.Villager) | 1 | 0 | 32 |
|---------------+-------------------------------------------------------+------+--------+-------+
| frontend | address | 2 | 0 | 20 |
╰---------------+-------------------------------------------------------+------+--------+-------+
With that we know the passphrase is at the first slot.
And we can read it with :
# get the kernel address
cast call -r $RPC $TARGET "kernel()(address)"
0x79C71Dc194e4bbEd8e33d3419858B6AEA1a163B3
cast storage -r $RPC 0x79C71Dc194e4bbEd8e33d3419858B6AEA1a163B3 0
0x00000000000000000000000000000000000000000000000000000000deadfade
So the passphrase is 0xdeadfade
.
Have an empty role
Now that we have the passphrase, we need to have an empty role.
We know that the role bits are read a from a number on 8 bits.
In the assembly we saw that roles a bitwise AND with the role and 0xff is made in order to read it.
Thus, if we manage to have a role value that result to zero when put on a bitwise AND with 0xff, example of a bitwise AND with a role value of 1 (SERF) :
We get 1.
And with a value of 256 we got a result of 0 :
Which result in an empty role.
Now, how do we submit a value of 256 ?
First remember that the role is one by default (see evaluateIdentity).
And our contribution is added to this one to get another role.
So we have to add 255
to this value. And we can do by sending some value when calling enter()
.
But 1 ether is not the smallest unit, 1 ether is actually equal to $1 * 10^{18}$ wei. We need to send wei and not ethers.
cast send -r $RPC $TARGET --value 255wei --private-key $PKEY "enter(bytes4)" 0xdeadfade
(success)
And we can check our role :
cast call -r $RPC $TARGET "getVillagerRoles(address)(string)" $ADDRESS
""
It is empty as wanted.
HTB{unkn0wn_1ntrud3r_1nsid3_...}