BKSEC CTF 2023: writeups
BKSEC CTF 2023 was a Vietnamese CTF run by the BKSEC club in 2023. This post covers two reverse engineering challenges I solved, checker (a compiled Lua script) and reality (a Windows executable with anti-debug tricks).
Rev - checker
Files: checker.zip
The challenge gives two files, main.lua and checker.lua. main.lua is plain source and checker.lua is compiled Lua bytecode. This is main.lua.
1
2
3
4
5
6
7
8
9
10
local util = require "checker"
-- local util = require("checker")
io.write("Input flag: ")
local flag = io.read("*l")
if util.check(flag, "BKctf2023") then
print("Correct!")
else
print("Wrong...")
end
So the flag is passed to check together with the fixed string "BKctf2023". I decompiled checker.lua with unluac.
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
local flag = {}
function flag.check(v2, v3)
local v4 = true
local v5 = string.lower(v3)
local v6 = {
46,
106,
119,
140,
105,
195,
195,
219,
180,
116,
151,
68,
191,
86,
169,
205,
195,
211,
107,
120,
110,
129,
160,
189,
189,
189,
194,
164,
102,
110,
123,
111
}
local v7 = {
219,
117,
231,
96,
201,
195,
228,
201,
255,
228,
195,
252,
219,
234,
213,
138,
138,
138,
96,
240,
228,
207,
195,
249,
207,
96,
261,
195,
219,
252,
99,
30
}
if 32 ~= #v2 then
v4 = false
end
for v8 = 1, #v7 do
io.write(string.char(v7[v8] / 3))
end
for v9 = 1, #v2 do
local v10 = v2:byte(v9) ~ v5:byte((v9 - 1) % #v5 + 1)
if v9 > 1 then
v10 = v10 + v2:byte(v9 - 1)
end
if v6[v9] ~= v10 then
v4 = false
end
end
return v4
end
return flag
The logic has three parts.
- The length check.
#v2is the Lua length operator, so32 ~= #v2means the flag must be exactly 32 characters. If it is not,v4is set tofalse, but the function keeps going and only returnsfalseat the end. - The
v7loop is a decoy. It prints each value divided by 3 as a character, which gives the textI'M CALCULATING... PLEASE WAIT!and has no effect on the result. - The real check. The key is
v5, the lowercase of"BKctf2023", which is"bkctf2023". For each position, the flag byte is XORed with the key byte at that position (cycling through the key), and then the previous flag byte is added to the result for every position except the first. The final value must equalv6[v9].
I first rewrote the check in Python to read it more easily. Note that Lua is 1-based, so the key index (v9 - 1) % #v5 + 1 in Lua becomes v9 % len(v5) when v9 is 0-based. The literal translation I wrote at first used (v9 - 1) with a 0-based index, which is off by one, so the solver below is the one to trust.
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
def check(v2, v3):
v4 = True
v5 = v3.lower()
v6 = [
46, 106, 119, 140, 105, 195, 195, 219, 180, 116,
151, 68, 191, 86, 169, 205, 195, 211, 107, 120,
110, 129, 160, 189, 189, 189, 194, 164, 102,
110, 123, 111
]
v7 = [
219, 117, 231, 96, 201, 195, 228, 201, 255,
228, 195, 252, 219, 234, 213, 138, 138, 138,
96, 240, 228, 207, 195, 249, 207, 96, 261,
195, 219, 252, 99, 30
]
if len(v2) != 32:
v4 = False
for v8 in v7:
print(chr(v8 // 3), end="")
print()
for v9 in range(len(v2)):
v10 = ord(v2[v9]) ^ ord(v5[(v9 - 1) % len(v5)])
if v9 > 0:
v10 = v10 + ord(v2[v9 - 1])
if v6[v9] != v10:
v4 = False
return v4
To reverse it, the first byte is just v6[0] ^ key[0]. For every later byte, I subtract the previous flag byte from v6[i] and then XOR with the key byte, since v6[i] = (flag[i] ^ key[i]) + flag[i-1].
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
v3 = "BKctf2023"
v5 = v3.lower()
v6 = [
46, 106, 119, 140, 105, 195, 195, 219, 180, 116,
151, 68, 191, 86, 169, 205, 195, 211, 107, 120,
110, 129, 160, 189, 189, 189, 194, 164, 102,
110, 123, 111
]
v7 = [
219, 117, 231, 96, 201, 195, 228, 201, 255,
228, 195, 252, 219, 234, 213, 138, 138, 138,
96, 240, 228, 207, 195, 249, 207, 96, 261,
195, 219, 252, 99, 30
]
for v8 in v7:
print(chr(v8 // 3), end="")
print()
flag = [0] * 32
flag[0] = chr(v6[0] ^ ord(v5[0]))
print()
for v9 in range(1,32):
x = v6[v9] - ord(flag[v9-1])
flag[v9] = chr(x^ ord(v5[v9 % len(v5)]) )
print("".join(flag))
Running it prints the decoy text and then Lua_len_fl@g,Long_nang_lang_lang, which is the content of the flag.
Flag: BKSEC{Lua_len_fl@g,Long_nang_lang_lang}
Rev - reality
Files: reality.zip
reality.exe asks for a flag. Some instructions in the binary are placed so that IDA disassembles them wrongly. After fixing those by hand, the first useful function is a repeating-key XOR routine.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
char __usercall sub_401220@<al>(int a1@<edx>, const char *a2@<ecx>, int a3)
{
char result; // al
signed int v5; // esi
int i; // ecx
v5 = strlen(a2);
for ( i = 0; i < a3; ++i )
{
result = a2[i % v5];
*(_BYTE *)(i + a1) ^= result;
}
return result;
}
It XORs a3 bytes at address a1 with the string a2, repeating the string. After removing the junk bytes, main looks like this.
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
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v3; // ebx
int v5[21]; // [esp+0h] [ebp-114h] BYREF
int v6; // [esp+54h] [ebp-C0h] BYREF
int v7; // [esp+58h] [ebp-BCh]
char v8[5]; // [esp+5Ch] [ebp-B8h]
char v9[40]; // [esp+61h] [ebp-B3h] BYREF
char v10[8]; // [esp+89h] [ebp-8Bh] BYREF
int v11; // [esp+94h] [ebp-80h]
unsigned int i; // [esp+98h] [ebp-7Ch]
char v13[100]; // [esp+9Ch] [ebp-78h] BYREF
int *v14; // [esp+104h] [ebp-10h]
int v15; // [esp+110h] [ebp-4h]
int savedregs; // [esp+114h] [ebp+0h]
v14 = v5;
sub_401020("Input the flag: ", v5[0]);
v15 = 0;
v7 = 15;
memset(v13, 0, sizeof(v13));
if ( !sub_401050("%s", v13) )
return 0;
if ( v7 < 18 )
{
v6 = v7;
sub_40468D(&v6, &_TI1H);
}
sub_401220(100);
if ( !NtCurrentPeb()->BeingDebugged )
{
v8[0] = 0;
v8[1] = 0;
v8[2] = 0;
v8[3] = 0;
v8[4] = 6;
qmemcpy(v9, "8&w0X~B*", 8);
v9[8] = 127;
v9[9] = 63;
v9[10] = 41;
v9[11] = 26;
v9[12] = 33;
v9[13] = 54;
v9[14] = 55;
v9[15] = 28;
v9[16] = 85;
v9[17] = 73;
v9[18] = 18;
v9[19] = 48;
v9[20] = 120;
v9[21] = 12;
v9[22] = 40;
v9[23] = 48;
v9[24] = 48;
v9[25] = 55;
v9[26] = 28;
v9[27] = 33;
v9[28] = 18;
v9[29] = 126;
v9[30] = 82;
v9[31] = 45;
v9[32] = 38;
v9[33] = 96;
v9[34] = 26;
v9[35] = 36;
v9[36] = 45;
v9[37] = 55;
v9[38] = 114;
v9[39] = 28;
qmemcpy(v10, "EDC7,lz8", sizeof(v10));
for ( i = 0; i < 0x35; ++i )
byte_4218B0[i] = v8[i];
goto LABEL_42;
}
if ( ((((v13[5] * v13[4]) >> 16) + 1) & (((v13[3] ^ v13[2]) << 16) * (2 * (v13[1] + v13[0]) + 1))) != 0 )
{
v5[5] = 0;
return (int)&loc_401E12;
}
/* ... 14 more identical checks on v13[6..89] with different offsets ... */
if ( ((((v13[95] * v13[94]) >> 16) + 1) & (((v13[93] ^ v13[92]) << 16) * (2 * (v13[91] + v13[90]) + 1))) == 0 )
{
LABEL_42:
v11 = 0;
JUMPOUT(0x401C97);
}
v5[20] = 0;
return (int)&loc_401CFB;
}
I shortened the repeated if blocks above, they follow the same pattern for each group of six bytes of the input.
The key observation is the BeingDebugged check on the PEB. When no debugger is attached, the program takes the first branch. It builds a 53-byte buffer (0x35 bytes) from v8, v9 and v10 and copies it to byte_4218B0, then jumps to LABEL_42. That buffer is the encrypted flag, and the XOR routine sub_401220 is later applied to it. When a debugger is attached, the program goes through the long chain of arithmetic checks on the input instead, which are there to mislead the analyst. So I did not need to satisfy any of them. I only needed the encrypted bytes and the XOR key, which is BKSEECCCC!!!.
The buffer is 5 bytes from v8 (0, 0, 0, 0, 6), then the 40 bytes of v9 ("8&w0X~B*" followed by the byte assignments), then the 8 bytes of v10 ("EDC7,lz8", which is 69, 68, 67, 55, 44, 108, 122, 56). XORing all 53 bytes with the repeating key gives the flag.
The solver in my repo had one slip. It wrote the first byte of the v9 list as 0 instead of 56 (the character 8 from "8&w0X~B*"), so a run of that script printed BKSECC instead of BKSEC{. Here is the corrected version, which prints the right flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
key = "BKSEECCCC!!!"
v7 = [0, 0, 0, 0, 6]
v8 = [56, 38, 119, 48, 88, 126, 66, 42, 127, 63, 41, 26, 33, 54, 55, 28, 85, 73, 18, 48, 120, 12, 40, 48, 48, 55, 28, 33, 18, 126, 82, 45, 38, 96, 26, 36, 45, 55, 114, 28]
v9 = [69, 68, 67, 55, 44, 108, 122, 56]
x = v7 + v8 + v9
v5 = len(key)
for i in range(len(x)):
result_byte = ord(key[i % v5])
x[i] ^= result_byte
result_string = ''.join(chr(byte) for byte in x)
print(result_string)
Flag: BKSEC{e4sy_ch4ll_but_th3r3_must_b3_som3_ant1_debug??}
