A Peek into an In-Game Ad Client

This should be a quick one! No crazy bugs, just an interesting little target I hadn't seen anyone dig into, and a few helpful tricks for quick reverse engineering.

A little bit ago I re-installed the racing game Trackmania, and I noticed I got product ads displayed at me in-game alongside the racetrack. Where were those coming from?

In-Game Ad in Trackmania

A bit of digging revealed a DLL loaded into the game, anzu.dll. Finding the company’s site, anzu.io helped to peel back the covers. These in-game ads were part of a complete ad-breakfast, including targeting, analytics, ad bidding, and more. Their site even claimed you could target ads based on in-game metrics). Their site also had many fun pictures of smiling people holding controllers in teal hoodies.

Happy Gamer at anzu.io

Digging into the DLL I wanted to see how this library was being used. The DLL was stripped of symbol information, hiding function and variable names that would have made reverse engineering easier. The exceptions were the exported symbols, and there were quite a few exports. We could just start walking through in Binary Ninja or another decompiler, but having a concrete look at the way the game was using the library would make things go much quicker.

Trampoline Tricks

I wanted to start with just tracking what Anzu_ functions were called, and how. Which functions exported by the library even mattered? What did the arguments look like? To quickly gather call traces I generated a "trampoline" DLL that exported all of the same functions as the original, but each function would simply output to a log file before jumping into the original DLL.

To do this we couldn't just export a C function with the same name, like so:

int Anzu_CampaignMetricGet(...) {  
    printf("Called Anzu_CampaignMetricGet\n");  
    return real_Anzu_CampaignMetricGet();  
}

This would not work as we didn't know how many arguments these calls took, or what types the arguments should have been. I solved this by generating a small assembly stub for each export that would:

  1. Save the register state to the stack
  2. Call the logging function with the export's name
  3. Restore the register state
  4. jmp to the original function

If you try this, just make sure you save the SIMD registers as well, or you will clobber your float arguments and end up sending a bunch of strange requests to the server. (Oops!)

EXTERN real_Anzu_CampaignMetricGet : QWORD

Anzu_CampaignMetricGet proc EXPORT  
    //; save volatile registers  
    push rax  
    push rcx  
    //;...snipped

    //; call log  
    lea rcx, [Anzu_CampaignMetricGet_name]  
    call log_proc

    //; restore  
    //;...snipped  
    pop rcx  
    pop rax

    //; go to original  
    jmp [real_Anzu_CampaignMetricGet]  
Anzu_CampaignMetricGet ENDP

Anzu_CampaignMetricGet_name db "Anzu_CampaignMetricGet", 0

Example Trampoline

Our generated trampoline logic would now load the original DLL and save off pointers to all the original functions. Our exports were these small assembly stubs passing all the arguments on to the original functions. (I also double checked that all the exports seemed to be functions, exported data pointers would need some extra care.)

You can see my messy little code generator here in the repo gen_stubs.py. It outputs:

  1. A declaration section to:
    • Export the trampoline stub with __declspec
    • Create a global to hold a pointer to the original function
  2. Logic to grab the original function pointer using GetProcAddress
  3. The assembly stub for each needed export

Including Generated Stubs

This all took only a few minutes and a python script to be able to gather some traces showing how Trackmania was using the Anzu library. (Thankfully Trackmania didn't do any sort of signature checks on the DLL, and would just load any DLL at the correct path.)

Interception DLL Loaded  
Init complete  
@ Anzu_GetVersionFloat  
@ Anzu_InternalDebugging  
@ Anzu_SetGDPRConsent  
@ Anzu_InternalDebugging  
@ Anzu_ApplicationActive  
@ Anzu_InternalDebugging  
@ Anzu_SetLogLevel  
@ Anzu_RegisterLogCallback  
@ Anzu_RegisterMessageCallback  
@ Anzu_RegisterTextureUpdateCallback  
@ Anzu_RegisterTextureInitCallback  
@ Anzu_RegisterTextureImpressionCallback  
@ Anzu_InternalDebugging  
@ Anzu_InternalDebugging  
@ Anzu_RegisterTexturePlaybackCompleteCallback  
@ Anzu_InternalDebugging  
@ Anzu_InternalDebugging  
...snipped

Initial Library Call Trace

Of course, we could have done all this easily with a scriptable debugger like Frida, x64dbg, or WinDbg. A simple .logopen and pattern breakpoint (bm anzu!* ".printf \"%y\\n\", @rip; gc") in WinDbg would get us a similar trace.

Quick Library Trace in WinDbg

If we could have accomplished all this in a debugger, why go through the trouble of building our own DLL? I chose the DLL route to give us a nice way to start iterating. Our initial traces showed us the functions we cared to start digging into. As we delved into those with our disassembler we started defining nicer stubs than our generic assembly trampolines. We started logging arguments and even changing functionality. Below is a stub that enabled more verbose logging in Anzu, even when the game asked for only error logging.

__declspec(dllexport) void Anzu_SetLogLevel(int32_t level) {

    fprintf(g_logf, "@ Anzu_SetLogLevel(%x) (forcing 0 and injecting debug setup)\n", level);  
    real_Anzu_SetLogLevel(0);

    fprintf(g_logf, "DEBUG: Force registering log file:" LOG_NAME "\\n");  
    real_Anzu_InternalDebugging(0xc0de5b05, LOG_NAME);  
}

Forcing Verbose Logging

Interception DLL Loaded  
Init complete  
@ Anzu_GetVersionFloat  
@ Anzu_InternalDebugging(c0de5b11, 0000000008000000) => 0000000000000000  
@ Anzu_SetGDPRConsent  
@ Anzu_InternalDebugging(c0de5b10, 00000165857AD171) => 0000000000000000  
@ Anzu_ApplicationActive(d6eb0001)  
@ Anzu_InternalDebugging(c0de5b03, 0000000000000001) => 0000000000000000  
@ Anzu_SetLogLevel(4) (forcing 0 and injecting debug setup)  
DEBUG: Force registering log file:log.log  
@ Anzu_RegisterMessageCallback(00007FF783725A50, 0000000000000000) (redirecting)  
DEBUG: Force registering net callback  
@ Anzu_RegisterMessageCallback(00007FF61B645B70, 0000000000000000) (redirecting)  
@ Anzu_RegisterTextureUpdateCallback  
@ Anzu_RegisterTextureInitCallback  
@ Anzu_RegisterTextureImpressionCallback  
@ Anzu_InternalDebugging(c0de5b31, 00007FF61B646840) => 0000000000000000  
@ Anzu_InternalDebugging(c0de5b04, 00007FF61B6469E0) => 0000000000000000  
@ Anzu_RegisterTexturePlaybackCompleteCallback  
@ Anzu_InternalDebugging(c0de5aff, 0000000000000001) => 0000000000000000  
@ Anzu_InternalDebugging(c0de5b00, 0000000000000001) => 0000000000000000  
@ Anzu_InternalDebugging(c0de5b16, 00007FF61C21E1E8) => 0000000000000000  
@ Anzu_InternalDebugging(c0de5b26, 00007FF61C21E3F8) => 0000000000000000  
@ Anzu_InternalDebugging(c0de5b17, 00007FF61C0BEEAC) => 0000000000000000  
@ Anzu_InternalDebugging(c0de5b27, 00007FF61C1ECE80) => 0000000000000000  
@ Anzu_Initialize  
@ Log CB 0: Registering SDK exit handler ...  
@ Message Callback: {"data":"ok","subtype":"initialize","type":"status"}  
@ Log CB 0: Cache capacity: 134217728, size: 12322  
@ Log CB 0: Cleaning cache entries...  
@ Log CB 1: Anzu SDK 5.41 is being initialized, CPUs = 24  
...snipped

Updated Trace A Few DLL Iterations Later

This codebase also gave us a jumping off point for building our own harness. With a harness for anzu.dll we could replace Trackmania, giving us faster resets and a simpler debugging environment. The code we wrote as we iterated on our middleware DLL, along with the gathered traces, made it easy to spin up a harness that used the Anzu library the same way Trackmania did. Having some harness code that can evolve alongside your reverse engineering effort can be worth more than a bunch of comments in your disassembler.

A few interesting things started popping out as we dug into the surface layer interactions with the Anzu DLL. First was their Anzu_InternalDebugging function exposed a lot of interesting functionality that was being used. By cross-referencing data used in their logging functions we found one of these internal commands would redirect the debug output to a log file.

Layout of Anzu_InternalDebugging

There were lots of interesting callback functions we added to our trampoline DLL and harness. Log callbacks, network callbacks, texture update callbacks, etc.

We also saw raycasts reporting ad visibility to the library (Anzu__Texture_SetVisibilityScore). These were used for reporting when users actually saw the ad, and how much of it. The logging callbacks were also showing us some of the network connections that this library was making as it called home to load the latest configuration and logic from the mothership.

This lead us to our next quick RE trick!

Network Harness

While we could have continued to dive into our disassemblers, we cut to the good stuff quicker by getting in-between the network and the DLL, similar to how we got between the game and the library.

For quickly getting between the network comms of an application that doesn't support a proxy, mitmproxy has some neat tools. Using the local mode, we could redirect traffic for our processes by PID or process name. (mitmproxy uses WinDivert behind the scenes on Windows). Making an mitmproxy plugin is simple, and lets us do things like we did in our trampoline-DLL:

  • modifying certain responses
  • logging interesting calls
  • iterating our tooling as we better understand our target
  • etc.

mitmproxy also handled the certificate generation for us and TLS-shenanigans behind the scenes. This made it pretty plug-and-play to start messing with the Anzu traffic.

Anzu thankfully made this interception simple for us because it used the system's trust store for verifying TLS certificates. We just had to add our own root certificate to our machine's store and let mitmproxy use it. After that Anzu happily let us do some mischief in the middle, accepting our TLS certificate for it's servers. Besides the lack of pinning, the TLS verification logic seemed tight.

Trackmania itself seemed to do some kind of certificate pinning for its communications and would not accept our intercepted TLS connections. If the Anzu DLL had done the same we would have needed to go find and subvert the verification logic before we could easily intervene in the communications. As is, all we had to do was tell mitmproxy to only intercept connections to the Anzu backend, letting the Trackmania connections continue unimpeded.

mitmproxy --mode local:trackmania -s .\server\server.py --allow-hosts '^(gateway\.prod\.anzu\-us\.com|3\.231\.86\.84|3\.234\.186\.225|52\.55\.69\.208|3\.230\.88\.142|35\.171\.212\.145|3\.91\.163\.159):443$'

mitmproxy using --allow-hosts

Having the harness made it easy to identify what domains were associted with Anzu and interceptable. We also could have used --ignore-hosts similarly to just cut out the Trackmania specific traffic.

mitmproxy Ignoring Game Traffic

Going through the network communications we found it downloading JavaScript that contained the core logic for managing the ad campaigns. With a little bit of beautifying and code reveiw, we figured out how to craft responses to serve our own ads. See the repo for the full plugin.

def tls_start_client(self, tlsdata):  
        self.lg(f"TLS Start: {tlsdata.conn.sni}")

    def request(self, flow):  
        self.lg(f"Request: {flow.request.method} {flow.request.pretty_url}")

        if flow.request.path.startswith("/multi_gateway/"):  
            # give our own response  
            self.update_campaigns()  
            resp = self.ads  
            self.lg(f"Responding")

            flow.response = http.Response.make(  
                200,  
                bytes(json.dumps(resp), "utf8"),  
            )

mitmproxy Plugin for Serving Our Own Ad Campaign

Injected Response for a Custom Ad Campaign

For the longest time I thought Anzu had shadow-banned me, as I was served no ads after I started developing my tooling. It wasn't until I had already gotten my own ad campaigns going that I did eventually capture traffic of an actual ad campaign. The real ad campaigns were interesting though, as they supported some interesting features. They could contain JavaScript filters used to better target ads from on the client’s machine, as well as tons of options for tracking callbacks at various points of ad playback. On the ad campaign I was served they had over twenty callback URLs registered to report ad impressions going to a variety of tracking domains.

Our plugin makes it easy to respond with our own campaign when the system asks for ads from the gateway. In my ads, I served some images straight from localhost. It is left as an exercise for the reader to hook this up to cataas.com

An In-Game Ad for a Drink

The Atredis Bird in the Same Location

There are still some fun questions left open. What in-game metrics are other games actually reporting? Besides the visibility metrics, I didn't see any other game behavior mentioned. Do you remember the funny Pranking My Roommate with Targeted Facebook Ads article; can we replicate that? Can I advertise only to those stuck in losers queue? And what does it look like when the MQTT library is used? (I was never served a configuration that used it.) AWS put out a case study about Anzu, even hinting at some possible future LLM use in contextual advertising, has that been rolled out yet? Will Anzu ever respond to my email and let me bid on a few ads too? (I'll be nice!)

There are interesting hidden systems like Anzu all over the place; it is a lot of fun digging into them. Even though I didn't find any major security issues in the client, it was still fun to be able to whip up some harnesses and peer behind the curtain a bit. Feel free to contact me (@jordan9001) and tell me about your own little reverse engineering projects! If you own an interesting system you want us at Atredis to help secure, please contact us via our contact form; we would love to work with you.

Next
Next

3D Printing Flying Probe Test Harnesses: Can you?