Post

BKSEC CTF 2023: writeups

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. #v2 is the Lua length operator, so 32 ~= #v2 means the flag must be exactly 32 characters. If it is not, v4 is set to false, but the function keeps going and only returns false at the end.
  • The v7 loop is a decoy. It prints each value divided by 3 as a character, which gives the text I'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 equal v6[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??}

This post is licensed under CC BY 4.0 by the author.