wget https:// が通るまでの壁は6枚あった — MMX・SSE2の語彙・RTC・CA束【rustx86 ネットワーク編 #4】

Rust

連載「rustx86 ネットワーク編」: Rust + WebAssemblyのx86エミュレータをインターネットに繋ぐまでの開発記。#1はSLiRP backend「wsslirp」(Go + gVisor netstack)#2はISAバスのNE2000で1993年のDOSからping#3はPCI + RTL8029でLinuxをネットに乗せ、真犯人のx87 FPUを追った

前回、ブラウザの中のAlpine Linuxが wget http://example.com/ で本物のHTMLを引いた。残るはTLSである。httpsが通れば、このエミュレータは現代のWebに参加できる。

事前の切り分けでは、壁は2枚だと思っていた。MMX未実装(libcryptoが踏む)と、ゲストの時計が2026-01-01固定であること(証明書の有効期間検証)。

実際に越えてみたら6枚あった。しかも最後の1枚は、TLSが完全に成立した後に待っていた。

壁1: SSE2を名乗った石は、MMXも踏まれる

まず素直に叩いてみる。

~ # wget https://pocraft.net/.../image.png
[   20.78] traps: ssl_client[431] trap invalid opcode ip:b7b98dd0
           in libcrypto.so.3[179dd0,b7a63000+21f000]

#UD (無効オペコード) で即死である。落ちた番地のバイト列を実物のlibcryptoから引くと、こうだった。

$ xxd -s 0x179dc0 -l 16 usr/lib/libcrypto.so.3
00179dc0: efbc fc10 0100 0024 0f0f 73f3 38c1 ed04
                                ^^^^
                              0F 6F = movq mm, mm/m64

0F 6FMMXの movq である。うちのCPUIDはSSE2を名乗っているが、MMXビット(EDX bit 23)は立てていなかった。それでもlibcryptoは踏んでくる。

i686のABIでは「SSE2があるならMMXもある」が常識で、実際そういうCPUしか存在しない。ライブラリはMMXビットをわざわざ確認しない。名乗っていないものを使われるという、CPUID実装の落とし穴だった。

mmレジスタは x87 の別名である

MMXの実装で厄介なのは、mm0〜mm7 が x87 の物理レジスタと同じ場所だという点である。mm(i) は R(i) の仮数64bitで、MMX命令を1つ実行するたびに TOP=0・タグ全valid になり、書いたレジスタの指数部は全1になる。

前回f64で裏打ちしたx87に、そのままMMXを載せるとビットが落ちる。f64の仮数は52bitで、MMXが要求する64bitに足りない。

そこで80bitの原本をサイドバンドとして持つことにした。

pub struct Fpu {
    /// 物理レジスタ (f64裏打ち)
    pub regs: [f64; 8],
    /// 80bitの原本 (仮数, 符号+指数)。f64に落とすと壊れる値のための控えで、
    /// **MMXレジスタはここに住む** (仮数64bit・指数0xFFFF がMMXの実機表現)。
    /// x87の演算がレジスタを書いた瞬間に消え、f64が原本に戻る
    pub raw: [Option<(u64, u16)>; 8],
    pub empty: u8,
    pub top: u8,
    pub cond: u16,
}

これでMMXの値がビット同一で往復する。さらに大事なのがFXSAVE/FXRSTORで、カーネルはコンテキストスイッチのたびにここを通る。原本ごと保存しないと、スライスをまたぐたびにMMXの計算が壊れる

// FXSAVEのST域: 80bit原本ごと保存する — MMX値 (指数全1) もビット同一で残る
for i in 0..8 {
    let (mant, se) = m.cpu.fpu.st_f80(i);
    ...
}

単体テストは「f64で表せないビット列」を積んで往復させる。0xDEAD_BEEF_CAFE_BABE を movq で書き、FXSAVE→破壊→FXRSTOR して、ビット同一で戻ることを釘で打った。

壁2: 「AがあればBもある」— SSE2の語彙の歯抜け

MMXを実装して再挑戦。今度は別の番地で #UD

00179dd0: 0fc4 145e 020f ef7c c410 c1c2 080f efb4
          ^^^^
        0F C4 = pinsrw (ワードの差し込み)

pinsrw を実装。また #UD

001024f0: 0666 0f70 c0cc 660f 6e4e 0466 0f7f 0366
00102500: 0ff4 c766 0f6e 5608 660f 70c9 cc66 0f7f
          ^^^^
     66 0F F4 = pmuludq (XMM版)

もぐら叩きが始まった。ここでユーザーから鋭い指摘が入る。

もしかしてAと言う命令があればBと言う命令もあるという前提じゃね?
32BITかつHTTPS時代にあった拡張命令全部無いとダメでは?

その通りである。踏まれてから実装するやり方では、libcryptoが選ぶコードパスが変わるたびに落ち続ける。しかも暗号ライブラリは実行時にCPU機能を見てベンチの速い実装を選ぶので、どの命令を踏むかは環境で変わる

方針を変えた。命令表と実装の突き合わせ監査をして、名乗った機能の語彙を一括で埋める。

$ for op in 0x6B 0xD5 0xE1 0xE2 0xE4 0xE5 0xE8 0xE9 0xEA 0xEC 0xED 0xEE 0xF4 0xF5 0xE6 0xD0; do
    grep -q "        $op " core/src/cpu/sse.rs && echo "$op あり" || echo "$op 無し"
  done
0x6B あり
0xD5 無し   ← pmullw
0xE1 無し   ← psraw
0xE4 無し   ← pmulhuw
0xF4 無し   ← pmuludq
...

歯抜けだらけだった。整数乗算・飽和加減算・min/max・平均・シフトの一族、変換命令 (cvtpi2ps / cvttpd2dq…)、MMXとXMMを渡す橋 (movq2dq / movdq2q)、暗黙の DS:EDI へ書く maskmovq まで、まとめて実装した

これは「名乗ったものは実装する」という、このプロジェクトの原則の再確認でもあった。CPUIDで機能ビットを立てるのは約束であって、その約束は使う側の都合で好きなところから履行を求められる。

壁3: 時計が狂っていると証明書が検証できない

命令が揃ったら、エラーが変わった。

error:0A000086:SSL routines:tls_post_process_server_certificate:
certificate verify failed

これもユーザーの読みが当たっていた。

時計が狂ってるから時計もそれもなおさないとダメじゃね?

~ # date
Thu Jan  1 00:02:49 UTC 2026

ゲストの時計は2026-01-01固定である。証明書の有効期間は「この期間内か」で判定されるので、時計が嘘だと検証は通らない。

ここで設計上の緊張がある。このエミュレータのcoreは意図的に時計を読まない。

/// 起動時の日時 (既定値・固定)。**coreは自分ではホストの時計を読まない**
/// - 決定的でなければスナップショットが再現しない。同じ状態から再開したら
///   同じ時刻でなければ困る
/// - core は時計を持てない。std::time::Instant は wasm32 では動かない
const EPOCH: Time = Time { year: 2026, month: 1, day: 1, ... };

決定性はこのプロジェクトの土台で、CIは「同じイメージなら起動命令数がビット同一」を毎回検査している。ホストの時計を読み始めたら、その定規が壊れる。

折り合いは入力にすることだった。MACアドレスと同じ扱いで、電源投入時にホストが渡す。渡さなければ従来どおり固定のまま。

/// 時計をUNIX時刻 (UTC秒) に合わせる。ホストが起動時に呼ぶ入力口で、
/// core自身は時計を読まない
pub fn set_clock_unix(&mut self, secs: u64) {
    let days = (secs / 86400) as i64;
    // proleptic Gregorian (Howard Hinnant の civil_from_days)
    let z = days + 719468;
    let era = z.div_euclid(146097);
    ...
}

ブラウザ側が Date.now() / 1000 を渡す。CIは渡さないので決定的なまま。同じコードが、用途によって決定的にも実時刻にもなる。

壁4: busyboxのwgetはTLSを自分で喋らない

busyboxの wget はhttpsを外部ヘルパ ssl_client に投げる。ゲストのinitramfsにはそれが入っていなかった。

Alpineの initramfs-lts から借りて同梱する。

もの 大きさ 役割
ssl_client 13KB wgetのTLS口
libssl / libcrypto.so.3 4.5MB (gz 2.1MB) OpenSSL本体。一番重いがTLSの実体
ca-certificates.crt 179KB 信頼の根。全束のまま

initramfsが 1.4MB → 4.1MB になった。起動命令数も 580M → 770M に増える。これは意味の後退ではなく積んだ荷物の重さなので、CIの上限もその分引き直した。数字が動いたら理由を書いて定規を直す、という運用にしている。

壁5: CAの束はあるのに、置き場所が違う

同梱して、時計も直して、再挑戦。同じエラーが出た。

certificate verify failed

時計を直したのに証明書が検証できない。ここで少し悩んだ。CA束は /etc/ssl/certs/ca-certificates.crt に確かに入っている。

Alpineの実物を覗いて分かった。

$ ls -la etc/ssl/
lrwxrwxrwx  cert.pem -> certs/ca-certificates.crt

OpenSSLの既定CAファイルは /etc/ssl/cert.pem (OPENSSLDIR直下) で、certs/ に束を置いただけでは見つけてくれない。Alpineはsymlinkで両方から見えるようにしていた。

ではなぜsymlinkが消えたのか。犯人は自作のcpio書き出しスクリプトだった。

# 展開側 (--extract) のコメント:
# シンボリックリンクは実体のコピーとして展開する — Windowsでリンクを
# 作るには権限が要るし、この後の cp も実体を運ぶので同じことになる

展開時にsymlinkを実体コピーにしていて、書き出し側にはsymlinkを書く機能がそもそも無かった。だから cert.pem が(実体としても)initramfsに入っていなかった。

書き出し側に S_IFLNK エントリを実装した。

if os.path.islink(full):
    # symlinkはリンクのまま運ぶ (本文=リンク先)。実体を複製すると
    # /etc/ssl/cert.pem のようなCA束の別名で179KBが二重になる
    out = entry(out, rel, stat.S_IFLNK | 0o777, os.readlink(full).encode())
    continue

壁6: TLSは通った。そして /tmp が無かった

再挑戦。TLSハンドシェイクが成立し、証明書が検証され、HTTPSでデータが流れ始めた

そして最後にこう出た。

wget: can't open '/tmp/i.png': No such file or directory

ミニinitramfsに /tmp を作っていなかった。6枚目の壁は、暗号でもCPUでも時計でもなく、空のディレクトリ1個だった。

mkdir を1つ足して、ようやくこうなった。

~ # wget -q -O /tmp/i.png https://pocraft.net/.../wsslirp-eyecatch-s1.png \
    && head -c4 /tmp/i.png | od -An -tx1 | tr -d ' \n'
89504e47

89 50 4E 47PNGのシグネチャである。ブラウザのタブの中で動く自作エミュレータの中のLinuxが、TLSで暗号化された通信路を通して、実物の画像ファイルを取ってきた。

おまけ: 壁を越えた後の落とし穴

TLSの検証中に、16bit側が壊れていることが発覚した。ユーザーからの報告はこうだった。

16BIT側がデグレッテルから確認して

FreeDOSでパケットドライバが名乗るMACが FF:FF:FF:FF:FF:FF に化け、DHCPが沈黙していた。原因はPCIバスを足した日(#3)の見落としである。

ブラウザ版の from_disk (フロッピー起動) がPCI付きのプロファイルで機械を作っていた。その機械でNICを挿すとPCIスロット側に入り、ISAの0x300窓が閉じる。FreeDOSのパケットドライバは0x300しか叩かないので、カードが丸ごと消えた

見逃した理由がこの型の核心だった。coreのテストは16bit機のプロファイルしか見ておらず、ブラウザだけが別の機械を作っていた。 ネイティブの最小再現は最初から正しく動いていて、コアには非がなかった。不具合は「テストが見ている世界」と「利用者が触る世界」の隙間に住んでいた。

/// フロッピーから起動する機械 (ELKS / FreeDOS / test386.img)。
///
/// **PCIは積まない。** フロッピーで起動する世代の機械にPCIは無く、
/// NICはISAの0x300に挿さる — FreeDOSのパケットドライバはそこしか叩かない
pub fn pc_floppy(mb: usize) -> Self {
    Self { name: "floppy PC", has_pci: false, ..Self::pc_32bit(mb) }
}

CIで見張れないものは、見張れないと書く

この一件を受けて、CIにネットワークの関門を常設した。ただし設計に一工夫要った。

毎回のCIから本物のインターネットへ出るのは弱い。相手のサイトが落ちればこちらが赤くなるし、ICMPには権限が要るし、他人のサーバーに毎push負荷をかける。

そこで宛先を内側に畳んだ

  ping 10.0.2.2   → SLiRPのゲートウェイ宛。wsslirpのnetstackが自分で答える
                    (外にもICMPの権限にも依存しない)
  http://<host>   → CIジョブが立てた自分のHTTPサーバ

ゲストが通る道 (NE2000/RTL8029 → WebSocket → netstack) は実物と同じなので、「NICが見えるか・DHCPが通るか・ICMPが往復するか・TCPが流れるか・ゲストの時計が実時間か」の回帰はそのまま守れる。GitHub Actionsのランナー上で、1993年のFreeDOSと現代のAlpine Linuxが同じ仮想NICから線に出る。

ホストの番地: 10.1.0.224:8199 (ゲストはここへ取りに行く)
### 16bit (FreeDOS + mTCP)
ping: 10.0.2.2 から応答が返った
### 32bit (Linux + udhcpc/wget)
✓ DHCPでアドレス取得 (10.0.2.15)
✓ pingの間隔は実時間 (3発 4.4s)
✓ http://10.1.0.224:8199/ から中身が引けた

ただしTLSだけはここで見張れない。閉じた世界に信頼できる証明書は置けないからで、自己署名を足せばCA束が本番と別物になり検証の意味が変わる。

だから、そう書いた。

TLS (https) はここでは見ない — 閉じた世界に信頼できる証明書が無い。
実インターネット向けの手動E2Eが持つ。自動で見張れていない穴として明記しておく。

見張れていないものを「見張れていない」と書いておくのは、緑のCIが与える安心の解像度を上げる作業だと思っている。

まとめ

TLSの壁は6枚あった。

  1. MMX — SSE2を名乗った時点で、MMXビットを見ずに踏まれる
  2. SSE2の語彙の歯抜け — もぐら叩きをやめ、命令表と突き合わせて一括で埋める
  3. RTCの実時刻 — 決定性を壊さずに、入力として注入する
  4. ssl_client + OpenSSL + CA束 — 4.5MBを同梱し、起動の代価を測って定規を引き直す
  5. CAの置き場所/etc/ssl/cert.pem。symlinkがcpioの書き出しから落ちていた
  6. /tmp が無い — 暗号でもCPUでもない、空のディレクトリ1個

面白いのは、6枚のうちCPUの話は2枚だけだったことである。残りは時計・ファイル配置・ディレクトリという、地味な現実の側にあった。エミュレータを「動く」から「使える」に持っていく作業は、命令セットの実装よりも、こういう積み木を1つずつ揃えることの方が多いのかもしれない。

この時点をタグとして切ってある: 32bit-network。zipを落として START.command をダブルクリックすれば、同じものが手元で動く(OSイメージは同梱していないので、付属のスクリプトで取得する)。

次回

ネットワークは通った。残るは本測定である。WebSocket (TCP) の上にゲストのTCPを流しているので、TCP over TCP の病理が出るはずで、その破綻の仕方とMTUの押し出し、レイテンシの内訳を測りたい。ゲストがLinuxのフルスタックなので、最初から本気の道具で測れる。


連載: #1 wsslirp (Go + gVisor netstack) / #2 ISAバスのNE2000で1993年のDOSからping / #3 PCI + RTL8029 と、真犯人のx87 FPU / #4 (この記事) / #5 装置を速くしても0.6%だった — 犯人はNE2000の受信リング

リポジトリ: rustx86 / wsslirp

コメント

タイトルとURLをコピーしました