この記事では最小構成のログインフォームを1つ用意し、送信されたパケットをWiresharkで開いて、パスワードがそのまま読める文字列で流れていることを確認します。最後に同じフォームをHTTPSに切り替え、同じ操作で中身が暗号文に変わることを見比べます。

alt text

今回確かめること!

今回は次の一点のみを確認します!

  • HTTPは通信の中身を暗号化せず、そのまま読める形で流している。HTTPSにするとその中身は暗号文になる。

一方で次のことは確認しません。

  • 送信者でも受信者でもない第三者が、他人の通信を実際に覗ける位置に立てるかどうか。

今回はキャプチャを自分のPCの内側(ループバック)で行います。送信も受信も観測も自分1台で完結しており他人の通信を盗み見る状況は再現していません。

環境

  • macOS
  • Node.jsとnpm
  • Wireshark

検証用アプリの構成

Vueで作ったフォームを1つ置き、送信先のAPIはVite開発サーバーの中に数行のミドルウェアとして同居させます。

.
├── index.html          マウント先のdivとsrc/main.jsを読み込むだけ
├── src/
│   ├── main.js         Vueアプリのマウント
│   └── App.vue         ユーザー名とパスワードのフォーム
└── vite.config.js      ポート設定と/api/loginの偽API、HTTPS切り替え

フォームの送信部分はこれだけです。

async function submit() {
  const res = await fetch("/api/login", {
    method: "POST",
    body: new URLSearchParams({ username: username.value, password: password.value }),
  });
  result.value = await res.text();
}

fetchのbodyにURLSearchParamsを渡しているのがポイントです。こうするとContent-Typeがapplication/x-www-form-urlencodedになり、送信される中身はusername=alice&password=himitsu1234のような素朴なキーと値の並びになります。Wiresharkはこの形式を認識してキーと値に分解して表示してくれるので、平文であることが一目で分かります。

送信先の/api/loginは、ViteのconfigureServerで受け止めて、受け取った内容をそのままJSONで返します。

function fakeLoginApi() {
  return {
    name: "fake-login-api",
    configureServer(server) {
      server.middlewares.use("/api/login", (req, res) => {
        let body = "";
        req.on("data", (chunk) => (body += chunk));
        req.on("end", () => {
          res.setHeader("Content-Type", "application/json; charset=utf-8");
          res.end(JSON.stringify({ ok: true, received: Object.fromEntries(new URLSearchParams(body)) }));
        });
      });
    },
  };
}

HTTPとHTTPSはViteのモードで切り替えます。HTTPSのときだけ@vitejs/plugin-basic-sslで自己署名証明書を有効にし、ポートも分けます。

export default defineConfig(({ mode }) => {
  const https = mode === "https";
  return {
    plugins: [vue(), fakeLoginApi(), https && basicSsl()],
    server: { port: https ? 8443 : 8080, strictPort: true },
  };
});

後で使うnpm run dev:httpsは、vite --mode httpsを呼ぶだけのnpmスクリプトです。

portを8080番に

Viteのポートを8080番に固定しています。Wiresharkはポート番号を手がかりにパケットをHTTPとして解釈しますが、その自動対象に5173番は含まれていません。5173番のまま進めると、パケット自体は流れているのにhttpフィルタに何も出てこなくなります。80番や8080番は自動対象に入っているらしいのでHTTPとして素直に展開してくれます。strictPortは、8080番が埋まっていたときに別ポートへ勝手に逃げる挙動を止めるためのものです。

HTTPで平文が見えることを確認する

1つだけ注意があります。キャプチャを先に始めてから、フォームを送信します。

  1. Wiresharkを起動します。最初の画面に出るインターフェース一覧からLoopback: lo0をダブルクリックしてキャプチャを開始します。一覧の上にある緑色の入力欄(キャプチャフィルタ)には何も入れません。
  2. キャプチャが始まると画面上部に表示フィルタの欄が現れます。そこへ次の式を半角で入力してEnterを押します。
http.request.method == "POST"
  1. ターミナルで依存を入れて開発サーバーを起動し、ブラウザでhttp://localhost:8080を開きます。
npm install
npm run dev
  1. フォームのログインボタンを押します。ユーザー名とパスワードには初期値が入っているので、そのままで構いません。
  2. WiresharkにPOST /api/loginの行が1つ現れます。
    alt text
    その行を選び、下部の詳細ペインでHTML Form URL Encodedの項目を展開します。usernameとpasswordが値ごと並び、パスワードのhimitsu1234がそのまま読めます。
    alt text
  3. 全体を1画面で見たいときは、その行を右クリックしてFollow(日本語表示では追跡)からHTTP Streamを開きます。リクエストとレスポンスの生のテキストが上下に並びます。

HTTP Streamで見えるのは、おおむね次のような中身です。

POST /api/login HTTP/1.1
Host: localhost:8080
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
Content-Length: 35
Origin: http://localhost:8080

username=alice&password=himitsu1234

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"ok":true,"received":{"username":"alice","password":"himitsu1234"}}

リクエストの最後の行に、入力したユーザー名とパスワードがそのまま乗っています。暗号化も難読化もされていません。HTTPは中身をこの姿のまま運んでいます。

HTTPSで同じことをして見比べる

次に、同じフォームをHTTPSで送って、同じ操作で中身がどう変わるかを見ます。

  1. Wiresharkの表示フィルタを次に変えます。
tls
  1. HTTPSモードで開発サーバーを起動し、ブラウザでhttps://localhost:8443を開きます。自己署名証明書なので警告が出ます。詳細を開いて続行します。
npm run dev:https
  1. 同じフォームでログインボタンを押します。
  2. WiresharkにはApplication Dataと表示されたパケットが並びます。
    alt text
    中身を展開しても、表示されるのは暗号化されたバイト列で、himitsu1234はもちろんpasswordという語も見つかりません。
    alt text
  3. その行を右クリックしてFollowからTCP Streamを開いても同じです。読める文字はTLSのやり取りに含まれるlocalhost程度で、フォームの中身は現れません。

HTTPでまる見えだったパスワードが、HTTPSでは暗号文になって読めなくなりました。フォームもブラウザも操作も同じで、変えたのは通信路の暗号化だけです。

ターミナルだけで確かめる場合

GUIを開かずに、フォームの値だけを抜き出す方法もあります。lo0を対象に、POSTのフォーム項目だけを表示します。

tshark -i lo0 -Y 'http.request.method == "POST"' -T fields -e urlencoded-form.key -e urlencoded-form.value

HTTPモードの開発サーバーを動かしたままフォームを送ると、usernameとpasswordのキーと値が1行で出力されます。パスワードが平文で取り出せることが、ここでも確認できます。

alt text

最後に

HTTPが通信の中身を暗号化せずに運んでいることを確認できました。フォームのパスワードは送信された瞬間のパケットにhimitsu1234という読める文字列で載っていました。HTTPSに切り替えると同じパスワードは暗号文になって取り出せなくなりました。

この検証はループバックで行ったので、第三者が実際にその通信を覗ける位置に立てるかどうかは示していません。ただしパケットはPCを出たあと、ルーターやWiFiの親機やISPといった経路上の装置を必ず通ります。HTTPである限り、そこを通るときの中身も今回見たものと同じ平文です。