1 ポイント 投稿者 GN⁺ 5 시간 전 | 1件のコメント | WhatsAppで共有
  • 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_ANYfstat64()、ディレクトリ一覧での 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 で中断しなければならない
  • パス抽出

    • 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 進数か確認し、そのバイト値に変換する
    • GETRange: ヘッダーを持てて、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.txtfile.txt を作成するか既存ファイルを完全に上書きし、1234 を 2 回送ってもファイル内容は 12341234 ではなく 1234 になる
  • グローバルに開かれた PUT は危険になり得て、処理中に考慮すべき問題は次のとおり
    • リクエスト処理中にプロセスがクラッシュする場合
    • クライアントが Content-Length を 2KB と言いながら 100 バイトしか送らない場合
    • クライアントが Content-Length を 50GB のように非常に大きく送る場合
  • config.SMAX_BODY_SIZE はデフォルトで 1GB であり、Content-Length がこれを超えると 413 Content Too Large を返す
  • 既存ファイルを直接開いて書くと、失敗時に中途半端に書かれたファイルが残る可能性があるため、ymawky はまず .ymawky_tmp_<pid> 形式の一時ファイルに書き込む
  • getpid() のシステムコール番号 20 で pid を取得し、自前の itoa() で文字列に変換するが、バッファオーバーフローも検査する
  • クライアントボディを一時ファイルにすべて書き込み、成功したら一時ファイルをその場の名前に置き換えて、要求ファイルをサーバー上に作成する
  • クライアントが予期せず接続を切ったり、タイムアウトしたり、不正なボディを送ったりした場合は、一時ファイルを unlink() システムコール 10 または unlinkat() システムコール 472 で削除する
  • 既存ファイルは、完全なリクエストが正常に転送されたあとにだけ上書きする

ディレクトリ一覧とエスケープ処理

  • GET /somedir/ リクエストを受けたら、config.SALLOW_DIR_LISTING が有効か確認する
  • ディレクトリ一覧が無効なら 403 Forbidden を返す
  • 有効なら getdirentries64() システムコール 344 で対象ディレクトリのファイル情報バッファを埋める
  • バッファには各ファイル名とファイル名長が含まれており、ymawky はそれを使ってクリック可能な HTML を生成する
  • 各ファイルについて、クライアントへ送る基本形は次のとおり
    <a href="filename">filename</a>
    
  • href="..." 内のファイル名は URL パスセグメントとして パーセントエンコード し、画面に表示される本文テキストは HTML エスケープ しなければならない
  • ファイル名が &.-~><foo なら、href は %26.-~%3E%3Cfoo、表示テキストは &amp;.-~&gt;&lt;foo となり、最終出力は次のようになる
    <a href="%26.-~%3E%3Cfoo">&amp;.-~&gt;&lt;foo</a>
    
  • <script>something evil</script> のように本文領域で XSS 可能な名前や、\"><script>something dastardly</script> のように href="..." 領域で XSS 可能な名前も、実行されないようエンコードされる

ネットワークセキュリティとタイムアウト

  • slowloris は多数の接続を開いたままリクエストを完了させず、サーバーリソースを拘束するサービス拒否攻撃である
  • ymawky は fork-on-request 構造なので slowloris に脆弱になり得る
  • ヘッダー全体が config.SHEADER_REQ_TIMEOUT_SECS 以内に受信されなければ、408 Request Timeout を送り、接続を閉じる
  • リクエストボディ受信中にクライアントが長時間データを送らなければ、config.SRECV_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 で、十分に緩いが無限ではない
  • この方式でサービス拒否攻撃を完全に防げるわけではないが、特定の攻撃がリソースを拘束できる時間を制限できる

ファイルシステムの安全性

  • ファイル情報確認の順序

    • GETHEAD では、要求パスを開いたあと、そのファイルディスクリプタに対して fstat64() システムコール 339 を実行し、ファイル種別やサイズなどの情報を得る
    • パスに対して先に stat64() システムコール 338 を実行してからファイルを開くと、検査時点と使用時点のあいだにファイルが変わる TOCTOU race condition が生じ得る
  • docroot とパストラバーサル防止

    • すべてのリクエストパスの前には docroot が付与される
    • デフォルトの docroot は config.SDEFAULT_DIR である www/ である
    • /etc/shadow リクエストは www/etc/shadow となり、www/etc/shadow が実際に存在しない限り 404 になる
    • しかし /../../../../etc/shadowwww/../../../../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_handlersigreturn を迂回する
  • 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 することより、リクエスト解析とあらゆる境界条件の処理だった
  • リクエスト、パス、レスポンスはすべてバイトであり、レンジリクエストは正確でなければならず、ファイル名は場所に応じて異なる方法でエスケープしなければならない
  • アセンブリは、リクエスト解析、メモリ管理、エラー処理、文字列変換、タイムアウト、ファイル安全性といったあらゆる作業を自分で書かせる
  • ymawkyimtomt がメンテナンスしている

1件のコメント

 
GN⁺ 5 시간 전
Lobste.rsのコメント
  • すごいな。以前、スマートデバイスを作っている小さな会社と連携する仕事をしたことがあるんだけど、その会社で唯一のエンジニアが アセンブリ言語しか知らなかった。
    ハードウェア制御コードからサーバーOS、私たちが使っていた JSON Web API まで、全部アセンブリで直接書かれていた。
    あるとき、Web API が見当違いのデバイスのデータを返すバグに遭遇したんだけど、調べてみるとOSのスケジューリングシステムに off-by-one エラーがあって、「データベース」がWebサービスに間違った行を返していた

    • その人の名前、もしかして Mel じゃなかった?
  • 「自殺」みたいな表現を扱うときは、頼むから コンテンツ警告 を付けてほしい。できれば、そもそも言及しないほうがいいし

    • 何のこと? 記事は少し流し読みしたけど、最初に読んだとき 自殺への言及 は見当たらなかった。
      このコメントを見てからもう一度探したけど見つからないんだが、何か見落としてる?
    • ユーモアのセンスがまったくないことのほうが、むしろ本人の健康にも社会全体にもはるかに危険だ
  • 「全部アセンブリで書かれている」という話を見て、Therac-25調査報告書 を思い出した