All Articles

SECCON Beginners CTF 2023 Writeup

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

I participated in SECCON Beginners CTF 2023 (starting June 3) with team 0nePadding.

Final placement was 35th out of 778 teams.

image-20230604164204980

We managed to hold a top-5 position for the first six hours or so, but then ran out of solvable problems and gradually slid down to 35th. Frustrating, but entirely a matter of skill — more practice needed.

As usual I focused on Rev and cleared it completely. This time I also tried my hand at Misc and Pwn.

This writeup covers the Rev and some of the Misc problems. The kernel exploit Pwn challenge deserves a separate, more detailed post.

Table of Contents

Half (Rev)

Let’s look up what kind of file a binary file is!

And how do we peek inside it…?

Running strings on the downloaded binary reveals the Flag.

Three (Rev)

You can’t find the flag just by glancing at the contents of this file!

Do you need a specialized tool to analyze binary files?

Decompiling with Ghidra shows that three data regions each define 4-byte-aligned values, and the Flag is assembled by taking one character at a time from each region.

The following solver script retrieves the Flag:

f1 = [ 0x63, 0x00, 0x00, 0x00, 0x34, 0x00, 0x00, 0x00, 0x63, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x75, 0x00, 0x00, 0x00, 0x62, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0x74, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x72, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x31, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x34, 0x00, 0x00, 0x00, 0x7d, 0x00, 0x00, 0x00 ]
f2 = [ 0x74, 0x00, 0x00, 0x00, 0x62, 0x00, 0x00, 0x00, 0x34, 0x00, 0x00, 0x00, 0x79, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x31, 0x00, 0x00, 0x00, 0x74, 0x00, 0x00, 0x00, 0x75, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x34, 0x00, 0x00, 0x00, 0x74, 0x00, 0x00, 0x00, 0x65, 0x00, 0x00, 0x00, 0x73, 0x00, 0x00, 0x00, 0x69, 0x00, 0x00, 0x00, 0x66, 0x00, 0x00, 0x00, 0x67, 0x00, 0x00, 0x00 ]
f3 = [ 0x66, 0x00, 0x00, 0x00, 0x7b, 0x00, 0x00, 0x00, 0x6e, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x61, 0x00, 0x00, 0x00, 0x65, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x6e, 0x00, 0x00, 0x00, 0x5f, 0x00, 0x00, 0x00, 0x65, 0x00, 0x00, 0x00, 0x34, 0x00, 0x00, 0x00, 0x65, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0x74, 0x00, 0x00, 0x00, 0x31, 0x00, 0x00, 0x00, 0x33, 0x00, 0x00, 0x00 ]

flag = ""
for i in range(0x31):
    if i % 3 == 0:
        flag += chr(f1[i//3*4])

    elif i % 3 == 1:
        flag += chr(f2[i//3*4])

    elif i % 3 == 2:
        flag += chr(f3[i//3*4])

print(flag)
# ctf4b{c4n_y0u_ab1e_t0_und0_t4e_t4ree_sp1it_f14g3}

Poker (Rev)

Let’s play poker together! Apparently you get a flag if you accumulate enough points!

But when I try to run this binary file…? Let’s use a specialized tool that can inspect the internals while it’s running!

Running the binary shows a program that randomly wins or loses, causing the score to fluctuate:

image-20230604165010482

Decompiling with Ghidra reveals that a function to retrieve the Flag executes once the total score reaches a value that is practically unachievable in normal play.

I could have patched memory at runtime with gdb, but it was easier to just patch the winning score threshold to 0 in Ghidra.

Running the patched binary means a single win triggers the Flag:

image-20230604165229548

Leak (Rev)

Suspicious traffic was detected from the server!

On further investigation, a suspicious file was found. Please analyze it along with the traffic log.

Sensitive information may have been exfiltrated…?

Looking at the provided pcap file shows a byte sequence being sent to a C2 server.

This looks like some kind of encrypted byte sequence, so I analyzed the program in Ghidra.

0x8e,0x57,0xff,0x59,0x45,0xda,0x90,0x06,0x28,0xb2,0xab,0xfa,0x49,0x73,0x32,0x33,0x4a,0x73,0x29,0x41,0x3c,0x34,0xb7,0xf6,0x62,0x73,0x25,0x0f,0x95,0x40,0x16,0xfa,0x47,0xe9,0x22,0x8d,0xa5,0xcd,0x3d,0x53,0xee,0xb4,0xb3,0x51,0x8e,0xd2,0x89,0x93,0x5b,0xe0,0x59,0xcb,0xfb,0xb1,0x1b

Looking at the decompiled output, the Flag encryption appears to be performed as follows:

image-20230604165644627

At a glance it’s clear this is simply XOR-ing each Flag character with a key generated by a local function.

Manually deriving the per-character key generation was tedious, so I used the following gdb script to extract it.

Since the key generation does not depend on the input string, the extracted key can be used directly for decryption:

# gdb -x run.py
import gdb
from pprint import pprint

# pprint(dir(gdb))
BINDIR = "/home/ubuntu/Hacking/CTF/2023/sec4b/Rev/Leak"
BIN = "leak"
INPUT = "./in.txt"
OUT = "./out.txt"
BREAK = "0x555555555518"

gdb.execute('file {}/{}'.format(BINDIR, BIN))
gdb.execute('b *{}'.format(BREAK))
gdb.execute('run < {}'.format(INPUT, OUT))

key = []
while True:
    # register
    reg = int(gdb.parse_and_eval("$rcx"))
    key.append(hex(reg))
    print(key)
    if not reg == "0x29":
        gdb.execute("continue")

# ['0xed', '0x23', '0x99', '0x6d', '0x27', '0xa1', '0xe0', '0x32', '0x51', '0xed', '0xc5', '0xca', '0x16', '0x47', '0x46', '0x47', '0x2f', '0x1d', '0x5d', '0x70', '0xc', '0x5a', '0xe8', '0x82', '0x52', '0x2c', '0x51', '0x3b', '0xf4', '0x34', '0x49', '0x97', '0x73', '0x87', '0x7d', '0xef', '0xc0', '0xa5', '0xc', '0x3d', '0x8a', '0xeb', '0xc7', '0x65', '0xeb', '0x8d', '0xea', '0xe6', '0x29']

XOR-ing the extracted key with the encrypted byte sequence from the pcap yields the Flag:

key=[0xed, 0x23, 0x99, 0x6d, 0x27, 0xa1, 0xe0, 0x32, 0x51, 0xed, 0xc5, 0xca, 0x16, 0x47, 0x46, 0x47, 0x2f, 0x1d, 0x5d, 0x70, 0xc, 0x5a, 0xe8, 0x82, 0x52, 0x2c, 0x51, 0x3b, 0xf4, 0x34, 0x49, 0x97, 0x73, 0x87, 0x7d, 0xef, 0xc0, 0xa5, 0xc, 0x3d, 0x8a, 0xeb, 0xc7, 0x65, 0xeb, 0x8d, 0xea, 0xe6, 0x29, 0xd4, 0x38, 0xfa, 0x95, 0xcc, 0x11]
enc=[0x8e,0x57,0xff,0x59,0x45,0xda,0x90,0x06,0x28,0xb2,0xab,0xfa,0x49,0x73,0x32,0x33,0x4a,0x73,0x29,0x41,0x3c,0x34,0xb7,0xf6,0x62,0x73,0x25,0x0f,0x95,0x40,0x16,0xfa,0x47,0xe9,0x22,0x8d,0xa5,0xcd,0x3d,0x53,0xee,0xb4,0xb3,0x51,0x8e,0xd2,0x89,0x93,0x5b,0xe0,0x59,0xcb,0xfb,0xb1,0x1b]

for i in range(len(enc)):
    print(chr(key[i]^enc[i]),end="")

# ctf4b{p4y_n0_4ttent10n_t0_t4at_m4n_beh1nd_t4e_cur4a1n}

Heaven (Rev)

I wrote a program to encrypt messages.

Try to decrypt it!

The challenge provides an ELF encryption binary and the output from encrypting the Flag:

$ ./heaven
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 0
message: ctf4b{---CENSORED---}
encrypted message: ca6ae6e83d63c90bed34a8be8a0bfd3ded34f25034ec508ae8ec0b7f

Decompiling with Ghidra shows the encryption logic implemented as follows:

image-20230604170357860

Since the message value itself is not modified, everything else can be ignored and we focus on:

do {
    lVar11 = lVar10 + 1;
    bVar2 = calc_xor(message[lVar10],uVar1);
    message[lVar10] = sbox[bVar2];
    lVar10 = lVar11;
} while (lVar7 != lVar11);

__printf_chk(1,"encrypted message: %02x",local_21);
print_hexdump(message,lVar7);

From this decompiled output, the encryption and output flow is:

  1. A random 1-byte value uVar1 is generated in advance.
  2. Each character of message is passed, along with uVar1, to calc_xor.
  3. The result of calc_xor is used as an index into the hardcoded sbox, and replaces the character in message.
  4. Finally, uVar1 and the hex-encoded encrypted message are printed.

From the output, the uVar1 used when encrypting the Flag is 0xca.

Since the sbox bytes are hardcoded, we just need to determine the implementation of calc_xor to decrypt the Flag.

However, attempting to analyze calc_xor in Ghidra did not yield a clear result:

image-20230604171234531

So I fell back to objdump to get the disassembly:

image-20230604171419586

I’m not entirely sure what this is doing, but it appears to store values defined from address 0x404181 onto the stack and then pop them with lret.

Analyzing with gdb revealed the following behavior:

  1. Decrement the received Flag character by 1.
  2. XOR the result from step 1 with uVar1.

So I wrote the following reverse script to retrieve the Flag:

sbox = [ 0xc2, 0x53, 0xbb, 0x80, 0x2e, 0x5f, 0x1e, 0xb5, 0x17, 0x11, 0x00, 0x9e, 0x24, 0xc5, 0xcd, 0xd2, 0x7e, 0x39, 0xc6, 0x1a, 0x41, 0x52, 0xa9, 0x99, 0x03, 0x69, 0x8b, 0x73, 0x6f, 0xa0, 0xf1, 0xd8, 0xf5, 0x43, 0x7d, 0x0e, 0x19, 0x94, 0xb9, 0x36, 0x7b, 0x30, 0x25, 0x18, 0x02, 0xa7, 0xdb, 0xb3, 0x90, 0x98, 0x74, 0xaa, 0xa3, 0x20, 0xea, 0x72, 0xa2, 0x8e, 0x14, 0x5b, 0x23, 0x96, 0x62, 0xa4, 0x46, 0x22, 0x65, 0x7a, 0x08, 0xf6, 0x12, 0xac, 0x44, 0xe9, 0x28, 0x8d, 0xfe, 0x84, 0xc3, 0xe3, 0xfb, 0x15, 0x91, 0x3a, 0x8f, 0x56, 0xeb, 0x33, 0x6d, 0x0a, 0x31, 0x27, 0x54, 0xf9, 0x4a, 0xf3, 0xbf, 0x4b, 0xda, 0x68, 0xa1, 0x3c, 0xff, 0x38, 0xa6, 0x3e, 0xb7, 0xc0, 0x9a, 0x35, 0xca, 0x09, 0xb8, 0x8c, 0xde, 0x1c, 0x0c, 0x32, 0x2a, 0x0f, 0x82, 0xad, 0x64, 0x45, 0x85, 0xd1, 0xaf, 0xd9, 0xfc, 0xb4, 0x29, 0x01, 0x9b, 0x60, 0x75, 0xce, 0x4f, 0xc8, 0xcc, 0xe2, 0xe4, 0xf7, 0xd4, 0x04, 0x67, 0x92, 0xe5, 0xc7, 0x34, 0x0d, 0xf0, 0x93, 0x2c, 0xd5, 0xdd, 0x13, 0x95, 0x81, 0x88, 0x47, 0x9d, 0x0b, 0x1f, 0x5e, 0x5d, 0xa8, 0xe7, 0x05, 0x6a, 0xed, 0x2b, 0x63, 0x2f, 0x4c, 0xcb, 0xe8, 0xc9, 0x5a, 0xdc, 0xc4, 0xb0, 0xe1, 0x7f, 0x9f, 0x06, 0xe6, 0x57, 0xbe, 0xbd, 0xc1, 0xec, 0x59, 0x26, 0xf4, 0xb1, 0x16, 0x86, 0xd7, 0x70, 0x37, 0x4d, 0x71, 0x77, 0xdf, 0xba, 0xf8, 0x3b, 0x55, 0x9c, 0x79, 0x07, 0x83, 0x97, 0xd6, 0x6e, 0x61, 0x1d, 0x1b, 0xa5, 0x40, 0xab, 0xbc, 0x6b, 0x89, 0xae, 0x51, 0x78, 0xb6, 0xb2, 0xfd, 0xfa, 0xd3, 0x87, 0xef, 0xee, 0xe0, 0x2d, 0x4e, 0x3f, 0x6c, 0x66, 0x5c, 0x7c, 0x10, 0xcf, 0x49, 0x48, 0x21, 0x8a, 0x3d, 0xf2, 0x76, 0xd0, 0x42, 0x50, 0x58, 0x00 ]

enc = [0x6a, 0xe6, 0xe8, 0x3d, 0x63, 0xc9, 0x0b, 0xed, 0x34, 0xa8, 0xbe, 0x8a, 0x0b, 0xfd, 0x3d, 0xed, 0x34, 0xf2, 0x50, 0x34, 0xec, 0x50, 0x8a, 0xe8, 0xec, 0x0b, 0x7f]

# unk is a random 1-byte value
# bVar1 = calc_xor(message[k],unk);
# message[k] = sbox[bVar1];

# for i in range(256):
flag = ""
for e in enc:
    bVar1 = sbox.index(e)
    flag += chr((bVar1 ^ 0xca) + 1)
print(flag)

YARO (Misc)

There may be malware on the server. Find it with your perfect signature.

A relatively rare YARA challenge — this got me excited!

The server accepts YARA rule text and returns the result of applying the submitted rules against the files under /root, including the matching rule names.

Multiple rules can be sent at once, and the server reports which rule names matched.

Since we can extract one character at a time, I first determined the character set and length of the Flag.

I started by binary-searching the Flag length using rules like:

$ctf4b_string = /.*ctf4b\{.{20,30}\}.*/
$ctf4b_string = /.*ctf4b\{.{25,30}\}.*/
$ctf4b_string = /.*ctf4b\{.{28,30}\}.*/
$ctf4b_string = /.*ctf4b\{.{28}\}.*/

The Flag content inside the braces turned out to be 28 characters.

Next I determined the character set with rules like:

$ctf4b_string = /.*ctf4b\{[0-9|A-Z|a-z]{28}\}.*/
$ctf4b_string = /.*ctf4b\{[0-9|a-z|_]{28}\}.*/
$ctf4b_string = /.*ctf4b\{[A-Z|a-z|_]{28}\}.*/
$ctf4b_string = /.*ctf4b\{[0-9|A-Z|_]{28}\}.*/

The Flag uses uppercase letters, lowercase letters, digits, and underscores.

I then wrote the following solver script to determine the Flag one character at a time:

from pwn import *

for i in range(28):
    allrule = ""
    p = remote("yaro.beginners.seccon.games", 5003)

    for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_":
        c = c
        R = "." * i
        L = 28-1-i
        sig = R + c + "[0-9|A-Z|a-z|_]{" + str(L) +"}"
        if c == "_":
            c = "ubar"
        rule = "rule check_" + c +""" {
    strings:
        $ctf4b_string = /.*ctf4b\{""" + sig + """\}.*/
    condition:
        $ctf4b_string
}
"""
        allrule += rule

    r = p.recvuntil(b"rule:\n")
    p.sendline(allrule.encode())
    r = p.recvuntil(b"Not found: ./server.py\n")
    r = p.recvline()
    print(r)
    p.close()


# b'Found: ./flag.txt, matched: [check_Y]\n'
# b'Found: ./flag.txt, matched: [check_3]\n'
# b'Found: ./flag.txt, matched: [check_t]\n'
# b'Found: ./flag.txt, matched: [check_ubar]\n'
# b'Found: ./flag.txt, matched: [check_A]\n'
# b'Found: ./flag.txt, matched: [check_n]\n'
# b'Found: ./flag.txt, matched: [check_0]\n'
# b'Found: ./flag.txt, matched: [check_t]\n'
# b'Found: ./flag.txt, matched: [check_h]\n'
# b'Found: ./flag.txt, matched: [check_3]\n'
# b'Found: ./flag.txt, matched: [check_r]\n'
# b'Found: ./flag.txt, matched: [check_ubar]\n'
# b'Found: ./flag.txt, matched: [check_R]\n'
# b'Found: ./flag.txt, matched: [check_3]\n'
# b'Found: ./flag.txt, matched: [check_4]\n'
# b'Found: ./flag.txt, matched: [check_d]\n'
# b'Found: ./flag.txt, matched: [check_ubar]\n'
# b'Found: ./flag.txt, matched: [check_O]\n'
# b'Found: ./flag.txt, matched: [check_p]\n'
# b'Found: ./flag.txt, matched: [check_p]\n'
# b'Found: ./flag.txt, matched: [check_0]\n'
# b'Found: ./flag.txt, matched: [check_r]\n'
# b'Found: ./flag.txt, matched: [check_t]\n'
# b'Found: ./flag.txt, matched: [check_u]\n'
# b'Found: ./flag.txt, matched: [check_n]\n'
# b'Found: ./flag.txt, matched: [check_1]\n'
# b'Found: ./flag.txt, matched: [check_t]\n'

# ctf4b{Y3t_An0th3r_R34d_Opp0rtun1ty}

Wrap-up

The gap to the top teams is real, but it was still an enjoyable event.

Team 0nePadding has been growing a bit lately, which makes things more fun — looking forward to more competitions.