
2026/05/08 22:39
AArch64 アセンブリ言語で Web サーバーを構築し、自分の人生(=意味のなさ)に「意味」を与えようとしている。
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
ymawky は、macOS 向けに完全に aarch64 アセンブリ で記述された最小限の静的 HTTP ウェブサーバーであり、生な Darwin システムコール を使用しており、外部ライブラリや
libc ラッパーは一切含まれていない。実装機能には GET, HEAD, PUT, OPTIONS, DELETE メソッド、バイト範囲指定、ディレクトリスティンギング、カスタムエラーページのサポートが含まれる。アーキテクチャは要求毎のフォークモデル(fork() システムコールを使用)に依存しており、メモリを各要求ごとに隔離するが、イベント駆動型の設計と比較してリソースの肥大化が顕著であり、並行処理能力には限界がある。
低レベルの実装に伴う課題も明らかである:アセンブリでは抽象化がほぼ存在せず、CPU レジスタの手動設定(例:システムコール番号のための x16)やキャリーフラグなどのフラグの直接的な確認が必要となる。具体的なメカニズムとしては、 Filename 上の書き込み防止のために 4096 バイトのバッファを使用し、オーバーフローチェック付きの
atoi 関数、PUT アップロードの安全確保のための一時的ファイル名のリネーミングが含まれる。セキュリティについては、HTTP パースのための手動バイトスキャン、パストラバージアルを拒否するパーセント符号デコード(.. セグメントのブロック)、ディレクトリスティンギングにおける HTML/パーセント符号化による XSS 防止、O_NOFOLLOW_ANY を使用したシンボリックリンク保護などが実施されている。
クラッシュや攻撃を防ぐために、サーバーは Content-Length および最低 throughput レートに基づいたタイムアウトシステムを採用しており、これにより Slowloris に対する防御がなされる。これは Apple 固有の
setitimer() の使用を通じて実装されている。また、proc_info()(システムコール #336)を使用して子プロセス数を制限している。この極限まで簡素化されたアプローチは OS インターナルへの習熟と極めて小さなバイナリサイズを実証するが、安全ラッパーの余地を全く残しておらず、いかなるロジックまたは指示の誤りも直ちにクラッシュを引き起こす。本文
Aarch64 アセンブリでウェブサーバーを構築して、自分の人生(あるいは意味lessness)に「意味」を与えてみました。
ymawky は、macOS 向けに完全に Aarch64 アセンブリだけで書かれた、小さな静的な HTTP ウェブサーバーです。標準ライブラリ (libc) のラッパーは一切使用せず、生の Darwin システムコールのみを用いており、静的ファイルを配信するほか、GET、HEAD、PUT、OPTIONS、DELETE、バイト範囲(Byte ranges)、ディレクトリリスト機能、カスタムエラーページに対応しています。可能な限りセキュリティを強化することを心がけています。
なぜ?なぜしないの?
80 年代の夢が ymawky に宿っています。誰もが nginx を使っていますが、Apache を持っていると「堅実」な人間(square)だと見なされてしまいます。ではどうでしょう?1957 年以来、コンピュータサイエンスが与えてくれた全ての利便性レイヤーを剥ぎ去ってみませんか?私は低レベル・システムズバックグラウンドを持ちながら、ウェブサーバーの仕組みを実際に理解したいという思いからこのプロジェクトを始めました。そこで直面するリスクや解決すべき課題、Python や C を書いている時には考えもしないような事柄に出会うことができます。
このサーバーは nginx を置き換えるものではないでしょう(おそらく)。しかし、それは可能な限り最も困難な方法で何かを行おうとしています。
制約条件
このプロジェクトに自ら課した制約条件です:
- Aarch64 アセンブリのみ
- macOS/Darwin 限定(Linux はなし)。現状のシステムだからです。ライナー heads に申し訳ありませんが :(
- 生のシステムコールのみ:libc ラッパーは使用しません。
- 静的ファイルのみ
- 既存のパースライブラリ不使用
- 外部ライブラリの完全な不使用
アセンブリ、私のお気に入り
アセンブリ言語は、マシンコードと他のプログラミング言語の間に位置する層です。C コードはアセンブリへコンパイルされ、その後アッセンブルされて実行可能バイナリになります。アセンブリとは、
mov, add, ldr, str, cmp などの命令語(mnemonics)を用いて、生の実行可能なバイト列と直接対応する人間が読みやすい言語です。例えば、svc #0x80 は、実行可能バイナリ中发现されるバイト列 D4 00 10 01 の人間が読みやすい同等表現です。
抽象化はほとんどありません。CPU レジスターとメモリ間で値を移動させ、比較し、コードの異なる部分へジャンプし、システムコールのためにカーネルを呼び出します。単純なことが複雑に見えますが、CPU が行うほぼ全てのステップが見え、あなたの制御下に置かれます。命令はあなたが言われたことを忠実に実行し、警告もなく、助けもくれません。もし動作が正しくないなら、それはあなたがコードを誤って書いたからです。
アセンブリでウェブサーバーを書くということは、HTTP ライブラリがないということです。自動的なクリーンアップもないです。ストリング型(string type)という概念さえありません:文字列は単にバイトを順次保持するメモリ領域のだけです。C 言語における構造体(struct)のような言語機能も存在しません。各フィールド間の正確なオフセットと、構造体の総サイズを知る必要があります。そうでないと CPU は喜んで誤ったメモリを読み取ってしまうでしょう。
生のシステムコール
ymawky はいかなる libc ラッパーも使用せず、単にカーネルへの直接呼び出しを行います。例えば、ファイルをオープンするこのコード断片を見てみましょう:
mov x16, #5 ; システムコール番号:SYS_open adrp x0, filename@PAGE add x0, x0, filename@PAGEOFF mov x1, #0x0 ; O_RDONLY は単に 0x0000 svc #0x80 ; システムコール呼び出し b.cs open_failed ; キャリフラグがセットなら失敗処理へジャンプ
Darwin では、システムコール番号は
x16 レジスターに入れます(Aarch64 Linux では x8 です)。システムコール番号 5 は open() で、ファイル名とモードといった引数をいくつか取ります。各引数は手動でレジスターにセットし、svc #0x80 を実行してカーネルを呼び出します。
open() が失敗するとキャリフラグがセットされます。私たちはこれを b.cs open_failed で確認し、「キャリフラグがセットされたら open_failed ラベルへジャンプする」という意味です。その後、open_failed 部分で必要なクリーンアップや応答処理を行います。
このようなことは頻繁に起こります。アセンブリには「例外(exceptions)」や「オブジェクト」がなく、単に CPU フラグを設定してあなたがチェックし対処しなければなりません。
概要
最も基本的には、ウェブサーバーはリクエストを受信し、それを処理してからステータスコード(および場合によってはファイル)を返します。「リクエストを受信する」という部分にも多くの作業が含まれます:
でソケットを設定するsocket(AF_INET, SOCK_STREAM, 0)
でソケットを構成するsetsockopt(serverfd, SOL_SOCKET, SO_REUSEADDR, &buf, sizeof(int))
でファイルディスクリプターをアドレスにバインドするbind(sockfd, &addr, 16)
で新しい接続用にソケットを待機させるlisten(sockfd, 5)
で接続を受け付けるaccept(sockfd, NULL, NULL)
ymawky は「リクエストごとにフォークする(fork-on-request)」サーバーです。つまり、各新規のインバウンド接続に対して
fork() システムコールを呼び出します。これにはいくつかの利点があります:
- リクエストハンドラー間でメモリは共有されない
- 理解しやすい
- 書きやすい
しかし、著しい不具合もあります:
- メモリ使用量の増加(ブロート)
- 各プロセスに独自のメモリー空間がある
- fundamentally に、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
最初の行はいくつかの情報を教えてくれます。これは GET リクエストで、クライアントは index.html を送ってもらいたいと求めています。HTTP/1.0 はクライアントが使用している HTTP バージョンを示します。
\r\n(リターン plus ラインフィード)のシーケンスは「この行の終わりです、次の行を処理してください」と伝えます。末尾の \r\n\r\n はヘッダーの終わりを示します。もしも \r\n\r\n を受け取らなければ、400 Bad Request でリクエストを拒否しなければなりません。
次に
Range: bytes=3-5 があります。これは「このファイルから、byte 3 から byte 5 のみを提供し、残りは無視してください」という意味です。ファイルが 500GB あっても、byte 3〜5 のみを要求されたら、結果として 3 バイトしか返ってこないでしょう。ヤッター!残念ながら私がこれらを処理しなければならないのです。プー!
まず、ymawky はサポートするメソッドのそれぞれの数バイトと比較することでリクエストタイプを決定し、その後パスを抽出します。ヘッダーをバイト単位でスキャンして
/ または空白を見つけますが、全ての / が要求されたパスであると仮定することはできません。もしクライアントが以下のように送ってきた場合:
GET /index.html HTTP/1.0\r\n
HTTP/1.0 に / が含まれています。私たちは / につつまると、直前のバイトが空白だったかチェックします。そうでなければ、400 Bad Request を返します。
パスを見つけると、それを保存する場所が必要になります。多くのシステムでは
PATH_MAX は 4096 バイトなので、ymawky では 4096 バイトのファイル名バッファーと null ターミネーター用の 1 バイトを確保します:
.bss filename_buffer: .skip 4097 .align 3
ファイル名のコピーは単なるループですが、そのループは常に両側をチェックしなければなりません:ヘッダーの先頭に読み込むのを防ぎ、ファイル名バッファーの先頭を書き越えないようにします。もしクライアントが
GET /aa...[5000 A]...a HTTP/1.0 を要求しても、5KB の任意のメモリーを書き越える代わりに、414 URI Too Long で応答するべきです。
Python では以下のような記述になります:
text.split("GET /")[1].split(" ")[0]
アセンブリではこれを保証すると約 200 行にもなります(HTTP の合法性チェックまで含む)。アセンブリは最高ですね、なんて!
その後、パスのパーセント符号エンコーディングを解読する必要があります。パースャが
% を見かけると、次の 2 バイトを読み、有効な 16 進数文字(0-9, a-f, A-F)であることを確認し、対応するバイトに変換して続けます。
GET リクエストには
Range: ヘッダーを含めることができ、PUT リクエストは Content-Length: が必須です。要求された URL と異なり、これらはヘッダー内のどこかの行に現れることができます。私たちはヘッダーを文字ごとにイテレートしていく必要があります。\r を見つけると、次の文字が \n かをチェックしなければなりません。そうでなければ、不正なヘッダーであり、400 Bad Request を送らなければなりません。同様に、\r のない \n も不正です。\r\n を見つけると、現在の行の終わりかつ次の行の始まりを示します。この新しい行が空白で始まっているか確認し、そうだとすると 400 Bad Request を返します(ヘッダーフィールドは空白で始まることはできません)。その後、メソッドに応じて Range:(または Content-Length:)をチェックするために、小さな文字列比較関数を使います:
streqn: ldrb w3, [x0] ldrb w4, [x1] cmp w3, w4 b.ne Lstreqn_no_match cbz w3, Lstreqn_match ;; 両方とも等しく両方 NULL = 文字列終了 = マッチ ;; 終了に達したら、マッチだね? subs x2, x2, #1 b.eq Lstreqn_match add x0, x0, #1 add x1, x1, #1 b streqn Lstreqn_match: mov x0, #1 ret Lstreqn_no_match: mov x0, #0 ret
これは、
x0 と x1 の 2 つの文字列ポインタと、x2 の最大長を受け取り、各文字が等しいか確認します。
Range: ヘッダーはどう見えますか:
Range: bytes=10- Range: bytes=-10 Range: bytes=5-10
範囲の両側はオプションですが、少なくとも一方は必須です。「10」は文字列なので、定数の 10 のものではありません。各側を ASCII デジットから整数に変換する必要があります。ここで
atoi スタイルの関数を書く必要がありますが、整数オーバーフローをチェックすることに注意してください:
;; x0 -> 文字列へのポインタ atoi: mov x1, #0 mov x3, #10 mov x4, #0 1: ; 数字が >=19 デジットだと、64 ビットレジスターでオーバーフローする可能性がある cmp x4, #19 b.hs Latoi_error ldrb w2, [x0] cbz w2, 2f cmp w2, #'0' b.lo Latoi_error cmp w2, #'9' b.hi Latoi_error ; result = (result * 10) + 現在の桁 mul x1, x1, x3 sub w2, w2, #'0' add x1, x1, w2 add x0, x0, #1 add x4, x4, #1 b 1b 2: cmn xzr, xzr ; 成功をシグナルするようキャリフラグをクリア mov x0, x1 ret Latoi_error: cmp xzr, xzr ; 失敗をシグナルするようキャリフラグを設定 mov x0, #0 ret
Python では
int(string) で済みますね。アセンブリは魔法のようです!
PUT
PUT は興味深いです。それは冪等(idempotent)で、同じリクエストを送信する回数に関わらず、サーバー側の最終結果は同じです。
PUT /file.txt は file.txt を作成するか、既に存在するなら完全に上書きします。file.txt に 1234 を連続して 2 回 PUT すると、ファイルには 1234 が含まれるだけで、12341234 にはなりません。
これは PUT をグローバルにオープンしておくことは実は結構危険です。しかしさて、どこの問題ですか?
PUT を扱う際に考慮すべきことはいくつかあります:
- プロセスがリクエスト処理の途中でクラッシュしたらどうなるか?
- クライアントが
が 2KB と宣言するが、実際には 100 バイトしか送らないとしたら?Content-Length - クライアントが
を巨大な値(例えば 50GB)と宣言したらどうなるか?Content-Length
最後のは解決しやすいです。最大ファイルサイズを設定します。
config.S ではデフォルトで MAX_BODY_SIZE が 1GB です。Content-Length がそれを上回ると、ymawky は 413 Content Too Large でリクエストを拒否します。お手軽!
最初 2 つの問題には基本的な解決策があります。blind に file.txt をオープンして書き込もうとすると、何かが起きた場合半分のデータしか書き込まれたファイルが残ってしまう可能性があります。そこで代わりに、ymawky は一時ファイルに書き込みます:
;; 成功したら一時ファイルを改名する形式で書く temp_file_path = "/tmp/ymawky_put_$(pid)" ;; ... 書き込みロジック ... rename(temp_file_path, target_file) unlink(temp_file_path) ;; 何か失敗したらクリーンアップ
PID を得るには
getpid()(システムコール #20)を使い、その後数値を文字列に変換するカスタム itoa() 関数を使います(もちろんバッファーオーバーフローチェックも行いながら)。その後、クライアントからの要求内容が一時ファイルに書き込まれます。すべてうまくいけば、一時ファイルが場所内に改名され、file.txt がサーバー上に存在することになります。クライアントが予期せず切断したり、タイムアウトしたり、または不正なボディを送った場合、一時ファイルは unlink() で削除されます(システムコール #10/ #472 unlinkat)。既存のファイルは、完全に成功したリクエストの後才被上書きされます。
ディレクトリリスト機能とさらに文字列パース Yay
あるウェブサイトを訪問し、ディレクトリページでファイル一覧とそのリンクが表示されたことがありませんか?これは非常に基本的な機能であり、複雑でもありません。しかしアセンブリでは全てを手動で行う必要があります。
GET /somedir/ と要求すると、ディレクトリリスト機能が有効かどうか(config.S の ALLOW_DIR_LISTING)をチェックします。有効でなければ 403 Forbidden を送り、そこで終了です。
有効な場合、要求されたディレクトリに対して
getdirentries64()(システムコール #344)を呼び出します。これはバッファーにディレクトリ内の全てのファイルに関する情報を満たします。特に重要なのは、各ファイルの名前と、その長さが含まれていることです。
私たちはこの名前情報を使って HTML を構築し、ディレクトリリストをクlickable にして魅力的にします。各ファイルに対して以下をクライアントに記述します:
<a href="filename">filename</a>
しかし、これらの 2 つのファイル名は異なる扱いとサニタイズが必要になります。
href="..." の内部では、ファイル名を URL/パスセグメント用にパーセント符号エンコードする必要があります。表示される本文文字列では HTML エスケープが必要です。
例えば、ファイル名が
<>& の場合:
href は %3C%26%3E である必要があります。
しかし、表示されるテキストは <&> であるべきです。
したがって最終出力は:
<a href="%26.-~%3E%3Cfoo">&.-~><foo</a>
のように安全にエンコードされます。ファイル名が
<script>something evil</script>(表示部分での XSS を許可)や ><script>something dastardly</script>(href="..." 部分での XSS を許可)の場合は、実行されるのではなく安全にエンコードされます。
ネットワークセキュリティ
DoS 攻撃の一種として Slowloris というものがあります。ymawky は slowloris の天国です。
Slowloris はサーバーに多くの接続を開いてリクエストを終了せずに放置し、接続を開放したまま完全なリクエストが届かず、サーバーが資源を使い続けて待機するのを許します。
これをどう防ぐでしょうか?もしヘッダー全体が設定されたタイムアウト(
config.S の HEADER_REQ_TIMEOUT_SECS)以内に受信されなければ、クライアントには 408 Request Timeout が返され接続が閉じます。リクエストボディ中にクライアントが長期間データを送らなければ、同じことが起きます。
しかし単なる読み込み毎のタイムアウトでは不十分です。悪意のあるクライアントが以下のように送ったとしたら:
PUT /file.txt HTTP/1.0\r\n Content-Length: 1073741823\r\n \r\n
その後、9 秒に 1 バイトずつ送信した場合どうなるでしょうか?リクエストは承認されます(コンテンツ長が最大値より 1 バイト少ないため)。もしタイムアウトがバイトあたり 10 秒だけなら、サーバーは 300 年以上 patiently に待つことになります。よくありません。結構悪いですね。
これを最小限にするために、ymawky は
Content-Length と最小転送速度(bytes-per-second)に基づいてタイムアウトを計算します:
timeout = grace_period + content_length / min_bps
Grace period はボディに与える最小時間の量です。Min bps サーバーが許容する最悪の転送速度です。デフォルトでは 16KB/s の寛大な値ですが、無限ではありません。これで ymawky が DoS 攻撃から完全に無防備になるわけではありませんが、特定の種類の攻撃が資源を占有する時間を制限します。
ファイルシステム安全性
GET と HEAD メソッドでは、ymawky は要求されたパスをオープンし、そのファイルディスクリプターに
fstat64()(システムコール #339)を呼び出して、ファイルタイプやサイズなどの情報を取得します。
最初にパスに対して
stat64()(システムコール #338)をチェックしてそれからファイルをオープンすると、チェックと使用のタイミングによる競合条件(time-of-check/time-of-use race condition)が発生する可能性があります。チェックしたファイルはオープンされる数マイクロ秒前に変更されているかもしれません。
悪意あるリクエスト
ファイル機密性を無視したサーバーを想像してみましょう。何でも許されます。誰かが以下のように要求すれば:
GET /etc/shadow HTTP/1.0\r\n \r\n
システムを支配できてしまいます。それはフェアではありません!何か対処する必要があります!
まず、全ての要求されたパスに docroot が付与されます。デフォルトでは
www/(config.S の DEFAULT_DIR)です。/etc/shadow へのリクエストは www/etc/shadow へのリクエストになります(これは 404 になるはずです)。つまり、www/ 内に etc/ というディレクトリがあり、その中に shadow というファイルがある場合を除く)。
問題解決!
...
しかし実はそれほど単純ではありません。
Unix ファイルシステムに少し馴染みのある誰もが
..(パス遍歷)について知っています。彼らは以下のように要求できます:
GET /../../../../etc/shadow
これは:
www/../../../../etc/shadow
となり、docroot の外へ解決します。それはかなり愚かです。我々は traversatl を拒否する必要がありますが、過度に厳しくなりません。単純な部分文字列検索で
.. に一致するものを全て拒否したくはありません(おっと...png というファイル名も正当なので)。
そこで ymawky は正確に
.. のパスセグメントを拒否します。これはパーセント符号エンコーディングの解読の後で行う必要があります。なぜなら %2E%2E が解読後 .. になるからです。
しかし待ってください!シンボルリンクはどうでしょうか?
open()(システムコール #5)には POSIX で定義された O_NOFOLLOW フラグがあり、最終パス成分がシンボリックリンクの場合に呼び出しを失敗させます。しかしパスの途中にあるディレクトリがシンボリックリンクの場合はどうでしょう?Darwin は「パス内のどの要素もシンボリックリンクの場合に失敗する」O_NOFOLLOW_ANY も用意しています。
もちろん、誰かが docroot 内に特定のシンボリックリンクを配置できるなら、すでにかなり大きな問題があります。それでも、悪くないでしょう。
Apple 固有の挙動
リクエストタイムアウトを動作させるためには、一定時間経過した後 SIGALRM を送るために
setitimer()(システムコール #83)を使用する必要があります。デフォルトでは SIGALRM は子プロセスを殺しますが、我々はまず 408 Request Timeout メッセージを送りたいのです。
sigaction()(システムコール #46)を使います。Darwin では生の sigaction 構造体に sa_tramp フィールドが露出しています。通常、libc が sa_tramp をセットアップしてくれるので、考える必要はありません。スタックとレジスターを保存し、sigreturn を設定して、全ての手続きを行い、その後ハンドラへジャンプします。もし sa_tramp がそれをやらないなら、ハンドラが終了した時にプログラムがどこに復帰すべきか分かりません。
しかし ymawky ではタイムアウトハンドラは決して戻る必要はありません。408 Request Timeout を送り、閉じるべきものを閉じて子プロセスを終了します。決して戻らないため、_trampoline スロットを直接タイムアウト応答を行うコードへ向け、
sa_handler と sigreturn を完全にバイパスできます。
Apple はまた、よく文書化されていない
proc_info()(システムコール #336)というシステムコールを持っています。これを使って実行中のプロセスに関する情報(子プロセス含む)を取得できます。通常は ps, lsof, top などのツールで使われますが、ymawky はこの機能を有効な子プロセスの数を数えるために使用します。
ymawky には設定可能な最大接続数があり、生きてる子のプロセス数を把握する必要があります。
proc_info() は子プロセス情報をバッファーに書き込みます。各要素は既知のサイズなので、サーバーは書かれたバイト数を見て子のプロセス数を決定できます。MAX_PROCS を超えれば、新しい接続は 503 Service Unavailable で拒否されます。
Yay!
結論
誰もがもっとアセンブリを書くべきです。セキュリティなんてどうでもいい?使いやすさはどうでもいい?4,000 行のアセンブリを書く代わりに 100 行の Python スクリプトを書く必要があるのか?生産的な日を過ごす代わりに、7 つ目の連続した日目に文字列パースをデバッグして 7 時間をかける方がよいのか?(ヒント:
[x3, #1] を [#x3], #1 と書いた)。
真剣に言うと、静的なウェブサーバーを書く上で最も難しい部分はソケットを開いたりリクエストを受信したりすることではありませんでした。困難なのはリクエストをパースし、あらゆるエッジケースを処理することです。全てのリクエストはバイトです。全てのパスもまたバイトです。全ての応答もまたバイトです。全ての範囲(range)は正確でなければなりません。全てのファイル名は異なる方法でエスケープする必要があります。
アセンブリはあなたにすべてを手で行うようにさせます。それが素晴らしいことではないですか?