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

Here is the challenge information:

challenge_info

You can get the challenge attachment here.

Overview

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:

error_message

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:

mods_works

Through the amxx plugins command, we can see the loaded plugins:

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’):

registered_cmds

For example, we can get a menu on the left side of the screen by saying /menu in the game.

menu

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:

diff

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:

amx_Exec

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:

decompiled_res

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:

SourceBuilder

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:

SourceBuilder_modified

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):

decompiled_new_res

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:

readAll

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:

readAll_modified

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):

1
2
3
4
5
6
7
8
9
10
11
// accounts.amxx
public plugin_init()
{
register_plugin("Account system", "21.37", "RiviT");
register_clcmd("say /menu", "cmd_menu");
...
register_clcmd("passwd", "handle_password_input");
g_nvault = nvault_open("accounts");
create_menu();
return 0;
}

As shown in the code, it opens amxmodx/data/vault/accounts.vault and whenever we input the password, handle_password_input function will be called:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// accounts.amxx
public handle_password_input(id)
{
...
get_user_name(id, name, 32);
new password_len = read_argv(1, password, 191);
new encoded_len = encode_password(id, password, password_len, encoded, 511);
words_to_str(encoded, encoded_len, bytes_str, 511);
switch (player_state[id])
{
case 1: // register
{
... // common check
nvault_set(g_nvault, name, bytes_str);
client_print(id, 3, "Register OK");
player_state[id] = 3;
}
case 2: // login
{
...
}
...
}
return 1;
}

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:

vault_content

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
// encoder.amxx
public encode_password(id, password[], len, output[], out_len)
{
...
new local_copy[256];
copy(local_copy, len, password);
new new_len = len;
new_len = encode1(id, local_copy, new_len);
new_len = encode2(local_copy, new_len);
new_len = encode3(local_copy, new_len);
copy(output, out_len, local_copy);
return new_len;
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// encoder.amxx
public plugin_init()
{
register_plugin("Super-encryptor 4001", "0.01 pre-alpha", "RiviT");
register_cvar("encoder_random_value", "1337");
read_regions();
set_task(1073741824, "prepare_plugin");
return 0;
}

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// db.amxx
public get(key[])
{
param_convert(1);
fmt("SELECT * FROM `hex_data` WHERE `name`='%s';", key);
SQL_ThreadQuery(g_sql_tuple, "get_handle", fmt_result, 2792);
return 0;
}

public get_handle(failstate, Handle:query, error[])
{
if (failstate)
{
log_amx("get_handle: failstate: %d error: %s", failstate, error);
}
if (SQL_NumResults(query))
{
new name[64];
new func_base;
SQL_ReadResult(query, SQL_FieldNameToNum(query, "data"), data, 2048);
SQL_ReadResult(query, SQL_FieldNameToNum(query, "name"), name, 64);
func_base = SQL_ReadResult(query, SQL_FieldNameToNum(query, "func_base"));
new new_len = unhex(data, strlen(data));
ExecuteForward(db_forward, 0, PrepareArray(name, 64), PrepareArray(data, 2048), new_len, func_base);
}
return 0;
}

public plugin_init()
{
register_plugin("DB", "13.37", "RiviT", 1948, 1952);
db_forward = CreateMultiForward("db_results_ready", 3, 4, 4, 0, 0);
...
return 0;
}

This function will execute a Forward initialized in plugin_init, which would result in a call to db_results_ready defined in encoder.amxx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// 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:

db_content

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:

AMXModXFile

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import lysis.BitConverter;

private void fix_relocation_opcodes(int base_func_addr, byte[] data, int old_func_base) {
int cip = 0;

while (cip < data.length) {
byte op = data[cip];
cip += 4;
switch (op) {
case 49: case 51: case 53: case 54: case 55:
case 56: case 57: case 58: case 59: case 60:
case 61: case 62: case 63: case 64: case (byte)129: {
int v = (int) (BitConverter.ToUInt32(data, cip) - old_func_base + base_func_addr);
data[cip] = (byte) (v & 0xFF);
data[cip+1] = (byte) ((v >> 8) & 0xFF);
data[cip+2] = (byte) ((v >> 16) & 0xFF);
data[cip+3] = (byte) ((v >> 24) & 0xFF);
cip += 4;
}
default:{ }
}
}
}

And then, we can do things like this (take encode1 as an example):

encode1

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:

encode23

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:

heap_analyze

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

complete_res

Solve

After beautifying the code, we finally got the correct encryption function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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:

get_cvar

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 < 256 and len(parts) != 256:
buf = b"%08x" % i
hash_str = sha256(buf).hexdigest()

j = 0
while j < 64:
if hash_str[j:j+2] not in 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from gmpy2 import *
from hashlib import sha256

def decode1(username, password):
for k in range(0, len(password) - 1, 2):
password[k], password[k + 1] = password[k + 1], password[k]

idx = 0
for i in range(len(password)):
password[i] ^= username[idx]
idx = (idx + 1) % len(username)
return password

def decode2(password):
for i in range(len(password)):
password[i] = rev_parts["%02x" % password[i]]
return password

def decode3(password, password_len):
random_value = 0x301a
for i in range(password_len):
tmp = password[i]
password[i] = (password[i] - 1 - random_value) * invert(57005, 4096) % 4096
random_value = tmp
return list(map(int, password))


i = 0
order = 0
parts = dict()
while i < 256 and len(parts) != 256:
buf = b"%08x" % i
hash_str = sha256(buf).hexdigest()

j = 0
while j < 64:
if hash_str[j:j+2] not in 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 in range(0, len(cstr), 4):
cipher.append(int(cstr[i:i+4], 16))

plain = decode1(username, decode2(decode3(cipher, len(cipher))))
flag = ''.join(map(chr, plain))
print(flag)
# justCTF{4lm057_d34d_g4m3}

We can also get other users’ passwords:

1
2
3
4
5
6
7
8
9
10
11
12
Joe                joejoejoejoe
Fragnatic passw0rd
Campers Death hotdog87
Wujek plgurom!
shw topplayer
Headshot Deluxe winteriscoming
fex 123123123
L33t headshot
Rivit admin1337
Botman q123123q
rumcajs zaq1@WSX
Dredd awp420awp

I also wrote a runnable mod under 1.9-dev. It has the exact same cryptographic logic as encoder.amxx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#include <amxmodx>
#include <celltrie>
#include <string>

new Trie:parts;

public set_parts()
{
new buf[32];
new hash[65];
new hexbuf[3];
new order = 0;
new i = 0;
new v;

parts = TrieCreate();
while (i < 256 && TrieGetSize(parts) != 256)
{
formatex(buf, 31, "%08x", i);
hash_string(buf, 3, hash, 64);
new j = 0;
while (j < 64)
{
formatex(hexbuf, 2, "%c%c", hash[j], hash[j + 1]);
if (!TrieGetCell(parts, hexbuf, v))
{
TrieSetCell(parts, hexbuf, order, 1);
order++;
}
j += 2;
}
i++;
}
}

public plugin_init()
{
register_plugin("sim_encoder", "1.0", "in1t");
register_clcmd("say /reg", "reg_func");
register_clcmd("passwd", "handle_password_input");
set_parts();
}

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;
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 = 0x301a;

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;
}

public encode_password(id, password[], len, output[], out_len)
{
new bytes_str[512];
new local_copy[256];
new new_len = len;

copy(local_copy, 255, password);
new_len = encode1(id, local_copy, new_len);
new_len = encode2(local_copy, new_len);
new_len = encode3(local_copy, new_len);

new j;
while (j < new_len)
{
add(bytes_str, 511, fmt("%04x", local_copy[j]), 4);
j++;
}
log_amx(bytes_str); // output ciphertext to game console

return 0;
}

public handle_password_input(id)
{
new encoded[512];
new password[192];

new password_len = read_argv(1, password, 191);
encode_password(id, password, password_len, encoded, 511);
}

public reg_func(id)
{
client_cmd(id, "messagemode passwd");
}

Later Story

I’m so excited that the challenge author - Rivit - praised my writeup:

challenge_author_reply

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

lysis_java_repo_with_discord_post