VxWorksの脆弱性「URGENT11」のテクニカルホワイトペーパーを読む①

はじめに

7月末にVxWorks脆弱性発見が公開されました。

armis.com

日本語関連記事

VxWorksは組込み機器では非常に有名なRTOSで、20億以上のデバイスに搭載されています。 今回の脆弱性URGENT11で影響を受けるデバイスは2億個以上であると報告されています。

脆弱性を報告したARMISが公開している上のページでは、医療機器やルータで任意コードを実行するエクスプロイトデモ動画が掲載されています。

URGENT11では11個の脆弱性が報告されています。Wind Riverセキュリティアドバイザリによると、その中の3つは、CVEのレーティングが9.8と非常に脅威度が高いものとなっています。

URGENT11 テクニカルホワイトペーパー

本記事では、ARMISが公開している脆弱性のテクニカルホワイトペーパーを読み、自分なりにまとめてみます。 今回は、レーティングが9.8のもののうち、リモート (LAN内) から任意コード実行が可能なCVE-2019-12256Stack overflow in the parsing of IPv4 packets’ IP optionsに関する部分を読んでみます。

ホワイトペーパーに掲載されているコードスニペットでは一部解析できない部分があり、コードスニペットの解析では、一部推測が混じっています。

最終的には、「C言語で安全なコード書くの大変!」というお話になります。 ZenやRustのような配列 (またはスライス) が配列要素数情報を持っている言語であれば、(少なくとも)DoSまでは脅威度が下げられたように見えます。

URGETN11のホワイトペーパーではスタックオーバーフローと記載されていますが、スタックバッファオーバーフローなのではないかと疑っています。記事内では元のホワイトペーパーのままスタックオーバーフローとしています。

URGETN11

過去に発見されたTCP/IPスタックの脆弱性と同様のものが、ソースコードがクローズドなRTOSにもないかどうか、を研究しています。 研究にあたっては、ダウンロード可能なファームウェア (デバッグ情報付きのELF) を逆コンパイルしている、とあります。

概要

複数のSRR (Source Record Route) オプションを含む不正なIPパケットを送信すると、攻撃者が意図的にスタックオーバーフローを起こすことができ、任意コードが実行できます。これは適切な長さチェックを行わずに、SRRオプションをスタック上に確保したバッファにコピーすることに起因しています。

バックグラウンド情報

SRR (Source Record Route)

ja.wikipedia.org

ソース・ルーティングとは、ネットワーク通信において、データの送信者が送信先のみでなく中継地点をも指定する経路制御方式のことである。

通ってきたルート情報をオプション内に記録していきます。オプションヘッダが3バイトあり、その後ろに、通ってきたルートのIPv4アドレスが記録されます。

記録できるルート情報は最大9件です。IPv4のオプションフィールドが (固定部分を除き) 最大で40バイトなので、オプションヘッダ (3バイト) + ルート情報 (4バイト×9 = 36バイト) まで、ということなのでしょう。

ICMPエラーパケット

IPパケットを処理している間にエラー状態になった場合 (不正なIPパケットを受け取った場合) 、ICMPエラーパケットを返信します。この場合、不正なIPパケットとは、送信先に到達できないパケットである不正なオプションフィールドを含んでいる、などが挙げられます。

ICMPエラーパケットには、不正なIPパケットのコピーが含まれる場合が多いです。

はい、嫌な予感がしますね!

脆弱性

1つのIPパケットに複数のSRRオプションが含まれている状態は、エラーとして検出されます。しかし、脆弱性のあるVxWorksの不正IPパケット検出ロジックでは、そのエラーを検出する前に別のエラーを検出した場合、複数のSRRオプションが含まれていることを認識しないまま、不正IPパケットをコピーしてICMPエラーパケットを作成します。

ICMPエラーパケットとして確保されているオプションフィールドは、スタックに確保されている40バイトです。この領域に対して、最大で40バイトの大きさになるSRRオプションを複数回コピーしてしまい、スタックオーバーフローが発生します。

コード

ファームウェアのバイナリから逆コンパイルしたコードスニペットがホワイトペーパー内に掲載されています。

不正なIPパケットを受け取った場合、下のipnet_icmp4_send関数からICMPエラーパケットを送信します。

int ipnet_icmp4_send(Ipnet_icmp_param *icmp_param, Ip_bool is_igmp)
{
    Ipnet_icmp_param *icmp_param;
    Ipcom_pkt *failing_pkt;
    struct Ipnet_copyopts_param options_to_copy;
    // スタックに確保された40バイトの配列
    struct Ipnet_ip4_sock_opts opts;
    // ...
    // 問題のオプションをコピーする関数を呼び出す
    ipnet_icmp4_copyopts(icmp_param, &options_to_copy, &opts, &ip4_info);
    // ...
}

Ipnet_ip4_sock_opts構造体の定義が掲載されていないので詳細は不明ですが、ホワイトペーパー内では、これは40バイトの配列である、と解説されています。

int ipnet_icmp4_copyopts(Ipnet_icmp_param *icmp_param,
                         struct Ipnet_copyopts_param *copyopts_param,
                         struct Ipnet_ip4_sock_opts *opts, void *ip4_info)
{
// ...
    while ( 1 ) {
        current_opt = ipnet_ip4_get_ip_opt_next( /* ... */ );
        // ...
            if ( opt_type == 0x83 || opt_type == 0x89 ) {
                // IPオプションがSRR (LSRRもしくはSSRR)
                srr_ptr_offset = 39;
                srr_opt = (srr_opt_t *)&opts->opts[opts->len];
                // ポインタオフセットは最大で39バイト目までしか指さないようにしているが、
                // このオフセットが現在のオプション内で有効であるかどうか、は検証されていない
                if ( (int)current_opt[2] <= 39 )
                    srr_ptr_offset = current_opt[2];
                offset_to_current_route_entry = srr_ptr_offset - 5;
                // ...
                // オプション内に記録されているIPアドレスを逆順に1つずつコピーする。
                while ( offset_to_current_route_entry > 0 ) {
                    memcpy((char *)srr_opt + srr_opt->length, current_route_entry, 4);
                    current_route_entry -= 4;
                    offset_to_current_route_entry -= 4;
                    srr_opt->length += 4;
                }
                // 最新 (自身) のルート情報を追加する
                memcpy((char *)srr_opt + srr_opt->length, &icmp_param->to, 4);
                srr_opt->length += 4;
                total_opts_len = opts->len + srr_opt->length;
            }
        }
    }
...
}

SRRオプションは次のデータ構造になっており、lengthはオプションのバイト数で、pointerroute dataのオフセットです (オプション開始位置から数えるので、4から始まります) 。pointer (コード中のsrr_ptr_offset) はlength以下でなければならないはずですが、上記コードではそれがチェックされていません。

Loose Source and Record Route
+--------+--------+--------+---------//--------+
|    0x83| length | pointer| route data |
+--------+--------+--------+---------//--------+

脆弱性をつくには、次のようなIPオプションフィールドを送信します。

type length pointer type length pointer
0x83 3 0x27 (39) 0x83 3 0x27 (39)

これでルート情報が欠落しているLSRRオプションが2連続で続くことになります。実際にはルート情報はありませんが、pointer0x27 (39)を指しているので、9番目のルート情報が格納されている位置から逆順に、ルート情報をコピーしようとします

このとき、コピー先はmemcpy((char *)srr_opt + srr_opt->length, current_route_entry, 4);から、srr_optです。srr_optは、40バイトの配列であるoptsどこかを指すポインタです。opts->lenを更新するコードが掲載されていないのですが、一番外側のwhileループが回るごとにオプションの長さ分加算されていると推測できます。

srr_opt = (srr_opt_t *)&opts->opts[opts->len];

上述の脆弱性をつくオプションフィールドをwhile文で1つずつ処理し、2つ目のオプションを処理する際には、opts->len340になっていると考えられます (オプションフィールドのlengthで更新していれば3、コピーしたオプションフィールドの長さで更新していれば40) 。そこを起点に、40バイトしか確保していない領域に、さらに最大で40バイトのコピーが発生します。

ここでコピーされる内容は、オプションフィールドに続く任意のデータです

ということで、上位に戻って、ipnet_icmp4_sendoptsを突き抜けてデータを書き込まれ、スタック上のリターンアドレスが、書き込まれた任意コードの先頭アドレスに書き変われば攻撃成功、なはずです。

int ipnet_icmp4_send(Ipnet_icmp_param *icmp_param, Ip_bool is_igmp)
{
    Ipnet_icmp_param *icmp_param;
    Ipcom_pkt *failing_pkt;
    struct Ipnet_copyopts_param options_to_copy;
    // スタックに確保された40バイトの配列
    struct Ipnet_ip4_sock_opts opts;

新しいプログラミング言語なら?

一番の直接的な問題点は、pointerオフセットが有効なlengthの範囲内であるかどうか検証していないこと、でしょう。ここはロジックレベルの実装ミスであり、プログラミング言語レベルでは防ぎようがありません。

int ipnet_icmp4_copyopts( /* ... */ )
{
// ...
                srr_ptr_offset = 39;
                srr_opt = (srr_opt_t *)&opts->opts[opts->len];
                // ポインタオフセットは最大で39バイト目までしか指さないようにしているが、
                // このオフセットが現在のオプション内であるかどうか、は検証されていない
                if ( (int)current_opt[2] <= 39 )
                    srr_ptr_offset = current_opt[2];

最終防衛線は、optsが確保しているメモリの範囲外アクセスを検出することです。

// ...
                srr_opt = (srr_opt_t *)&opts->opts[opts->len];
// ...
                    memcpy((char *)srr_opt + srr_opt->length, current_route_entry, 4);
// ...

ZenやRustのような言語であれば、opts->optssrr_opt配列要素数を型の一部として持つ配列 (へのポインタ) 、もしくは、スライスになります。このような言語であればopts->opts[opts->len]で範囲外アクセスを行った場合や、スライスのコピー操作により範囲外アクセスを防ぐことが可能です。

どちらの言語でもunsafeC言語のmemcpyと同等のコピー関数がありますが、そのような関数を使うと、もちろんC言語と同じ結果になります。

次のコードは、かなり問題を単純化した例をZenで書いたものです。

const std = @import("std");

fn copy_opts(opts: *[]u8) void {
    const length: usize = 38;
    // `opts`の38番目から42番目の要素を指すスライスを取得して、書き換えようとする
    var entry = opts.*[length..length + 4];
    std.mem.copy(u8, entry, [_]u8{ 1, 2, 3, 4 });
}

pub fn main() void {
    var opts: [40]u8 = [_]u8{0} ** 40;
    copy_opts(&opts[0..]);
}

このコードは、パニックで停止します。copy_opts関数の引数optsが40個の要素しか持たないことがわかっているため、それを超える範囲のスライスを作ろうとすると、実行時にパニックが発生します。

index out of bounds
main.zen:5:23: 0x2250b6 in copy_opts (main)
    var entry = opts.*[length..length + 4];
                      ^

お次はRustで書いたものです。

fn copy_opts(opts: &mut [u8]) {
    let length: usize = 38;
    // `opts`の38番目から42番目の要素を指すスライスを取得して、書き換えようとする
    let entry = &mut opts[length..length+4];
    entry.copy_from_slice(&[1, 2, 3, 4]);
}

fn main() {
    let mut opts: [u8; 40] = [0; 40];
    copy_opts(&mut opts);
}

このコードも、パニックで停止します。

thread 'main' panicked at 'index 42 out of range for slice of length 40', src/libcore/slice/mod.rs:2555:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

パニック発生時にパニックを捕捉してハンドリングすることも可能です。

マルウェアを送り込まれた上で動作し続けることに比べると、システムが定義されたパニック状態に陥る方が、かなり被害が軽減できるでしょう。 このような最終防衛線がプログラミング言語レベルで用意されていることが、いかに大切かわかりますね!