krystalgamer - Blog

Reverse Engineering: Marvel's Avengers - Developing a Server Emulator

Context

During these past two weeks I had the chance to play the Marvel's Avengers Beta. The game allows the player to player solo or hop into matchmaking to find some squad-mates. Even playing solo it was clear that the game needed internet connectivity. Having already played last week during the closed-beta I decided to use the new open-beta to explore more about the game's networking.

The process

One of the reasons I like reversing and understanding client/server communications is that the process is much more clear than other types of reversing, such as custom archives and formats. The process goes like this:

  1. Understand the type, the sender and destination of the traffic - who is responsible of sending it, whether it's UDP/TCP, the endpoints, is it encrypted? ...
  2. Acquire readibility and instrumentation - develop/use tools to dump the traffic to later analyze it
  3. Learn the protocol details - crucial for debugging, this can be done by omitting requests/responses, messing with the contents,...

If the goal is to develop an emulator then there's extra steps:

  1. Start by parroting the server responses
  2. Slowly build the backend and start making your own responses

Understanding the traffic

I started by opening Wireshark and checking how my actions impact the traffic. During the game's boot there's nothing happening, pressing the Start button in the main menu the game performs a DNS query of cry-trmv6-beta.os.eidos.com followed by setting up a TLS connection to the return IP. This indicates that the protocol being HTTPS - it's nice since it well known but it's encrypted which hurts readibility and tamperability.

Having some experience with games that use HTTPS, such as Shakes and Fidget, it's common for the client not to care about the server being HTTP(useful for dumping packets) or even the server allowing HTTP requests. I added an entry to the HOSTS file to redirect the traffic to my local server sadly the connection couldn't be established and Square Enix servers only allowed HTTPS. This meant I had to dug deeper to acquire reading power of the packets.

This part got me scared since the game uses Denuvo which contributes to its 400MB of executable size. I wanted to avoid messing its anti-tamper measures. To verify where the traffic comes from within the game using x64dbg I placed a breakpoint at getaddrinfo in ws2_32.dll. When the breakpoint was hit reading the callstack showed where it came from, it was osdk.dll. This module was shipped with the game and until that moment I was not aware of its existence.

The next step was to see what it exports:

$ dumpbin -exports osdk.dll


    ordinal hint RVA      name

         25   18 000DB510 osdk_HTTPClient_SetDefaultLoggingFlags
         26   19 000DB520 osdk_HTTPClient_addBackFilter
         29   1C 000DB5F0 osdk_HTTPClient_enableBodyCompression
         30   1D 000DB600 osdk_HTTPClient_enableWebLogging
         33   20 000DB7A0 osdk_HTTPClient_setLoggingFlags
         34   21 000DBBF0 osdk_HTTPFilter_Create
         35   22 000DBF60 osdk_HTTPHeaderNames_Get
         36   23 000DBF70 osdk_HTTPHeaders_getKeyAt
         48   2F 000DC8A0 osdk_HTTPRequestBuilder_setConnectTimeout
         49   30 000DC8B0 osdk_HTTPRequestBuilder_setHeader
         50   31 000DC8C0 osdk_HTTPRequestBuilder_setParameter
         51   32 000DC8D0 osdk_HTTPRequestBuilder_setSerializationMilliseconds
         52   33 000A9B00 osdk_HTTPRequestBuilder_structSize
         53   34 000DC0B0 osdk_HTTPRequest_setHeader
         54   35 000DCA70 osdk_HTTPResponse_Create
         63   3E 000DCE10 osdk_HTTPResponse_getReceivedAt
         64   3F 000DCE40 osdk_HTTPResponse_getStatusCode
         65   40 000DCE50 osdk_HTTPResponse_setStatus
         66   41 000DCEE0 osdk_HTTPUserAgentBuilder_build
         67   42 000DCF50 osdk_HTTPUserAgentBuilder_construct
         68   43 000DCF60 osdk_HTTPUserAgentBuilder_destruct
         69   44 000DCF70 osdk_HTTPUserAgentBuilder_setApplicationDetail
         75   4A 000DD000 osdk_HTTPUserAgentBuilder_setFrameworkVersion
         76   4B 000B5E20 osdk_IdentityProviders_Get
         83   52 000DD280 osdk_Identity_assignMove
         84   53 000DD290 osdk_Identity_construct
         85   54 000DD2B0 osdk_Identity_constructMove
        107   6A 000DD7A0 osdk_JSONArray_begin
        108   6B 000DD7C0 osdk_JSONArray_end
        109   6C 000DD7F0 osdk_JSONArray_get
        110   6D 000DD820 osdk_JSONArray_isEmpty
        111   6E 000DD840 osdk_JSONArray_pushBack
        112   6F 000DD860 osdk_JSONArray_pushBackBool
        147   92 000E5A20 osdk_JSONValue_asFloat
        148   93 000E5A30 osdk_JSONValue_asInt16
        149   94 000E5A30 osdk_JSONValue_asInt32
        150   95 000E5A40 osdk_JSONValue_asInt64
        208   CF 000C0F90 osdk_ManualSignal_cleanup
        209   D0 000C0FC0 osdk_ManualSignal_initialize
        217   D8 000E6220 osdk_MediaTypeNames_Get
        230   E5 000C0A60 osdk_ObjectId_Parse
        231   E6 000C0A70 osdk_ObjectId_structSize
        232   E7 000C0A80 osdk_ObjectId_toString
        233   E8 000A9B00 osdk_OsdkInfo_structSize
        234   E9 000C0AF0 osdk_ProfileId_structSize
        305  130 000BFF50 osdk_UInt32ToAscii
        306  131 000BFF70 osdk_UInt32ToAsciiLength
        307  132 000BFFB0 osdk_UInt64ToAscii
        308  133 000BFFD0 osdk_UInt64ToAsciiLength
        313  138 000C2B40 osdk_UserAccountType_Get
        314  139 000A57C0 osdk_UserId_structSize
        315  13A 000A5840 osdk_UserInfo_constructCopy
        316  13B 000A5850 osdk_UserInfo_move
        317  13C 000A58B0 osdk_UserInfo_structSize
        329  148 000C2E00 osdk_Variant_ConstructUInt16
        336  14F 000C3210 osdk_VersionValues_Get
        337  150 000C3220 osdk_Version_Parse
        338  151 000C32E0 osdk_Version_fromParts
        339  152 000C3320 osdk_Version_toString
        340  153 000A71A0 osdk_WebServiceClientAttributeKeys_Get
        341  154 000A97A0 osdk_WebServiceClientBuilder_Create
        342  155 000A97C0 osdk_WebServiceClientBuilder_build
        360  167 000A6A10 osdk_WebServiceClient_addStatisticsDeserializationMilliseconds
        387  182 000A3C90 osdk_addProcessCallback
        388  183 000A3CA0 osdk_beginNewGameSession

Some of the entries were removed due to being too long. This was my breakthrough, this library is responsible for the communications and it appears to be done in JSON!

Digging around the strings I noticed it was using libcurl which is also awesome, but also libuv which could pose serious problems for debugging and tracing. IDA wasn't picking cURL's function names so I had to use one of the community FLIRT databases, FLIRTDB, which was key in finding functions such as curl_easy_setopt. cURL has two interfaces the easy and the multi, the latter being focused in asynchronous programming. Even though it uses libuv all the connections are done via the easy interface.

Having the targets isolated it was time to add some logging. In this case I resorted to dll proxying which consists of substituting the original module with a one created by me and redirecting all the calls to the original one. Microsoft Visual C compiler makes it super easy with a simple pragma directive.

__pragma (comment (linker, "/export:" export_name "=" original_dll_name.export_name ",@" ordinal))

In this case I renamed osdk.dll to osdk_orig.dll, the new module exported the exact same functions which just forwarded to the original.

#define FORWARDED_EXPORT_WITH_ORDINAL(exp_name, ordinal, target_name) __pragma (comment (linker, "/export:" #exp_name "=" #target_name ",@" #ordinal))

FORWARDED_EXPORT_WITH_ORDINAL(osdk_Allocator_Free, 1, osdk_orig.osdk_Allocator_Free)
FORWARDED_EXPORT_WITH_ORDINAL(osdk_Allocator_Init, 2, osdk_orig.osdk_Allocator_Init)

(...)

DLL Proxying has also another big advantage, since DllMain runs before the main executable(the ones of the modules that are explicitly imported!), it's the safest place to perform any hooks/patches. Other methods such as DLL Injection require creating an extra thread which might cause some racing issues.

Here I run into another problem, for some reason GetModuleHandle was always returning 0, except when the argument was NULL. Calling ntdll!LdrGetDllHandle also yielded 0 and even walking the LDR table from PEB(Process Environment Block), yield no modules which was extremely suspicious.

I was eager to to get my hooks running and debugging Windows internal structures was not on my best interest, because it might be caused by Denuvo! Being desperate I decided to try something that even Microsoft discourages from doing, calling LoadLibrary in Dllmain, the reason behind it is that it during the DllMain routine the loader lock is acquired and is not free'd until it's over(also the loaded modules list must not change inside), thus loading new modules might cause crashes or deadlock.

Since my dll depends on osdk_orig.dll then it must be loaded after the original one, thus my call of LoadLibrary does not violate the condition of modifying the module list! Luckily it worked and GetProcAddress was also working. Using osdk_Allocator_Init as a pivot, the offsets to all the relevant functions inside osdk_orig.dll were calculated and relevant functions hooked. It was time to start logging!

Logging curl_easy_setopt

Performing requests with cURL is quite straightforward, first create a CURL* handle with curl_easy_init and then set ALL connection related settings/parameters curl_easy_setopt. The function prototype is as follows:

CURLcode curl_easy_setopt(CURL *handle, CURLoption option, parameter);

The first argument was already discussed, the second one is an enum which specifies which option will be set and the third argument is the data related to the parameter. E.g with option CURLOPT_URL the parameter would be a null-terminated string of the url.

Hooking this function was done with a trampoline, which consists of over-writing the function prologue with something like this:

mov rax, ADDRESS_OF_MY_DETOUR
push rax
ret

One cool thing I learned about cURL's internals is that it uses a self-defined macro to enforce 3 arguments but the function still works with va_args?

============ from curl.h ======================
/* This preprocessor magic that replaces a call with the exact same call is
   only done to make sure application authors pass exactly three arguments
   to these functions. */
#define curl_easy_setopt(handle,opt,param) curl_easy_setopt(handle,opt,param)

============ from setopt.c ======================
#undef curl_easy_setopt
CURLcode curl_easy_setopt(struct Curl_easy *data, CURLoption tag, ...)
{
  va_list arg;
  CURLcode result;

  if(!data)
    return CURLE_BAD_FUNCTION_ARGUMENT;

  va_start(arg, tag);

  result = Curl_vsetopt(data, tag, arg);

  va_end(arg);
  return result;
}

In order to dump the options I redefined the CURLoption enum by re-using their CURLOPT macro:

#define CURLOPT(na,t,nu) na = t + nu

//CURLOPTTYPE_STRINGPOINT is a define of a constant

typedef enum {

  /* The full URL to get/put */
  CURLOPT(CURLOPT_URL, CURLOPTTYPE_STRINGPOINT, 2)
}

This is where the beauty of C comes, for logging it's much more convenient to have the options in text format and not integers like enums are. The stringification preprocessor was the key for this problem, the # sign in macros allows to turn any variable passed to a string, thus I lazily converted the CURLOPT macro to CURLOPT_CASE as follows:

#define CURLOPT_CASE(name, ignore, ignore1) \
	case name:\
	logger("setopt: %s (%d)\n", #name, name);\
	break;

switch (tag) {
			CURLOPT_CASE(CURLOPT_WRITEDATA, CURLOPT_CASETYPE_OBJECTPOINT, 1)

			CURLOPT_CASE(CURLOPT_URL, CURLOPT_CASETYPE_STRINGPOINT, 2)
		(...)

}

The result:

setopt: CURLOPT_PRIVATE (10103)
setopt: CURLOPT_CONNECTTIMEOUT (78)
setopt: CURLOPT_HTTPGET (80)
setopt: CURLOPT_URL (10002)
setopt: CURLOPT_POSTFIELDSIZE (60)
setopt: CURLOPT_READDATA (10009)
setopt: CURLOPT_READFUNCTION (20012)
setopt: CURLOPT_READFUNCTION (20012)
setopt: CURLOPT_LOW_SPEED_TIME (20)
setopt: CURLOPT_LOW_SPEED_LIMIT (19)
setopt: CURLOPT_ERRORBUFFER (10010)
setopt: CURLOPT_WRITEDATA (10001)

Establishing connection to own server

cURL by default performs host and peer verification with HTTPS connections, this means it must be disabled dynamically. To disable curl_easy_setopt needs to be called with CURLOPT_VERIFYPEER and CURLOPT_VERIFYHOST with the parameter set as 0. My solution was to inject these two calls right after CURLOPT_URL is set, this guarantees that it's set for all cURL handles.

if (tag == CURLOPT_URL){
		curl_easy_setopt(data, CURLOPT_SSL_VERIFYHOST, 0);
		curl_easy_setopt(data, CURLOPT_SSL_VERIFYPEER, 0);
}

For the server I was using flask which has a really useful option ssl_context='adhoc' which allows to generate TLS certificates on the fly, which are marked as Dummy Certificate. With the verification disabled and traffic redirection(via DNS) done it was time to start logging endpoints and the incoming traffic. Now I had a way of logging what the client sent, it was time to log what the server sent.

The result:

Client sent:
{
  "userSandbox": "steam",
  "user": {
    "uid": "[redacted]",
    "provider": "steam_id"
  },
  "system": "windows",
  "identity": {
    "type": "steam_token",
    "token": "[redacted]"
  }
}

CURLOPT_READFUNCTION/CURLOPT_WRITEFUNCTION Hook

The nomenclature on these functions is a little confusing at first since READ is what the server wants to read from the client and WRITE is what the server wrote to the client. These options setup the callback function when any of these actions occur. The buffers that these functions receive are in a decrypted state, thus logging the packets it's just a matter of printf.

Since I already controlled what is passed to curl_easy_setopt it was quite easy to replace osdks_orig callback with mine:

typedef size_t (*write_callback_ptr)(char* ptr, size_t size, size_t nmemb, void* userdata);
write_callback_ptr write_callback_orig = NULL;
size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata) {

	logger("got write(%p) here(%d) %.*s\n", userdata, nmemb, nmemb, ptr);
	return write_callback_orig(ptr, size, nmemb, userdata);
}


typedef size_t (*read_callback_ptr)(char* buffer, size_t size, size_t nitems, void* userdata);
read_callback_ptr read_callback_orig = NULL;
size_t read_callback(char* buffer, size_t size, size_t nitems, void* userdata) {

	size_t ret = read_callback_orig(buffer, size, nitems, userdata);
	logger("got read here %.*s\n", ret, buffer);
	return ret;
}

DWORD64 curl_easy_setopt(struct Curl_easy* data, DWORD64 tag, ...)
{
	print_setopt(tag);
	va_list arg;
	DWORD64 result;

	if (!data)
		return 43;

	va_start(arg, tag);


	(...)
	else if (tag == CURLOPT_WRITEFUNCTION) {

		write_callback_ptr tmp = va_arg(arg, write_callback_ptr);
		
		if (tmp != write_callback) {
			logger("Will spoof write callback %p\n", write_callback);
			write_callback_orig = tmp;
			return curl_easy_setopt(data, tag, write_callback);
		}

		va_end(arg);
		va_start(arg, tag);
	}
	else if (tag == CURLOPT_READFUNCTION) {

		read_callback_ptr tmp = va_arg(arg, read_callback_ptr);

		if (tmp != read_callback) {
			logger("Will spoof read callback %p\n", read_callback);
			read_callback_orig = tmp;
			return curl_easy_setopt(data, tag, read_callback);
		}

		va_end(arg);
		va_start(arg, tag);
	}

The result:

got read here {
  "userSandbox": "steam",
  "user": {
    "uid": "[redacted]",
    "provider": "steam_id"
  },
  "system": "windows",
  "identity": {
    "type": "steam_token",
    "token": "[redacted]"
  }
}

got write(0000000170938590) here(873) {
  "baseURI": "https://cry-trmv6-beta-prod.os.eidos.com/api",
  "token": "[redacted]",
  "membership": {
    "id": "[redacted]",
    "email": "[redacted]",
    "confirmed": true
  },
  "services": {
    "version": {
      "max_lcm_version": "1.1.0.0",
      "min_lcm_version": "1.0.0.0",
      "max_game_version": "0.0.1",
      "min_game_version": "0.0.1"
    }
  }
}

NOTE: There's also HEADERFUNCTION which I also hooked, it's not as relevant as the other two so I did not include it in, the ideia of hooking is the exact same.

Logging and async problem

For small responses like the ones showed above it was all fine and dandy. There are some responses such as for Market, Wartable and level jsons(yes, each level has a json) which are huge, not only they contain a ton of data but also the text displayed in ALL available languages. The biggest mission I have recorded is 47KB and it's Condition: Green.

The functions hooked above get called as soon there's data so there were times that they're called with as little as 8 characters in the buffer, rebuilding them by hand would be a nightmare since they're all scattered around the logs.

The solution to this problem was also provided by libcurl interface. The write and read callbacks contain a 4th parameter which is defined in the documentation as userdata. This parameter works like an accumulator, incoming data comes from ptr and should be stored in userdata. Due to the fact the game uses different buffers for different requests, it was the best way to get complete packet dumps. The implemented solution was not the most performant(it's pretty bad) but it served it's purpose. It consisted in creating and appending to a text file with the name of the buffer's address - a few minutes in the game exploring all the missions was enough to get all the JSON responses.

size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata) {

	logger("got write(%p) here(%d) %.*s\n", userdata, nmemb, nmemb, ptr);
	char tmp[255];
	sprintf(tmp, "[REDACTED]\\%p.txt", userdata);

	FILE* fp = fopen(tmp, "a+");
	fwrite(ptr, nmemb, size, fp);
	fclose(fp);
	return write_callback_orig(ptr, size, nmemb, userdata);
}

Logging the read callback turned useless, which will be explained in the next section.

The results:

$ stat -c "%n %s" * | sort

000000016FEB6E20.txt 26516
00000001700DBDB0.txt 20120
0000000170938590.txt 1065
0000000184C69780.txt 151453
00000001854A8060.txt 131
0000000188971080.txt 2
0000000188975920.txt 1139
000000018A8EB810.txt 131
000000018B4780D0.txt 1008
000000018EAE6A50.txt 86249
000000018EC02A00.txt 60
000000018EC1B9D0.txt 19522
000000018EC38310.txt 9120
000000018ECD16D0.txt 2420
000000018ECDC7A0.txt 449
000000018ECF2FB0.txt 16318
000000018ED00C10.txt 468939
000000018ED0D4F0.txt 887819
000000018ED20540.txt 1546
000000018F02DDF0.txt 131
000000018F4545E0.txt 20
000000018F4B16E0.txt 20
000000018FA14320.txt 20
000000018FE18FC0.txt 20
000000018FF7A120.txt 20
000000018FFB3340.txt 367056
00000001900737D0.txt 299528
000000019026F0C0.txt 1031
00000001934FA2A0.txt 1008
00000001A2504E60.txt 59
00000001AB601840.txt 236
00000001AB62A270.txt 232
00000001AC2BE620.txt 236
00000001ADEC3150.txt 131
00000001AED47B10.txt 234
00000001BDDE5500.txt 234
00000001BDE06630.txt 232
00000001BF94D330.txt 20
00000001C23D0FD0.txt 20
00000001CCAD22C0.txt 20

Developing the emulator

Anyone that has worked with Flask knows how easy it is to setup a new route, surprisingly all requests were getting 308 Permanent Redirect. For GET requests the client was following it through but with POST not only the client got stuck but the server was throwing an error - something along the lines that it only shows in debug mode that the correct endpoint should be used. The cause were the double slashes in the client endpoints such as /api//health and /api//login.

Googling this problem was quite hard, since the most common problem slash related was the trailing slash which can be solved by setting strict_slashes=False. The reason it's not common with flask or other frameworks it's because web servers such as nginx and apache merge them automatically. There were some people having the same problem, but the solution came from this SO post.

app = Flask(__name__)
app.url_map.merge_slashes = False

Serving JSON was done as follows:

def get_json(endpoint):
    with open(f'jsons/{endpoint}.json', encoding='utf8') as f:
        return json.load(f)

@app.route('/api//health')
def health():
    return 'OK'

@app.route('/api//login', methods=['GET', 'POST'])
def login():
    d = jsonify(get_json('login'))
    return d

Login served but not received

Having setup the most important endpoints I quickly got stuck on login, the web server was sending the response correctly but the client was not receiving it. I was sure it was being received but something internally was ditching the response. The contents being exactly the same the problem had to be the HTTP headers, checking the logs I found the following:

HTTP/1.1 200 OK

Server: Medici

Vary: Origin

Vary: Accept-Encoding

Cache-Control: no-transform

Content-Type: application/json

Date: Sun, 22 Aug 2020 19:26:58 GMT

Connection: keep-alive

X-Powered-By: General Sebastiano Di Ravello

X-Location: GCPEW1

Content-Length: 873

Set-Cookie: [REDACTED]; expires=Mon, 23 Aug 2021 06:55:26 GMT; HttpOnly; path=/; Domain=.os.eidos.com

Set-Cookie: [REDACTED]; path=/; Domain=.os.eidos.com

Set-Cookie: [REDACTED]; path=/; Domain=.os.eidos.com

X-CDN: Incapsula

X-Iinfo: [REDACTED]

Flask did not send half of these, so by trial and error I managed to conclude the problem was the lack of Connection-Type: keep-alive in my response, the fix was quick:

@app.route('/api//login', methods=['GET', 'POST'])
def login():
    d = jsonify(get_json('login'))
    d.headers['Connection'] = 'keep-alive'
    return d

It worked perfectly!

Beta over and emulator stops working

As soon as the beta was over it was time to really test my emulator aaaaaaaaaaaaaaand it got stuck at logging in. The problem was not apparent at all, since lots of requests were creating errors but the game was still playable. Looking at the logs something caught my attention, the login endpoint was throwing a 405 Method Not Allowed, now this is interesting. I had it setup to accept both GET and POST so what the hell was it trying to do? Checking the HTTP headers it became clear what was happening, the client was prefixing the HTTP method with a JSON token.

{"userSandbox":"steam","user":{"uid":"[redacted]","provider":"steam_id"},"system":"windows","identity":{"type":"steam_token","token":"[redacted]"}}POST /api//login

Square has their reasons for doing this, it probably gets filtered on the listener webserver before even hitting the node responsible before dealing with the request.

The solution was not the best but it did the trick:

@app.errorhandler(405)
def whygod(e):
    if 'trm_warzones' in request.url:
         return warzone_id(request.url.split('/')[-1])

    return login()

This handler solved the problem! Sadly I didn't figure why it worked on the previous day, weird stuff.

Clearing credentials for release

Having it work just fine was time to clear my personal data from it. E.g the /api endpoints has answers that contain your IP, your GPS coordinates, country and city, the /api//login contains steam tokens(which I'm not sure are really useful, regardless it's better to be safe than sorry).

Everything was cleared but there was still a thing that I didn't know if it was dangerous. The token field in the login response.

eyJhbGciOiJIUzI1NiJ9.eyJsb2MiOiJCRS1XQUwiLCJzdWIiOiI2OSIsImF0eSI6InByb2QiLCJjbXUiOiJzdGVhbSIsInVieCI6InN0ZWFtIiwic3lzIjoid2luZG93cyIsInJvbCI6WyJwbGF5QGNyeS10cm12Ni1iZXRhLXByb2Q6cmV0YWlsIl0sImlkcCI6InN0ZWFtX2lkIiwic2VnIjoiTmozdHROSExCZl9IZ1A3R1U5VTBZS0tZeUN0cXAyTDAiLCJjYngiOiJkZWZhdWx0Iiwic2VtIjoiMjIzMDI3MDMiLCJ0YWciOiJ0aGljYyBib2kiLCJza3UiOiIxMzU4ODIwIiwiZXhwIjoxNTk4MjQwNDI2LCJpYXQiOjE1OTgxOTcyMjZ9.eyJiYW5hbmEiOjF9

It looked like base64 so I decoded it and quickly learned it was a JWT(JSON Web Token). They're composed of three parts, seperated by a period - Header(defines signature algorithm), Payload(data) and Signature. The first two parts are base64 encoded and are JSONS such as these ones:

//Header
{"alg":"HS256"}


//Payload
{
  "loc": "BE-WAL",
  "sub": "69",
  "aty": "prod",
  "cmu": "steam",
  "ubx": "steam",
  "sys": "windows",
  "rol": [
    "play@cry-trmv6-beta-prod:retail"
  ],
  "idp": "steam_id",
  "seg": "Nj3ttNHLBf_HgP7GU9U0YKKYyCtqp2L0",
  "cbx": "default",
  "sem": "22302703",
  "tag": "thicc boi",
  "sku": "1358820",
  "exp": 1598240426,
  "iat": 1598197226
}

I read some articles on how to forge one, with methods that include changing the algorithm, 'alg':'none', my tests were a success I had succesfully bypassed the signature of JWT. In the meantime I had also isolated the routine responsible for the login procedure which is conveniently named OnlineSuiteIdentityProvider::PerformLogin. Something was off, there didn't appear to be any signature checking. In fact changing the alg or even the signature worked just fine.

The reason was that the game was trimming the string on the periods, thus only looking at the payload, there was NO SIGNATURE CHECK. Why even use JWT if the signature is not enforced?

Here's the excerpt from the responsible function:

__int64 __fastcall sub_180099260(const char *a1, __int64 a2, __int64 a3){
	/*
		removed
	*/
 v14 = memchr(v13, '.', v12 - (_QWORD)v13);    // finds first period
  if ( v14 )
    v12 = (__int64)v14;
  if ( v12 != osdk_String_end(&v121) )
  {
    v15 = v12 - (_QWORD)osdk_String_begin(&v121);
    if ( v15 != -1 )
    {
      v16 = osdk_String_end(&v121);
      v17 = osdk_String_begin(&v121);
      v18 = memchr(&v17[v15 + 1], '.', v16 - (_QWORD)&v17[v15 + 1]);// finds second period
      if ( v18 )
        v16 = (__int64)v18;
      if ( v16 != osdk_String_end(&v121) )
      {
        v19 = v16 - (_QWORD)osdk_String_begin(&v121);
        if ( v19 != -1 )
        {
          osdk_String_begin(&v121);
          v103 = 0i64;
          v104 = 0i64;
          v103 = &osdk_String_begin(&v121)[v15 + 1];
          v104 = (v19 - v15 - 1) & 0x7FFFFFFFFFFFFFFFi64;
          osdk_String_begin(&v121);
          if ( !v121 )
            osdk_String_length(&v121);
          base64decode((__int64)&v109, (__int64)&v103);
          v99 = 0i64;
          jsonparse(&v99, (__int64)&v109);
          v20 = (char *)v99;
          handlepayload((__int64)&v125, (__int64)v99);
	/*
		removed
	*/
}

With all of these the first iteration of the server emulator was complete!

No more HOSTS file

For the release it was clear that asking people to modify the hosts file was way too much. The solution I came up with was an IAT hook getaddrinfo on osdk_orig.dll. IAT stands for Import Address Table, since at compilation time addresses of the functions in DLL are not known each module has a table reserved for all imported functions. This table gets filled while the module is being loaded.

At the lower level it works like this:

//C
getaddrinfo(); // function of ws2_32.dll

;asm
call [iat_entry_for_getaddrinfo]; [] are a dereference in assembly

Thus if I know the offset of a call to getaddrinfo I can easily get it's address on table and replace it with function. Checking x-refs in IDA yielded two results inside Curl_getaddrinfo_ex.

.text:0000000180005540 Curl_getaddrinfo_ex proc near           ; CODE XREF: sub_1800018C0+100?p
.text:0000000180005540                                         ; getaddrinfo_thread+50?p ...
.text:0000000180005540
.text:0000000180005540 arg_0           = qword ptr  8
.text:0000000180005540 arg_8           = qword ptr  10h
.text:0000000180005540 ppResult        = qword ptr  20h
.text:0000000180005540
.text:0000000180005540                 push    rbp
.text:0000000180005542                 push    rsi
.text:0000000180005543                 push    r12
.text:0000000180005545                 push    r14
.text:0000000180005547                 push    r15
.text:0000000180005549                 sub     rsp, 20h
.text:000000018000554D                 xor     r12d, r12d
.text:0000000180005550                 mov     r15, r9
.text:0000000180005553                 mov     [r9], r12
.text:0000000180005556                 mov     esi, r12d
.text:0000000180005559                 lea     r9, [rsp+48h+ppResult] ; ppResult
.text:000000018000555E                 mov     r14d, r12d
.text:0000000180005561                 call    cs:getaddrinfo ;<-----------
.text:0000000180005567                 mov     ebp, eax
.text:0000000180005569                 test    eax, eax
.text:000000018000556B                 jnz     loc_1800056B8
INT WSAAPI getaddrinfo_hook(PCSTR pNodeName, PCSTR pServiceName, const ADDRINFOA* pHints,PADDRINFOA* ppResult) {

	logger("getaddrinfo of %s\n", pNodeName);
	INT res = getaddrinfo("localhost", pServiceName, pHints, ppResult);
	return res;
}

HMODULE value = LoadLibraryA("osdk_orig.dll");
DWORD64 allocatorInit = GetProcAddress(value, "osdk_Allocator_Init");
DWORD64 addressGetAddr = &getaddrinfo_hook;
DWORD lmao;
VirtualProtect(allocatorInit + 0x3A210, 8, PAGE_READWRITE, &lmao); //allow writing to the address
memcpy(allocatorInit + 0x3A210, &addressGetAddr, sizeof(addressGetAddr));

The best thing about IAT hooks is that they're module specific, this way any other module can call getaddrinfo and not be affected by my hook. This patch allows for the client to be installed just by simple drag-n-drop, no worries.

Source code

You can find the source code and binaries at: MarvelAvengers

Video showcasing the emulator: