Logo strn.dev
Cyber Apocalypse 2024: Hacker Royale

Cyber Apocalypse 2024: Hacker Royale

April 12, 2024
19 min read
Table of Contents

This week, my team and I played HackTheBox’s fourth iteration of their Cyber Apocalypse event; this one being named Cyber Apocalypse 2024 - Hacker Royale. We placed 625th out of 5693 teams (~11000 players!), being in the top 11%, and here’s a compilation of the challenges that I’ve solved.


misc / Stop Drop and Roll

The Fray: The Video Game is one of the greatest hits of the last… well, we don’t remember quite how long. Our “computers” these days can’t run much more than that, and it has a tendency to get repetitive…

A description of the challenge
===== THE FRAY: THE VIDEO GAME =====
Welcome!
This video game is very simple
You are a competitor in The Fray, running the GAUNTLET
I will give you one of three scenarios: GORGE, PHREAK or FIRE
You have to tell me if I need to STOP, DROP or ROLL
If I tell you there's a GORGE, you send back STOP
If I tell you there's a PHREAK, you send back DROP
If I tell you there's a FIRE, you send back ROLL
Sometimes, I will send back more than one! Like this:
GORGE, FIRE, PHREAK
In this case, you need to send back STOP-ROLL-DROP!
Are you ready? (y/n)

This challenge is connecting to a remote container, giving us strings containing GORGE, PHREAK, and FIRE. We are supposed to send the correct translation, of which GORGE is STOP, PHREAK, is DROP, and FIRE is ROLL.

Here’s the final script, with added comments for clarity.

===== THE FRAY: THE VIDEO GAME =====
Welcome!
This video game is very simple
You are a competitor in The Fray, running the GAUNTLET
I will give you one of three scenarios: GORGE, PHREAK or FIRE
You have to tell me if I need to STOP, DROP or ROLL
If I tell you there's a GORGE, you send back STOP
If I tell you there's a PHREAK, you send back DROP
If I tell you there's a FIRE, you send back ROLL
Sometimes, I will send back more than one! Like this:
GORGE, FIRE, PHREAK
In this case, you need to send back STOP-ROLL-DROP!
Are you ready? (y/n)
Challenge explanation

This challenge is connecting to a remote container, giving us strings containing GORGE, PHREAK, and FIRE. We are supposed to send the correct translation, of which GORGE is STOP, PHREAK, is DROP, and FIRE is ROLL.

solve.py
from pwn import *
 
r = remote('localhost', 1337)
r.sendlineafter(b'(y/n)', b'y')
 
r.recvuntil(b'\n')
 
tries = 0
 
while True:
    try:
        got = r.recvline().decode()
        payload = got.replace(", ", "-").replace("GORGE", "STOP").replace("PHREAK", "DROP").replace("FIRE", "ROLL").strip()
 
        r.sendlineafter(b'What do you do?', payload.encode())
        tries = tries + 1
        log.info(f'{tries}: {payload}')
    except EOFError:
        log.success(got.strip())
        r.close()
        break
solve script

And the flag is:

HTB{1_wiLl_sT0p_dR0p_4nD_r0Ll_mY_w4Y_oUt!}

misc / Unbreakable

Think you can escape my grasp? Challenge accepted! I dare you to try and break free, but beware, it won’t be easy. I’m ready for whatever tricks you have up your sleeve!

We are supposed to read flag.txt (which is in the same directory as the challenge runs), and here is the challenge we’ve been given.

source.py
#!/usr/bin/python3
 
blacklist = [ ';', '"', 'os', '_', '\\', '/', '`',
              ' ', '-', '!', '[', ']', '*', 'import',
              'eval', 'banner', 'echo', 'cat', '%',
              '&', '>', '<', '+', '1', '2', '3', '4',
              '5', '6', '7', '8', '9', '0', 'b', 's',
              'lower', 'upper', 'system', '}', '{' ]
 
while True:
  ans = input('Break me, shake me!\n\n$ ').strip()
 
  if any(char in ans for char in blacklist):
    print(f'Naughty naughty..\n')
  else:
    try:
      eval(ans + '()')
      print('WHAT WAS THAT?!')
    except:
      print(f"I'm UNBREAKABLE!")
Source challenge

We can’t enter any characters that are in blacklist, and the problems we have are not being able to use the letters b, s, every number, and no spaces.

If we pass the blacklist, it tries to eval() the answer, literally just meaning that it executes the given Python code. I even wrote a ‘helper’ script made for debugging inputs that I couldn’t see exactly why they were disallowed.

help.py
blacklist = [ ';', '"', 'os', '_', '\\', '/', '`',
              ' ', '-', '!', '[', ']', '*', 'import',
              'eval', 'banner', 'echo', 'cat', '%',
              '&', '>', '<', '+', '1', '2', '3', '4',
              '5', '6', '7', '8', '9', '0', 'b', 's',
              'lower', 'upper', 'system', '}', '{' ]
 
 
 
while True:
    ans = input('Break me, shake me!\n\n$ ').strip()
 
    disallowed_chars = [(char, index) for index, char in enumerate(ans) if char in blacklist]    
    if disallowed_chars:
        for char, index in disallowed_chars:
            print(f"Character '{char}' at position {index}")
    else:
        try:
            eval(ans + '()')
        except Exception as e:
            print(e)
Helper script

This challenge is actually much easier than it looks like. The trick is just knowing you don’t need a space in this line:

print(open('flag.txt','r').read())
The payload

Here was my final solution.

solve.py
#!/usr/bin/python3
 
from pwn import *
 
r = remote('83.136.252.96', 37438)
 
r.recvuntil(b'$')
 
r.sendline(b"print(open('flag.txt','r').read())")
 
response = r.recvline().decode()
 
print(response)
 
r.close()
Solution

And the flag is:

HTB{3v4l_0r_3vuln??}

misc / Character

Security through Induced Boredom is a personal favourite approach of mine. Not as exciting as something like The Fray, but I love making it as tedious as possible to see my secrets, so you can only get one character at a time!

Upon connecting to the remote docker server, we simply get asked for the index of the flag we want, in this format.

Which character (index) of the flag do you want?
Enter an index: 0
Character at Index 0: H

So, because I don’t know pwntools that well, I made two separate scripts to extract the flag. Here’s the first one:

from pwn import *
 
r = remote('localhost', 1337)
num = 0
flag = ''
 
while True:
    r.sendlineafter(b'Enter an index: ', str(num).encode())
    r.recvuntil(b': ')
    char = r.recvline().decode().strip()
    flag = flag + char
    num = num + 1
    print(flag)
    if char == '}':
        break

And the flag is:

HTB{tH15_1s_4_r3aLly_l0nG_fL4g_i_h0p3_f0r_y0Ur_s4k3_tH4t_y0U_sCr1pTEd_tH1s_oR_els3_iT_t0oK_qU1t3_l0ng!!}
flag

crypto / Iced TEA

Locked within a cabin crafted entirely from ice, you’re enveloped in a chilling silence. Your eyes land upon an old notebook, its pages adorned with thousands of cryptic mathematical symbols. Tasked with deciphering these enigmatic glyphs to secure your escape, you set to work, your fingers tracing each intricate curve and line with determination. As you delve deeper into the mysterious symbols, you notice that patterns appear in several pages and a glimmer of hope begins to emerge. Time is flying and the temperature is dropping, will you make it before you become one with the cabin?

Let’s take a look at what we’re decrypting:

output.txt
Key : 850c1413787c389e0b34437a6828a1b2
Ciphertext : b36c62d96d9daaa90634242e1e6c76556d020de35f7a3b248ed71351cc3f3da97d4d8fd0ebc5c06a655eb57f2b250dcb2b39c8b2000297f635ce4a44110ec66596c50624d6ab582b2fd92228a21ad9eece4729e589aba644393f57736a0b870308ff00d778214f238056b8cf5721a843
Values to decrypt

source.py
import os
from secret import FLAG
from Crypto.Util.Padding import pad
from Crypto.Util.number import bytes_to_long as b2l, long_to_bytes as l2b
from enum import Enum
 
class Mode(Enum):
    ECB = 0x01
    CBC = 0x02
 
class Cipher:
    def __init__(self, key, iv=None):
        self.BLOCK_SIZE = 64
        self.KEY = [b2l(key[i:i+self.BLOCK_SIZE//16]) for i in range(0, len(key), self.BLOCK_SIZE//16)]
        self.DELTA = 0x9e3779b9
        self.IV = iv
        if self.IV:
            self.mode = Mode.CBC
        else:
            self.mode = Mode.ECB
 
    def _xor(self, a, b):
        return b''.join(bytes([_a ^ _b]) for _a, _b in zip(a, b))
 
    def encrypt(self, msg):
        msg = pad(msg, self.BLOCK_SIZE//8)
        blocks = [msg[i:i+self.BLOCK_SIZE//8] for i in range(0, len(msg), self.BLOCK_SIZE//8)]
 
        ct = b''
        if self.mode == Mode.ECB:
            for pt in blocks:
                ct += self.encrypt_block(pt)
        elif self.mode == Mode.CBC:
            X = self.IV
            for pt in blocks:
                enc_block = self.encrypt_block(self._xor(X, pt))
                ct += enc_block
                X = enc_block
        return ct
 
    def encrypt_block(self, msg):
        m0 = b2l(msg[:4])
        m1 = b2l(msg[4:])
        K = self.KEY
        msk = (1 << (self.BLOCK_SIZE//2)) - 1
 
        s = 0
        for i in range(32):
            s += self.DELTA
            m0 += ((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1])
            m0 &= msk
            m1 += ((m0 << 4) + K[2]) ^ (m0 + s) ^ ((m0 >> 5) + K[3])
            m1 &= msk
 
        m = ((m0 << (self.BLOCK_SIZE//2)) + m1) & ((1 << self.BLOCK_SIZE) - 1) # m = m0 || m1
        return l2b(m)
 
if __name__ == '__main__':
    KEY = os.urandom(16)
    cipher = Cipher(KEY)
    ct = cipher.encrypt(FLAG)
    with open('output.txt', 'w') as f:
        f.write(f'Key : {KEY.hex()}\nCiphertext : {ct.hex()}')
Source challenge

How does this encrypt the flag?

This is actually a modified encryption algorithm based on the Tiny Encryption Algorithm (hence Iced TEA).

The encryption key KEY in this file is split into four 32-bit integers, called [K0, K1, K2, K3], and the message is padded to the block size. Padded means that it ensures it’s length is a multiple of one another. The message is then divided into blocks of 64 bits. Something interesting is that there’s two modes of encryption here, one if an IV is provided, and one if it isn’t. If the IV is provided, it uses Cipher Block Chaining (CBC), and if the IV isn’t provided, Electronic Codebook (ECB) is used. In this case, ECB is used, so let’s take a look on that.

ECB, in this file means that each block is encrypted independently using the TEA algorithm.


How does the TEA Algorithm encrypt our flag?

The TEA algorithm operates on two 32-bit halves, m0 and m1, which it gets by first padding the message, then dividing it into two blocks, of 32-bit. Each block undergoes 32 rounds of encryption, where for each round it adds a set of bitwise operations, additions, and XOR operations based on the previous key components K0, K1, K2, K3. The result of the TEA algorithm for each block gets transformed into our ciphertext.

How can we decrypt the flag?

Because we are given both the key, and the ciphertext, and with both the key and the ciphertext, we can reverse the message, because the Tiny Encryption Algorithm is designed to be reversible. That’s why they’ve given us more problems. Because they’ve conveniently given us multiple functions, I realized it’s much more simple if we just add the decrypting function myself to the original script. Here’s my solution!


solve.py
from Crypto.Util.Padding import unpad
from Crypto.Util.number import bytes_to_long as b2l, long_to_bytes as l2b
from enum import Enum
 
 
# ... rest of the code above
    def decrypt_block(self, ct_block):
        # converts the block to a long, and then decrypts it
        c = b2l(ct_block)
        msk = (1 << (self.BLOCK_SIZE//2)) - 1 # creates a mask used for truncating the block
 
        # using the mask, get the first and second half of the block
        m1 = c & msk
        m0 = (c >> (self.BLOCK_SIZE//2)) & msk
 
        K = self.KEY # the key
        s = self.DELTA * 32
 
        # reversing round fractions, and then reversing the encryption
        for i in range(31, -1, -1):
            m1 -= ((m0 << 4) + K[2]) ^ (m0 + s) ^ ((m0 >> 5) + K[3])
            m1 &= msk
            m0 -= ((m1 << 4) + K[0]) ^ (m1 + s) ^ ((m1 >> 5) + K[1])
            m0 &= msk
            s -= self.DELTA
 
        m = ((m0 << (self.BLOCK_SIZE//2)) + m1) & ((1 << self.BLOCK_SIZE) - 1)  # m = m0 || m1
 
        return l2b(m)
 
if __name__ == '__main__':
    KEY = bytes.fromhex('850c1413787c389e0b34437a6828a1b2')
    ct_hex = 'b36c62d96d9daaa90634242e1e6c76556d020de35f7a3b248ed71351cc3f3da97d4d8fd0ebc5c06a655eb57f2b250dcb2b39c8b2000297f635ce4a44110ec66596c50624d6ab582b2fd92228a21ad9eece4729e589aba644393f57736a0b870308ff00d778214f238056b8cf5721a843'
    ct = bytes.fromhex(ct_hex)
 
    cipher = Cipher(KEY)
    decrypted_msg = cipher.decrypt(ct)
 
    print(decrypted_msg.decode())
Solution

And the flag is:

flag.txt
HTB{th1s_1s_th3_t1ny_3ncryp710n_4lg0r1thm_____y0u_m1ght_h4v3_4lr34dy_s7umbl3d_up0n_1t_1f_y0u_d0_r3v3rs1ng}
flag

crypto / Primary Knowledge

Surrounded by an untamed forest and the serene waters of the Primus river, your sole objective is surviving for 24 hours. Yet, survival is far from guaranteed as the area is full of Rattlesnakes, Spiders and Alligators and the weather fluctuates unpredictably, shifting from scorching heat to torrential downpours with each passing hour. Threat is compounded by the existence of a virtual circle which shrinks every minute that passes. Anything caught beyond its bounds, is consumed by flames, leaving only ashes in its wake. As the time sleeps away, you need to prioritise your actions secure your surviving tools. Every decision becomes a matter of life and death. Will you focus on securing a shelter to sleep, protect yourself against the dangers of the wilderness, or seek out means of navigating the Primus’ waters?

Let’s take a look at what we have:

output.txt
n = 144595784022187052238125262458232959109987136704231245881870735843030914418780422519197073054193003090872912033596512666042758783502695953159051463566278382720140120749528617388336646147072604310690631290350467553484062369903150007357049541933018919332888376075574412714397536728967816658337874664379646535347
e = 65537
c = 15114190905253542247495696649766224943647565245575793033722173362381895081574269185793855569028304967185492350704248662115269163914175084627211079781200695659317523835901228170250632843476020488370822347715086086989906717932813405479321939826364601353394090531331666739056025477042690259429336665430591623215
RSA variables

The source file contains this:

source.py
import math
from Crypto.Util.number import getPrime, bytes_to_long
from secret import FLAG
 
m = bytes_to_long(FLAG)
 
n = math.prod([getPrime(1024) for _ in range(2**0)])
e = 0x10001
 
c = pow(m, e, n)
 
with open('output.txt', 'w') as f:
 
    f.write(f'{n = }\n')
    f.write(f'{e = }\n')
    f.write(f'{c = }\n')
Source challenge

This is a modified version of the RSA encryption algorithm, which works by setting NN to be a product of 1024-bit prime numbers, named PP, and QQ, and then encrypting the flag using the public exponent e=65537e = 65537. The key point is in n = math.prod([getPrime(1024) for _ in range(2**0)]), is that it generates a product 202^0 prime numbers, which means that nn is prime. The security behind RSA is based on the impossibility of factoring large numbers, but we can easily factor a number if we know that it is prime, to {n: 1}

Euler’s totient function

Euler’s totient function, ϕ(n)\phi(n), is a function that counts the number of positive integers less than nn that are coprime to nn. It is defined as:

ϕ(n)=n(11p1)(11p2)(11pk)\phi(n) = n \left(1 - \frac{1}{p_1}\right)\left(1 - \frac{1}{p_2}\right) \cdots \left(1 - \frac{1}{p_k}\right)

If nn is a prime number pp, the prime factorization is pp itself:

ϕ(p)=p(11p)=pp1p=p1\phi(p) = p \left(1 - \frac{1}{p}\right) = p \cdot \frac{p - 1}{p} = p - 1

RSA Decryption

To decrypt a ciphertext CC, and recover the message MM, use the formula:

M=CdmodnM = C^d \mod n

Where dd is the modular multiplicative inverse of ee modulo ϕ(n)\phi(n), which is the Euler’s Totient Function of nn. If nn is prime, then ϕ(n)=n1\phi(n) = n - 1.

In this case, we can calculate the private exponent dd as:

d=e1mod(n1)d = e^{-1} \mod (n - 1)

Here’s the solution:


My solution

solve.py
from Crypto.Util.number import long_to_bytes
 
n = 144595784022187052238125262458232959109987136704231245881870735843030914418780422519197073054193003090872912033596512666042758783502695953159051463566278382720140120749528617388336646147072604310690631290350467553484062369903150007357049541933018919332888376075574412714397536728967816658337874664379646535347
e = 65537
c = 15114190905253542247495696649766224943647565245575793033722173362381895081574269185793855569028304967185492350704248662115269163914175084627211079781200695659317523835901228170250632843476020488370822347715086086989906717932813405479321939826364601353394090531331666739056025477042690259429336665430591623215
 
phi = n - 1
d = pow(e, -1, phi)
m = pow(c, d, n)
 
flag = long_to_bytes(m)
print(flag.decode())

Alternative solution

RsaCtfTool can also be used to solve this challenge:

RsaCtfTool command
docker run --rm -it -v $PWD:/data notepid/rsactftool -n 144595784022187052238125262458232959109987136704231245881870735843030914418780422519197073054193003090872912033596512666042758783502695953159051463566278382720140120749528617388336646147072604310690631290350467553484062369903150007357049541933018919332888376075574412714397536728967816658337874664379646535347 -e 65537 --uncipher 15114190905253542247495696649766224943647565245575793033722173362381895081574269185793855569028304967185492350704248662115269163914175084627211079781200695659317523835901228170250632843476020488370822347715086086989906717932813405479321939826364601353394090531331666739056025477042690259429336665430591623215

RsaCtfTool output
WARNING: The requested image's platform (linux/amd64) does not match the detected host platform (linux/arm64/v8) and no specific platform was requested
private argument is not set, the private key will not be displayed, even if recovered.
 
[] Testing key /tmp/tmp8yqq_0vs.
[] Performing factordb attack on /tmp/tmp8yqq_0vs.
[*] Attack success with factordb method !
 
Results for /tmp/tmp8yqq_0vs:
 
Unciphered data :
HEX : 0x4854427b30685f64346d6e5f346e793768316e675f7234317333645f74305f305f31735f312121217d
INT (big endian) : 154494104126246428636989946273736411011334723383700225366857664731705373903436582850185452949938557
INT (little endian) : 267274801349899412188868063532143570829996278787448868450360864366938573170691131108957681933767752
utf-8 : HTB{0h_d4mn_4ny7h1ng_r41s3d_t0_0_1s_1!!!}
STR : b'HTB{0h_d4mn_4ny7h1ng_r41s3d_t0_0_1s_1!!!}'

TL;DR

RSA encrypted text, with nn, ee, and a ciphertext encoded. nn is weak because it’s prime (so ϕ(N)=N1\phi(N) = N - 1. Use Euler’s totient function to decrypt ciphertext into original text.

And the flag is:

HTB{0h_d4mn_4ny7h1ng_r41s3d_t0_0_1s_1!!!}
flag

crypto / Makeshift

Weak and starved, you struggle to plod on. Food is a commodity at this stage, but you can’t lose your alertness - to do so would spell death. You realise that to survive you will need a weapon, both to kill and to hunt, but the field is bare of stones. As you drop your body to the floor, something sharp sticks out of the undergrowth and into your thigh. As you grab a hold and pull it out, you realise it’s a long stick; not the finest of weapons, but once sharpened could be the difference between dying of hunger and dying with honour in combat.

Let’s take a look at what we’ve been given.

output.txt
!?}De!e3d_5n_nipaOw_3eTR3bt4{_THB
output

The first thing I noticed, is that we can clearly see HTB, and both curly braces in the encrypted flag ourselves, meaning we have an anagram that we need to solve.

source.py
 
from secret import FLAG
 
flag = FLAG[::-1]
 
new_flag = ''
 
for i in range(0, len(flag), 3):
 
    new_flag += flag[i+1]
    new_flag += flag[i+2]
    new_flag += flag[i]
 
print(new_flag)
Source challenge

How does this work?

This takes the flag, reverses it, and reorders the flag into groups of three. For example if the flag was ‘FUNNY_FLAG’, this would be the order of operations.

Reversing the flag

GALF_YNNUF

Reorders the flag into groups of three letters.

ALF_YNNUFGAL

Here’s the solution script:

solve.py
encoded_flag = "!?}De!e3d_5n_nipaOw_3eTR3bt4{_THB"
 
decoded_flag = ''
length = len(encoded_flag)
 
for i in range(0, length, 3):
    decoded_flag += encoded_flag[i+2]
    decoded_flag += encoded_flag[i]
    decoded_flag += encoded_flag[i+1]
 
original_flag = decoded_flag[::-1]
print(original_flag)
Solution

And the flag is:

flag.txt
HTB{4_b3tTeR_w3apOn_i5_n3edeD!?!}
flag

crypto / Dynastic

You find yourself trapped inside a sealed gas chamber, and suddenly, the air is pierced by the sound of a distorted voice played through a pre-recorded tape. Through this eerie transmission, you discover that within the next 15 minutes, this very chamber will be inundated with lethal hydrogen cyanide. As the tape’s message concludes, a sudden mechanical whirring fills the chamber, followed by the ominous ticking of a clock. You realise that each beat is one step closer to death. Darkness envelops you, your right hand restrained by handcuffs, and the exit door is locked. Your situation deteriorates as you realise that both the door and the handcuffs demand the same passcode to unlock. Panic is a luxury you cannot afford; swift action is imperative. As you explore your surroundings, your trembling fingers encounter a torch. Instantly, upon flipping the switch, the chamber is bathed in a dim glow, unveiling cryptic letters etched into the walls and a disturbing image of a Roman emperor drawn in blood. Decrypting the letters will provide you the key required to unlock the locks. Use the torch wisely as its battery is almost drained out!

Let’s take a look at what we have.

output.txt
Make sure you wrap the decrypted text with the HTB flag format :-]
DJF_CTA_SWYH_NPDKK_MBZ_QPHTIGPMZY_KRZSQE?!_ZL_CN_PGLIMCU_YU_KJODME_RYGZXL
ciphertext

source.py
from secret import FLAG
 
from random import randint
 
def to_identity_map(a):
    return ord(a) - 0x41
 
def from_identity_map(a):
    return chr(a % 26 + 0x41)
 
def encrypt(m):
    c = ''
    for i in range(len(m)):
        ch = m[i]
        if not ch.isalpha():
            ech = ch
        else:
            chi = to_identity_map(ch)
            ech = from_identity_map(chi + i)
        c += ech
    return c
 
with open('output.txt', 'w') as f:
    f.write('Make sure you wrap the decrypted text with the HTB flag format :-]\n')
    f.write(encrypt(FLAG))
Source challenge

How does this encrypt our flag?

For each char in the message: If char is not in the alphabet, it changes nothing to the char. If char is in the alphabet: Maps the char into its position in the alphabet (for example aa being 01) Adds offset value to mapped previous number Converted back to an integer using mod 26, forcing the character to be within range of 0 to 25. Adds 0x41 (which is the representation of ‘A’), making character to a capital letter. For example: If a % 26 = 0, adding 0x41 results in 65, which maps towards the ASCII value for ‘A’, same with 66 being ‘B’, etc. This is why all alphabet characters in the encrypted flag are uppercase.

I’ve added comments, to clarify which function is which. This was my solution script.

solve.py
def fuck(a):
    return ord(a) - 0x41  # subtracting 0x41, mapping 'A' to 0, 'B' to 1, ..., 'Z' to 25
 
# reverse mapping of integers to characters
def from_identity_map(a):
    return chr(a % 26 + 0x41) # making sure result is range 0-25, and adding 0x41, converting it back to an ASCII char
 
# decrypting the ciphertext
def decrypt(ciphertext):
    m = ''
    for i in range(len(ciphertext)):
        ch = ciphertext[i]
        if not ch.isalpha():  # if the character is not an alphabet character
            m += ch  # keep it unchanged
        else:
            c = fuck(ch)  # mapping character to integer using the reverse mapping function (which is named 'fuck')
            m += from_identity_map(c - i)  # reversing shift by subtracting the index 'i' from the mapped value and converting back
    return m
 
# ciphertext
to_decode = 'DJF_CTA_SWYH_NPDKK_MBZ_QPHTIGPMZY_KRZSQE?!_ZL_CN_PGLIMCU_YU_KJODME_RYGZXL'
 
# decrypting cipher, adding HTB flag format
decoded_text = decrypt(to_decode)
flagged_text = f'HTB{{{decoded_text}}}'
 
print(flagged_text)
 
Solution script

flag.txt
HTB{DID_YOU_KNOW_ABOUT_THE_TRITHEMIUS_CIPHER?!_IT_IS_SIMILAR_TO_CAESAR_CIPHER}
flag

blockchain / russian roulette

This challenge took a lot of time and work trying to learn web3 and the blockchain. We get attached two .sol files, Setup.sol, and RussianRoulette.sol meaning they’re using Solidity, which is a programming language made for developing Ethereum contracts. Here’s Setup.sol.

Setup

Setup.sol
pragma solidity 0.8.23;
 
import {RussianRoulette} from "./RussianRoulette.sol";
 
contract Setup {
    RussianRoulette public immutable TARGET;
 
    constructor() payable {
        TARGET = new RussianRoulette{value: 10 ether}();
    }
 
    function isSolved() public view returns (bool) {
        return address(TARGET).balance == 0;
    }
}

This Setup.sol sends 10 ether to the RussianRoulette.sol contract, and it has an isSolved() function that returns a bool if the RussianRoulette contract is 0. Here’s RussianRoulette.sol.

RussianRoulette.sol
pragma solidity 0.8.23;
 
contract RussianRoulette {
 
    constructor() payable {
        // i need more bullets
    }
 
    function pullTrigger() public returns (string memory) {
        if (uint256(blockhash(block.number - 1)) % 10 == 7) {
            selfdestruct(payable(msg.sender)); // 💀
        } else {
  return "im SAFU ... for now";
     }
    }
}

In the RussianRoulette contract, it has a pullTrigger function that returns a string, and if the blockhash of the previous block is divisible by 10 and the remainder is 7, it self-destructs. When a contract self-destructs, it sends all of its remaining balance to the caller (which is us in this case), and because the isSolved function checks if the balance is 0, we can just keep pulling the trigger until it triggers selfdestruct, and then we can get the flag.

This went on and on, going through multiple ideas, until I finally realized that I needed an ABI to interact with the ETH smart contract, which was RussianRoulette in this case. I got the ABI by simply going back to the Remix IDE, and compiling, and then copying the ABI from there.

What’s an ABI?

An ABI (Application Binary Interface) is a file that contains the contract’s functions and their inputs and outputs. It’s used to interact with the contract from the outside world, and it’s what I needed to interact with the contract from my Python script. You could simply say an ABI acts as a translator between the contract and the outside world.

For example, This is RussianRoulette’s ABI.

RussianRoulette ABI
[
  {
    "inputs": [],
    "stateMutability": "payable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "pullTrigger",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]

Notice how this is just a JSON file, which contains everything about the contract. I noticed how the RussianRoulette contract has a pullTrigger function, and it returns a string. We can use this string to check if the self-destruct event was triggered, and simply pull the trigger until the contract balance is 0.

On the challenge itself, we are provided two ports. The first port simply just says “Sandbox is active”, and this is the sandbox running the ETH contracts.

The second port simply contains a netcat instance of which we can use to get the addresses we need. We get the private key, the address, the target contract, and the setup contract. We can use the private key to interact with the contract, and using the same port, we can get the flag.

In the below script, I will call the sandbox RPC_PORT, and the netcat instance SEND_PORT. Here’s the final script. I’ve added comments if you need help understanding what I’m doing.

Solution

solve.py
from web3 import Web3
from pwn import remote
 
# inline ABI's (who would've known this was possible)
setup_abi = [
    {"inputs": [], "stateMutability": "payable", "type": "constructor"},
    {"inputs": [], "name": "TARGET", "outputs": [{"internalType": "contract RussianRoulette", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
    {"inputs": [], "name": "isSolved", "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], "stateMutability": "view", "type": "function"}
]
 
rr_abi = [
    {"inputs": [], "stateMutability": "payable", "type": "constructor"},
    {"inputs": [], "name": "pullTrigger", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "nonpayable", "type": "function"}
]
 
def getAddressAndConnectToRPC():
    global HOST, RPC_PORT, SEND_PORT, w3
 
    HOST = 'localhost'
    RPC_PORT = 1337
    SEND_PORT = 1338
 
    r = remote(HOST, SEND_PORT)
    r.sendlineafter(b'action? ', b'1')
 
    contents = r.recvall().strip().decode()
    r.close()
 
    replacements = [
        "2 - Restart Instance", "3 - Get flag", "action?", "Private key",
        "Address", "Target contract", ":", "Setup contract", " "
    ]
 
    for item in replacements:
        contents = contents.replace(item, "")
 
    contents = contents.strip()
    lines = contents.splitlines()
 
    global private_key, address, target_contract, setup_contract
    private_key = lines[0]
    address = lines[1]
    target_contract = lines[2]
    setup_contract = lines[3]
 
# call the function to get the variables
getAddressAndConnectToRPC()
 
# connecting to ethereum
rpc_url = 'http://{}:{}'.format(HOST, RPC_PORT)
web3 = Web3(Web3.HTTPProvider(rpc_url))
 
# creating the contracts
setup_contract = web3.eth.contract(address=setup_contract, abi=setup_abi)
russian_roulette_contract = web3.eth.contract(address=target_contract, abi=rr_abi)
 
# pulling trigger until the contract balance is zero
while web3.eth.get_balance(target_contract) > 0:
    tx_hash = russian_roulette_contract.functions.pullTrigger().transact()
    tx_receipt = web3.eth.wait_for_transaction_receipt(tx_hash)
    print("Trigger pulled. Transaction receipt:", tx_receipt)
 
print("got the flag!")
 
# connecting to second remote, getting the flag
r = remote(HOST, SEND_PORT)
 
# sending 3 - which maps to "Get flag"
r.sendlineafter(b'action? ', b'3')
 
# recieves the line containing the flag
flag = str(r.recvline().strip().decode()).replace("action?", "").strip()
 
print(flag)
Solution

And the flag is:

flag.txt
HTB{99%_0f_g4mbl3rs_quit_b4_bigwin}
flag

Reflection

Being the second CTF I’ve played all the way through, I think we did a great job. I have to thank my team D0M BU$TЄR$ for their insights, solving challenges I wouldn’t even know how to start. The CTF itself was extremely fun, and the challenges were well thought out. Kudos to the authors!

Thank you so much for reading.