人生に意味(不足)を与えるために、aarch64アセンブリでWebサーバーを作る
(imtomt.github.io)- ymawky は、aarch64アセンブリのみで書かれた macOS 向けの小型静的 HTTP サーバーであり、libc ラッパーなしで Darwin の生システムコールだけを使用する
GET,HEAD,PUT,OPTIONS,DELETE、バイトレンジリクエスト、ディレクトリ一覧、カスタムエラーページをサポートするが、nginx の代替ではなく、Web サーバーの動作を理解するために便宜的なレイヤーを取り払った実装である- リクエスト解析、パーセントデコード、ヘッダー検査、レンジ値変換、エラー処理、ファイルクローズ、レスポンス生成まですべて自前で書く必要があり、Python の単純な文字列分割や
int(string)に相当する処理も、アセンブリでは数十〜数百行の検証コードになる - サーバーは新しい接続ごとに
fork()を呼ぶ fork-on-request 構造なので実装は容易だが、同時接続の処理量は低く、slowloris に脆弱になり得るため、ヘッダータイムアウトとContent-Lengthベースのボディタイムアウトを適用している PUTはまず.ymawky_tmp_<pid>一時ファイルに書き込み、成功時に置き換える方式で、パストラバーサル防止、O_NOFOLLOW_ANY、fstat64()、ディレクトリ一覧での URL エンコード・HTML エスケープなど、ファイルシステムの安全性も自前で処理している
ymawky の概要と制約
- ymawky は、aarch64アセンブリのみで書かれた macOS 向けの小型静的 HTTP サーバーである
- libc ラッパーなしで Darwin の生システムコールだけを使い、外部ライブラリや既存パーサーは使わない
- サポート機能は
GET,HEAD,PUT,OPTIONS,DELETE、バイトレンジリクエスト、ディレクトリ一覧、カスタムエラーページである - プロジェクトの制約は次のとおり
- aarch64 assembly only
- macOS/Darwin 対象
- raw syscalls only, libc wrappers なし
- static files only
- preexisting parsers なし
- external libraries なし
- nginx を置き換えるのが目的ではなく、Web サーバーが実際にどう動くかを理解するために便宜的なレイヤーを取り払った実装である
アセンブリで Web サーバーを作るときに必要な作業
- アセンブリは機械語と高級言語のあいだの層であり、
mov,add,ldr,str,cmpのような命令は実行バイナリのバイトと直接対応する svc #0x80は実行バイナリのD4 00 10 01バイトに対応する、人が読める形である- 文字列型がないため、文字列はメモリ上の連続したバイト領域として存在し、C の
structのような言語機能もないので、フィールドのオフセットや全体サイズを自分で把握しなければならない - HTTP ライブラリ、自動クリーンアップ、例外、オブジェクトがないため、リクエスト解析、エラー処理、ファイルクローズ、レスポンス生成のような作業をすべて自前で書く必要がある
- 誤動作していても CPU は警告せずそのまま実行するので、問題は書いた命令とメモリアクセスにある
生システムコールとサーバーの流れ
-
Darwin システムコール
- ymawky は libc ラッパーの代わりにカーネルを直接呼び出す
- Darwin aarch64 ではシステムコール番号を
x16レジスタに入れ、Linux aarch64 ではx8に入れる open()のシステムコール番号は5で、ファイル名やモードなどの引数をレジスタに直接配置したあとsvc #0x80でカーネルを呼ぶopen()が失敗すると carry flag が設定され、b.cs open_failedのように carry flag を調べて失敗処理コードへ分岐する
-
基本的なサーバー動作
- Web サーバーの基本フローは、リクエストを受け取り、処理し、ステータスコードと必要なファイルを返す構造である
- ソケット設定は
socket(AF_INET, SOCK_STREAM, 0),setsockopt(... SO_REUSEADDR ...),bind(sockfd, &addr, 16),listen(sockfd, 5),accept(sockfd, NULL, NULL)のような段階で構成される - ymawky は新しい接続ごとに
fork()を呼ぶ fork-on-request サーバーである - この方式はリクエスト処理間でメモリを共有しないため理解しやすく実装も容易だが、プロセスごとのメモリ空間のため負荷が大きく、nginx のイベント駆動・非同期・ノンブロッキングモデルより同時接続の処理量が低い
- 同時接続が増えると、カーネルはプロセス内部の実行よりプロセス切り替えに多くの時間を使うようになる
-
リクエスト処理で必要な作業
- リクエストメソッドが
GET,HEAD,OPTIONS,PUT,DELETEのどれかを判定する - リクエストパスを抽出し、
%20のようなパーセントエンコードをデコードする - パスの安全性検査を行い、クライアントが送ったヘッダーフィールドを解析する
- リクエスト対象のファイル情報を取得し、ディレクトリか通常ファイルかを判別する
PUTリクエストボディは一時ファイルに書き込み、レスポンスヘッダーとボディを生成する- 開いたファイルを閉じ、サーバーがクラッシュしないようにエラーを処理する
- リクエストメソッドが
HTTP パースを自前で実装する
-
リクエストラインとヘッダー終了
- HTTP リクエストはサーバーが解釈しなければならない文字列であり、例は次のとおり
GET /index.html HTTP/1.0\r\n Range: bytes=1-5\r\n\r\n - 1 行目には
GETリクエスト、対象ファイルindex.html、HTTP バージョンHTTP/1.0が含まれる \r\nは行末、\r\n\r\nはヘッダーの終わりである\r\n\r\nを受け取れなければ400 Bad Requestで中断しなければならない
- HTTP リクエストはサーバーが解釈しなければならない文字列であり、例は次のとおり
-
パス抽出
- ymawky はサポートするメソッドと先頭バイト列を比較してリクエスト種別を判定したうえで、パスを抽出する
- ヘッダーを 1 バイトずつ走査して
/または*を探すが、HTTP/1.0内の/をパスと誤認しないよう、/の直前のバイトが空白かどうかを確認する - たとえば
GET HTTP/1.0\r\n\r\nにはHTTP/1.0内に/があるので、直前のバイトが空白でなければ400 Bad Requestを返す - ほとんどのシステムでは
PATH_MAXが 4096 バイトなので、ymawky は 4096 バイトのファイル名バッファと終端 NUL 1 バイトのためにfilename_buffer: .skip 4097を用意している - リクエストパスがバッファより長い場合は任意メモリを書き潰す代わりに
414 URI Too Longを返さなければならない - Python の
text.split("GET /")[1].split(" ")[0]に近い処理も、アセンブリでは HTTP 妥当性検査まで含めると約 200 行になる
-
パーセントデコードとヘッダーフィールド検査
- パス中で
%に遭遇したら、続く 2 バイトが0-9,a-f,A-Fに当たる有効な 16 進数か確認し、そのバイト値に変換する GETはRange:ヘッダーを持てて、PUTにはContent-Length:が必要である- これらのヘッダーはリクエスト URL のように固定位置にあるわけではないので、ヘッダー全体を文字単位で走査しなければならない
\rの次に\nがなかったり、先行する\rなしで\nが現れたりしたら、不正なヘッダーとして400 Bad Requestを返す- 新しいヘッダー行が空白で始まっていたら、ヘッダーフィールドは空白で始められないので
400 Bad Requestを返す
- パス中で
-
文字列比較と数値変換
Range:やContent-Length:を探すために、2 つの文字列ポインタx0,x1と最大長x2を受け取り、文字単位で比較するstreqn関数を書くRange:ヘッダーは次のように開始または終了のどちらかを省略できるが、少なくともどちらか一方は必要であるRange: bytes=10- Range: bytes=-10 Range: bytes=5-10- レンジ値は文字列なので、ASCII 数字を整数へ変換する
atoiスタイルの関数が必要になる - 64 ビットレジスタのオーバーフローを避けるため、数字が 19 桁以上ならエラーとして扱う
- Python の
int(string)に相当する処理も、アセンブリでは数値検査、乗算、加算、carry flag ベースの成功・失敗通知を自前で実装しなければならない
PUT 処理と一時ファイル戦略
PUTは同じリクエストを何度送っても最終的なサーバー状態が同じになる 冪等 (idempotent) メソッドであるPUT /file.txtはfile.txtを作成するか既存ファイルを完全に上書きし、1234を 2 回送ってもファイル内容は12341234ではなく1234になる- グローバルに開かれた
PUTは危険になり得て、処理中に考慮すべき問題は次のとおり- リクエスト処理中にプロセスがクラッシュする場合
- クライアントが
Content-Lengthを 2KB と言いながら 100 バイトしか送らない場合 - クライアントが
Content-Lengthを 50GB のように非常に大きく送る場合
config.SのMAX_BODY_SIZEはデフォルトで 1GB であり、Content-Lengthがこれを超えると413 Content Too Largeを返す- 既存ファイルを直接開いて書くと、失敗時に中途半端に書かれたファイルが残る可能性があるため、ymawky はまず
.ymawky_tmp_<pid>形式の一時ファイルに書き込む getpid()のシステムコール番号20で pid を取得し、自前のitoa()で文字列に変換するが、バッファオーバーフローも検査する- クライアントボディを一時ファイルにすべて書き込み、成功したら一時ファイルをその場の名前に置き換えて、要求ファイルをサーバー上に作成する
- クライアントが予期せず接続を切ったり、タイムアウトしたり、不正なボディを送ったりした場合は、一時ファイルを
unlink()システムコール10またはunlinkat()システムコール472で削除する - 既存ファイルは、完全なリクエストが正常に転送されたあとにだけ上書きする
ディレクトリ一覧とエスケープ処理
GET /somedir/リクエストを受けたら、config.SのALLOW_DIR_LISTINGが有効か確認する- ディレクトリ一覧が無効なら
403 Forbiddenを返す - 有効なら
getdirentries64()システムコール344で対象ディレクトリのファイル情報バッファを埋める - バッファには各ファイル名とファイル名長が含まれており、ymawky はそれを使ってクリック可能な HTML を生成する
- 各ファイルについて、クライアントへ送る基本形は次のとおり
<a href="filename">filename</a> href="..."内のファイル名は URL パスセグメントとして パーセントエンコード し、画面に表示される本文テキストは HTML エスケープ しなければならない- ファイル名が
&.-~><fooなら、href は%26.-~%3E%3Cfoo、表示テキストは&.-~><fooとなり、最終出力は次のようになる<a href="%26.-~%3E%3Cfoo">&.-~><foo</a> <script>something evil</script>のように本文領域で XSS 可能な名前や、\"><script>something dastardly</script>のようにhref="..."領域で XSS 可能な名前も、実行されないようエンコードされる
ネットワークセキュリティとタイムアウト
- slowloris は多数の接続を開いたままリクエストを完了させず、サーバーリソースを拘束するサービス拒否攻撃である
- ymawky は fork-on-request 構造なので slowloris に脆弱になり得る
- ヘッダー全体が
config.SのHEADER_REQ_TIMEOUT_SECS以内に受信されなければ、408 Request Timeoutを送り、接続を閉じる - リクエストボディ受信中にクライアントが長時間データを送らなければ、
config.SのRECV_TIMEOUTに従って同様に処理する - 単純な read ごとのタイムアウトだけでは十分ではない
- 悪意あるクライアントが
Content-Length: 1073741823を送り、9 秒ごとに 1 バイトずつ送ると、コンテンツ長は最大値より 1 バイト小さいため許可され、10 秒単位のタイムアウトでは 300 年以上待ててしまう
- 悪意あるクライアントが
- これを緩和するため、ymawky は
Content-Lengthと最小毎秒バイト数に基づいてタイムアウトを計算するtimeout = grace_period + content_length / min_bps grace_periodはすべてのボディに与える最低時間で、min_bpsはサーバーが許容する最も遅い転送速度である- デフォルトの
min_bpsは 16KB/s で、十分に緩いが無限ではない - この方式でサービス拒否攻撃を完全に防げるわけではないが、特定の攻撃がリソースを拘束できる時間を制限できる
ファイルシステムの安全性
-
ファイル情報確認の順序
GETとHEADでは、要求パスを開いたあと、そのファイルディスクリプタに対してfstat64()システムコール339を実行し、ファイル種別やサイズなどの情報を得る- パスに対して先に
stat64()システムコール338を実行してからファイルを開くと、検査時点と使用時点のあいだにファイルが変わる TOCTOU race condition が生じ得る
-
docroot とパストラバーサル防止
- すべてのリクエストパスの前には docroot が付与される
- デフォルトの docroot は
config.SのDEFAULT_DIRであるwww/である /etc/shadowリクエストはwww/etc/shadowとなり、www/etc/shadowが実際に存在しない限り 404 になる- しかし
/../../../../etc/shadowはwww/../../../../etc/shadowとなって docroot の外側に解釈され得るため、追加の防御が必要である - ymawky は単に文字列
..を含むすべてのパスを拒否するのではなく、パスセグメントが厳密に..の場合に拒否する %2E%2Eはデコード後に..になるため、この検査はパーセントデコード後に行わなければならない
-
シンボリックリンク処理
- POSIX の
O_NOFOLLOWフラグは、最終パス要素がシンボリックリンクならopen()を失敗させる - Darwin の
O_NOFOLLOW_ANYは、パス中のどの要素であってもシンボリックリンクなら失敗させる - docroot 内に特定のシンボリックリンクを仕込めるなら、すでに別の問題が起きている可能性が高いが、このフラグで追加防御が可能である
- POSIX の
Apple 固有の動作
-
タイムアウト処理と
sigaction()- リクエストタイムアウトを実装するには、
setitimer()システムコール83で一定時間後にSIGALRMを送る必要がある - デフォルトでは
SIGALRMは child を終了させるが、ymawky はその前に408 Request Timeoutを送らなければならない - そのために
sigaction()システムコール46を使う - Darwin の生
sigaction構造体はsa_trampフィールドを公開している - 通常は libc が
sa_trampを設定し、スタックとレジスタを保存してsigreturnを準備したうえでハンドラへ分岐する - ymawky のタイムアウトハンドラは
408 Request Timeoutを送り、必要なものを閉じたあと child を終了するので、復帰する必要がない - そのため trampoline スロットをタイムアウト応答を直接実行するコードへ向け、
sa_handlerとsigreturnを迂回する
- リクエストタイムアウトを実装するには、
-
proc_info()と child process 数の制限- Apple には、実行中プロセスとその子プロセス情報を取得できる、よく文書化されていない
proc_info()システムコール336がある - この呼び出しは通常
ps,lsof,topのようなツールで使われる - ymawky はアクティブな child process 数を数えるために
proc_info()を使う - 最大接続数を設定できるので、生きている child 数を知る必要がある
proc_info()は child process 情報をバッファへ書き込み、各要素サイズが既知なので、記録されたバイト数から child 数を計算できる- child 数が
MAX_PROCSを超えると、新しい接続は503 Service Unavailableで拒否される
- Apple には、実行中プロセスとその子プロセス情報を取得できる、よく文書化されていない
1件のコメント
Lobste.rsのコメント
すごいな。以前、スマートデバイスを作っている小さな会社と連携する仕事をしたことがあるんだけど、その会社で唯一のエンジニアが アセンブリ言語しか知らなかった。
ハードウェア制御コードからサーバーOS、私たちが使っていた JSON Web API まで、全部アセンブリで直接書かれていた。
あるとき、Web API が見当違いのデバイスのデータを返すバグに遭遇したんだけど、調べてみるとOSのスケジューリングシステムに off-by-one エラーがあって、「データベース」がWebサービスに間違った行を返していた
「自殺」みたいな表現を扱うときは、頼むから コンテンツ警告 を付けてほしい。できれば、そもそも言及しないほうがいいし
このコメントを見てからもう一度探したけど見つからないんだが、何か見落としてる?
「全部アセンブリで書かれている」という話を見て、Therac-25調査報告書 を思い出した