Last weekend, I played justCTF 2022 with my team. We got 6th place. It took me a whole day to solve the “AMXX” challenge during this 37h game. Tired though I was, I really enjoyed this challenge. To solve it, I bought cs1.6 for 37 RMB, and learned how to develop mods. Thanks to the game organizer :D
After decompressing the task archive, we can see amxmodx folder in addons directory, which means that we need to focus on Half-Life plugins (programs written in SourcePawn) in this challenge. Meanwhile, we can also find csstats.amxx in amxmodx/data directory, which leads to cs1.6 mods reverse engineering. However, I haven’t used cs1.6 mods before, I don’t even have the game itself on my PC. So I bought one on steam XD
The AMX Mod X installer can be downloaded from here, and I installed v1.8.2 at the very beginning. But when I replaced the three folders in amxmodx directory to cstrike and called console in the game (press ‘`’), I got this:
Maybe the plugins are using a newer AMX Mod X version? I then downloaded 1.9-dev on the official website. After reinstalling, it worked without error message:
Through the amxx plugins command, we can see the loaded plugins:
The three modules circled by the red frame above are developed by the challenge author, and I will analyze them later.
Through the amxx cmds command, we can list the cmds registered by plugins. Here we can see three commands that can be triggered by say (to say in the game, you should press ‘Y’):
For example, we can get a menu on the left side of the screen by saying /menu in the game.
This means we can register and login as plugin users. However, when I chose to register and entered my password, the game crashed. Oops, it seems we have to work on those plugins.
Method Selection
Before starting to reverse amxx, we should take a look at the given diff file 0001-Set-Core-Mode.patch. The author modified the AMX Mod X source code in Github and disabled the JIT/ASM32 technology. We can find the evidence on line 61-64:
He also removed the amx_Exec function that supports JIT/ASM32 (line 132-1154). So let’s see how the remaining amx_Exec function in amx.cpp runs amxx files.
At line 2784, the compiler will determine whether the JIT or ASM32 compilation flag is defined, which is obviously not. So the part after line 2822 will be compiled into the program. It looks like a typical virtual machine:
Therefore, the amxx file compiled by the modified AMX Mod X should be similar to pyc file, it needs to be executed by an interpreter. And usually, there are existing disassembly/decompilation tools to deal with such programs.
After some searching on Github, I found a project called lysis-java. It even provides an online decompilation website. The decompiled result after uploading amxmodx/plugins/db.amxx is surprisingly good, except for a few errors:
So I decided to clone this project and managed to fix errors so that I could get to the analysis stage with the completely readable code.
Tool Fixup
Let’s fix the first error: Can't print expression: Heap.
I opened the project on IDEA, searched for the error string and went to lysis.builder.SourceBuilder:652 in buildExpression function:
It looks like the project author forgot to consider the construction of the Heap expression. Since this error often occurs after fmt function, I added a case here to return “fmt_result” string:
After using Gradle to build, I got lysis-java.jar in build/libs folder. Run java -jar lysis-java.jar db.amxx in cmd, it works (at least get function is successfully decompiled):
But as we can see, another error occurred while parsing the next function. Now we can find a slightly higher-level function through stack trace to modify.
Here I chose readAll. This function traverses all machine codes through the while loop, parses them into virtual machine instructions through readInstruction, and finally adds them to an instruction list:
Obviously, the second error occurred in the process of parsing a certain instructions. Consider discarding unparseable instructions through try...catch, so that the code changes can be minimal and it would not cause too much impact on the decompilation results:
Run the project again, this time db.amxx was successfully decompiled, no error was reported. Then I tried to decompile accounts.amxx and encoder.amxx, they all turned out fine.
Analysis
First come to accounts.amxx. Those commands we see in the game are registered in plugin_init function below (AMX Mod X API can be found here):
This function will encrypt the entered password with username and store it to accounts.vault. According to the challenge description, we need to recover the password of Pr0g4m3r in it:
So we should analyze encode_password function to figure out how it encrypts user’s password. It is defined as a native function in encoder.amxx:
It mainly encrypts the plaintext through three different encryption functions in turn, and all the encoding functions are defined in this module. But interestingly, they’re all fake. We can notice that plugin_init of encoder.amxx starts a task called prepare_plugin:
public prepare_plugin() { parts = TrieCreate(); // create dictionary ... new i; while (i < 256 && TrieGetSize(parts) != 256) { formatex(buf, 31, "%08x", i); hash_string(buf, 3, hash, 64); new i; while (i < 64) { formatex(hexbuf, 2, "%c%c", hash[i], hash[i + 1]); if (!TrieGetCell(parts, hexbuf, v)) { TrieSetCell(parts, hexbuf, order, 1); order++; } i += 2; } i++; } new i; while (i < 3) { db_get(code_keys[i]); // code_keys = ["stage1", "stage2", "stage3"] i++; } return 0; }
The prepare_plugin function first initializes a dictionary with 256 key-value pairs, then performs SMC through db_get (a native function named get in db.amxx) to modify the machine code of encode1/2/3. When get finishes SELECT request, get_handle will be called:
// encoder.amxx public db_results_ready(key[], data[], len, func_base) { log_amx("%s", key); new dest; new var1 = code_keys; if (equal(key, var1[0][var1])) { dest = 2404; // encode1 function base } else { if (equal(key, code_keys[1])) { dest = 3256; // encode2 function base } if (equal(key, code_keys[2])) { dest = 3652; // encode3 function base } set_fail_state("invalid code key"); } apply_patch(dest, data, len, func_base); return 0; }
public apply_patch(address, data[], data_len, func_base) { fix_relocation_opcodes(address, data, data_len, func_base); address = address + COD - DAT; new i; while (i < data_len) { write_mem(address, data[i]); address += 4; i++; } return 0; }
public fix_relocation_opcodes(base_func_addr, data[], data_len, old_func_base) { new cip; while (cip < data_len) { new op = data[cip]; cip++; switch (op) { // branch instruction opcode case 49, 51, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 129: { new v = data[cip] - old_func_base + base_func_addr + amx_base; data[cip] = v; cip++; } default: { } } } return 0; }
The three main functions above will use the data from database to modify encryption functions. So we should focus on the amxmodx/data/sqlite3/jctf.sq3 instead of decompiled bogus functions:
Now we are going to modify the lysis-java project again, so that we can replace the machine code to be parsed.
Tool Modification
If you follow the control flow of the project from lysis.Lysis.main, you can quickly find that it parses and reads the amxx file in the constructor of lysis.amxmodx.AMXModXFile class. And you’ll find it handling .CODE segment on line 195-203:
It calculates the code length and slices it to build a Code object. And in the following for loop we can get information about each function (name, base, etc.). So why not just modify the code bytes here?
Before that, we must implement the fix_relocation_opcodes function in Java:
And then, we can do things like this (take encode1 as an example):
Both the new_encode1(data) and 2440 (func_base) are obtained from stage1 entry in the database. 2404 (base_func_addr) is obtained from encoder.amxx!db_results_ready. Then make the same changes to encode 2 & 3:
Build & Run towards encoder.amxx, it stuck after decompiling encode1…
After some debugging work, I found the problem in lysis.Lysis.DumpMethod. It tries to analyze the heap usage on line 117:
The previous modification may have caused the loop exit condition inside to be unsatisfied, resulting in an infinite loop. So I just deleted this line, and it works :D
Solve
After beautifying the code, we finally got the correct encryption function:
public encode1(id, password[], password_len) { new username[32]; new username_len = get_user_name(id, username, 32); new i = 0; new j = 0; while (i < password_len) { new var5 = password[i]; password[i] = username[j] ^ var5; j++; j %= username_len; i++; } new k = 0; while (k + 1 < password_len) { password[k] ^= password[k + 1]; password[k + 1] ^= password[k]; password[k] ^= password[k + 1]; k += 2; } return password_len; }
public encode2(password[], password_len) { new var1 = 0; new var2 = 0; while (var2 < password_len) { TrieGetCell(parts, fmt("%02x", password[var2]), var1); password[var2] = var1; var2++; } return password_len; }
public encode3(password[], password_len) { new random_value = get_cvar_num("encoder_random_value"); new i = 0; while (i < password_len) { random_value = password[i] * 57005 + random_value; random_value += 1; random_value %= 4096; password[i] = random_value; i++; } return password_len; }
We can get the cvar encoder_random_value (used in encode3) by typing amxx cvars in the game console:
And we can generate parts (used in encode2) by ourselves:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
from hashlib import sha256
parts = dict() i, order = 0, 0 while i < 256andlen(parts) != 256: buf = b"%08x" % i hash_str = sha256(buf).hexdigest()
j = 0 while j < 64: if hash_str[j:j+2] notin parts: parts[hash_str[j:j+2]] = order order += 1 j += 2 i += 1
The last step is to determine how the encrypted password is stored in accounts.vault. Looking back at accounts.amxx!handle_password_input, we can find that the words_to_str function is also called after encryption:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// accounts.amxx new COD = 37;
words_to_str(encoded[], encoded_len, bytes[], bytes_len) { new i; while (i < encoded_len) { fmt(COD, encoded[i]); add(bytes, bytes_len, fmt_result); // strcat i++; } return 0; }
Obviously, COD should be a format string, but the decompilation result is wrong here. However, we can still guess the content of this format string through encode3 and ciphertext. Take Rivit’s password ciphertext as an example: 0f5401c00a740b8d0dc10407081d0c8700ee.
Because the ciphertext contains hex characters, starts with ‘0’, and contains many zeros, the format string should look like %0?x. In encode3, we can see the expression random_value %= 4096, which means every element in the list should be less than 0x1000 (4096). So the format string may be %03x or %04x, and ciphertext starts with ‘0’, so it can only be %04x.
Through the solving script below, we eventually get the flag:
i = 0 order = 0 parts = dict() while i < 256andlen(parts) != 256: buf = b"%08x" % i hash_str = sha256(buf).hexdigest()
j = 0 while j < 64: if hash_str[j:j+2] notin parts: parts[hash_str[j:j+2]] = order order += 1 j += 2 i += 1 rev_parts = {"%02x" % value: int(key, 16) for key, value in parts.items()}
username = list(map(ord, "Pr0g4m3r")) cstr = "0ec80d920582039e07950164007f02940e290e97038d08670cd108630bce02de0566079a02f4012c08ac04950aa60f0d0c81" cipher = [] for i inrange(0, len(cstr), 4): cipher.append(int(cstr[i:i+4], 16))
public reg_func(id) { client_cmd(id, "messagemode passwd"); }
Later Story
I’m so excited that the challenge author - Rivit - praised my writeup:
What’s more interesting is that the author of lysis-java is also playing this CTF. There’s no wonder that their team got the first blood of AMXX so fast lol