- 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_ 一時ファイルに書き込み、成功時に置き換える方式で、パストラバーサル防止、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 パースを自前で実装する
-
リクエストラインとヘッダー終了
-
パス抽出
- 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 を返す
-
文字列比較と数値変換
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_ 形式の一時ファイルに書き込む
getpid() のシステムコール番号 20 で pid を取得し、自前の itoa() で文字列に変換するが、バッファオーバーフローも検査する
- クライアントボディを一時ファイルにすべて書き込み、成功したら一時ファイルをその場の名前に置き換えて、要求ファイルをサーバー上に作成する
- クライアントが予期せず接続を切ったり、タイムアウトしたり、不正なボディを送ったりした場合は、一時ファイルを
unlink() システムコール 10 または unlinkat() システムコール 472 で削除する
- 既存ファイルは、完全なリクエストが正常に転送されたあとにだけ上書きする
ディレクトリ一覧とエスケープ処理
GET /somedir/ リクエストを受けたら、config.S の ALLOW_DIR_LISTING が有効か確認する
- ディレクトリ一覧が無効なら
403 Forbidden を返す
- 有効なら
getdirentries64() システムコール 344 で対象ディレクトリのファイル情報バッファを埋める
- バッファには各ファイル名とファイル名長が含まれており、ymawky はそれを使ってクリック可能な HTML を生成する
- 各ファイルについて、クライアントへ送る基本形は次のとおり
filename
href="..." 内のファイル名は URL パスセグメントとして パーセントエンコード し、画面に表示される本文テキストは HTML エスケープ しなければならない
- ファイル名が `&.-~>](%26.-~%3E%3Cfoo)
something evil のように本文領域で XSS 可能な名前や、\">something dastardly のように href="..." 領域で XSS 可能な名前も、実行されないようエンコードされる
ネットワークセキュリティとタイムアウト
ファイルシステムの安全性
-
ファイル情報確認の順序
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 内に特定のシンボリックリンクを仕込めるなら、すでに別の問題が起きている可能性が高いが、このフラグで追加防御が可能である
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 で拒否される
結論とプロジェクト情報
- 静的 Web サーバーで難しい部分は、ソケットを開いて listen することより、リクエスト解析とあらゆる境界条件の処理だった
- リクエスト、パス、レスポンスはすべてバイトであり、レンジリクエストは正確でなければならず、ファイル名は場所に応じて異なる方法でエスケープしなければならない
- アセンブリは、リクエスト解析、メモリ管理、エラー処理、文字列変換、タイムアウト、ファイル安全性といったあらゆる作業を自分で書かせる
- ymawky は imtomt がメンテナンスしている
1件のコメント
Lobste.rsのコメント
すごいな。以前、スマートデバイスを作っている小さな会社と連携する仕事をしたことがあるんだけど、その会社で唯一のエンジニアが アセンブリ言語しか知らなかった。
ハードウェア制御コードからサーバーOS、私たちが使っていた JSON Web API まで、全部アセンブリで直接書かれていた。
あるとき、Web API が見当違いのデバイスのデータを返すバグに遭遇したんだけど、調べてみるとOSのスケジューリングシステムに off-by-one エラーがあって、「データベース」がWebサービスに間違った行を返していた
「自殺」みたいな表現を扱うときは、頼むから コンテンツ警告 を付けてほしい。できれば、そもそも言及しないほうがいいし
このコメントを見てからもう一度探したけど見つからないんだが、何か見落としてる?
「全部アセンブリで書かれている」という話を見て、Therac-25調査報告書 を思い出した