This post is also available in: English (英語)
概要
このところ、私はLinuxカーネルのパケットソケットのソースコードの監査に時間を注いでいました。これは、Linuxカーネルのメモリ破損の脆弱性であるCVE-2020-14386の発見につながりました。このような脆弱性を利用することにより、Linuxシステムで、権限のないユーザーの権限をルートユーザーの権限に昇格できます。本稿では、この脆弱性の技術的解説、その悪用方法、そしてパロアルトネットワークスのお客様がどのように保護されているかについて説明します。
数年前、パケットソケットでいくつかの脆弱性が発見され(CVE-2017-7308およびCVE-2016-8655)、その主要な機能の概要を示した、Project ZeroのブログやOpenwallなどの記事が公開されています。
具体的にいえば、この脆弱性をトリガーするには、カーネルでAF_PACKETソケットが有効になっていること(CONFIG_PACKET=y)にくわえ、トリガーとなるプロセスにCAP_NET_RAW権限がなければいけません。CAP_NET_RAW権限は、ユーザー名前空間が有効で(CONFIG_USER_NS=y)、権限のないユーザーからもアクセス可能であれば、権限のないユーザーの名前空間でも取得できます。驚くことに、これらの制約は、Ubuntuなどの一部のディストリビューションではデフォルトで満たされています。
パロアルトネットワークスのCortex XDRをご利用中のお客様はこの不具合の悪用を阻止できます。これには、一連のイベント全体にわたり悪意のある振る舞いを監視し、攻撃検出後ただちに中断させるBehavioral Threat Protection (BTP) (振る舞いベースの脅威防御)機能と、Local Privilege Escalation Protection (ローカル権限昇格防御)モジュールが組み合わせて使われます。
技術的詳細
(このセクションのすべてのコードの図はバージョン5.7のカーネルソースからのものです。)
AF_PACKETソケットの実装についてはProject Zeroブログで詳しく説明していますので、ここではそちらの記事で解説済みの内容(フレームとブロックの関係など)については割愛し、さっそく脆弱性とその根本原因の説明に入りたいと思います。
この不具合は、メモリの破損につながる演算の問題に端を発しています。この演算の問題は、net/packet/af_packet.c内のtpacket_rcv関数にあります。
この演算の不具合は2008年7月19日に、コミット8913336 (“packet: add PACKET_RESERVE sockopt”)で導入されました。ただし、メモリ破損を引き起こすようなトリガー可能になったのは、2016年2月のコミット58d19b19cd99 (“packet: vnet_hdr support for tpacket_rcv“)からです。2017年5月のコミットbcc536 (“net/packet: fix overflow in check for tp_reserve”)や、2017年8月のコミットedb58be (“packet: Don’t write vnet header beyond end of buffer”)などで何度か修正が試みられましたが、いずれもメモリ破損を十分防ぐことができませんでした。
まず、PACKET_RESERVEオプションから見ていきましょう。この脆弱性をトリガーするには、rawソケット(domain に AF_PACKET、type に SOCK_RAWを指定)でリングバッファにTPACKET_V2を指定し、PACKET_RESERVEオプションに特定の値を指定して作成する必要があります。
このマニュアルで言及されている「additional headroom(パケット前の追加領域)」は単にユーザーが指定したサイズのバッファのことで、この割り当てが行われた後でリングバッファの各パケットの実データが受信されます。この値はユーザー空間からsetsockoptシステムコールで設定できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
case PACKET_RESERVE: { unsigned int val; if (optlen != sizeof(val)) return -EINVAL; if (copy_from_user(&val, optval, sizeof(val))) return -EFAULT; if (val > INT_MAX) return -EINVAL; lock_sock(sk); if (po->rx_ring.pg_vec || po->tx_ring.pg_vec) { ret = -EBUSY; } else { po->tp_reserve = val; ret = 0; } release_sock(sk); return ret; } |
図1. setsockopt – PACKET_RESERVEの実装
図1に示すように、最初に、値がINT_MAXより小さいかどうかのチェックがあります。このチェックは、packet_set_ringでの最小フレームサイズの計算でのオーバーフローを防止するために、このパッチで追加されたものです。その後、受信/送信リングバッファにページが割り当てられていないことが確認されます。これは、tp_reserveフィールドとリングバッファ自体の間で矛盾が発生しないように実行されます。
tp_reserveに値を設定後、optnameにPACKET_RX_RINGを指定したsetsockoptシステムコールにより、リングバッファ自体の割り当てをトリガーできます。
Create a memory-mapped ring buffer for asynchronous packet reception.(非同期でのパケット受信用のメモリマップされたリングバッファを作成する。) |
図2. マニュアルの packet – PACKET_RX_RINGオプションから
これはpacket_set_ring関数に実装されています。最初に、リングバッファ割り当て前に、ユーザー空間から受け取ったtpacket_req構造体に対し、いくつかの演算チェックが実行されます。
1 2 3 4 5 |
min_frame_size = po->tp_hdrlen + po->tp_reserve; … … if (unlikely(req->tp_frame_size < min_frame_size)) goto out; |
図3. packet_set_ring関数のサニティチェックの一部
図3に示すように、まず最小フレームサイズが計算され、次にユーザー空間から受け取った値と比較して検証されます。このチェックにより、tpacketヘッダー構造体(対応するバージョンに対する)およびtp_reserveのバイト数に対する十分なスペースが各フレームにあることが確認されます。
その後、すべてのサニティチェックの実行後、alloc_pg_vecへのコールにより、リングバッファ自体が割り当てられます。
1 2 |
order = get_order(req->tp_block_size); pg_vec = alloc_pg_vec(req, order); |
図4.packet_set_ring関数内でのリングバッファ割り当て関数の呼び出し
上の図に示すように、ブロックサイズはユーザー空間から制御されます。alloc_pg_vec関数はpg_vec配列を割り当ててから、alloc_one_pg_vec_page関数により各ブロックを割り当てます。
1 2 3 4 5 6 7 8 9 10 |
static struct pgv *alloc_pg_vec(struct tpacket_req *req, int order) { unsigned int block_nr = req->tp_block_nr; struct pgv *pg_vec; int i; pg_vec = kcalloc(block_nr, sizeof(struct pgv), GFP_KERNEL | __GFP_NOWARN); if (unlikely(!pg_vec)) goto out; for (i = 0; i < block_nr; i++) { pg_vec[i].buffer = alloc_one_pg_vec_page(order); |
図5. alloc_pg_vecの実装
alloc_one_pg_vec_page関数は、ブロックページを割り当てるために__get_free_pagesを使用します。
1 2 3 4 5 6 7 8 |
static char *alloc_one_pg_vec_page(unsigned long order) { char *buffer; gfp_t gfp_flags = GFP_KERNEL | __GFP_COMP | __GFP_ZERO | __GFP_NOWARN | __GFP_NORETRY; buffer = (char *) __get_free_pages(gfp_flags, order); if (buffer) return buffer; |
図6. alloc_one_pg_vec_pageの実装
ブロックの割り当て後、pg_vec配列が、ソケットを表すpacket_sock構造体に埋め込まれているpacket_ring_buffer構造体に保存されます。
インターフェイス上でパケットを受信すると、tpacket_rcv関数にバインドされているソケットが呼び出され、パケットデータがTPACKETメタデータとともに、リングバッファに書き込まれます。tcpdumpなどの実際の用途では、このバッファはユーザー空間にmmapされ、そこからパケットデータを読み出すことができます。
不具合
次は、tpacket_rcv関数の実装(図7)を詳しく見ていきましょう。まず、受信したパケットのネットワークヘッダーのオフセットをmaclenに抽出するために、skb_network_offsetが呼び出されます。この例では、サイズはEthernetヘッダーのサイズである14バイトです。その後、TPACKETヘッダー(バージョンごとに固定)、maclen、およびtp_reserve値(ユーザー制御)を考慮して、netoff(フレーム内のネットワークヘッダーのオフセットを表す)が計算されます。
ただし、tp_reserveの型はunsigned int、netoffの型はunsigned shortで、(前述のとおり)tp_reserveの値に対する唯一の制約はINT_MAXより小さいことであるため、この計算はオーバーフローすることがあります。
1 2 3 4 5 6 7 8 9 10 11 12 |
if (sk->sk_type == SOCK_DGRAM) { … else { unsigned int maclen = skb_network_offset(skb); netoff = TPACKET_ALIGN(po->tp_hdrlen + (maclen < 16 ? 16 : maclen)) + po->tp_reserve; if (po->has_vnet_hdr) { netoff += sizeof(struct virtio_net_hdr); do_vnet = true; } macoff = netoff – maclen; } |
図7. tpacket_rcv内の演算
図7に示すように、ソケットでPACKET_VNET_HDRオプションが設定されていれば、Ethernetヘッダーのすぐ後ろに位置するvirtio_net_hdr構造体に対応するために、sizeof(struct virtio_net_hdr)がソケットに追加されます。最後に、Ethernetヘッダーのオフセットが計算され、macoffに保存されます。
以下の図8に示すように、この関数ではこの後、virtio_net_hdr_from_skb関数を使用して、virtio_net_hdr構造体がリングバッファに書き込まれます。図8では、h.rawは、(alloc_pg_vecで割り当てられた)リングバッファで現在空いているフレームをポイントしています。
1 2 3 4 5 |
if (do_vnet && virtio_net_hdr_from_skb(skb, h.raw + macoff – sizeof(struct virtio_net_hdr), vio_le(), true, 0)) goto drop_n_account; |
図8.tpacket_rcv内のvirtio_net_hdr_from_skb関数への呼び出し
最初は、macoffがブロックのサイズより大きい値を受け取れ(アンダーフローから)、バッファの境界を越えて書き込めるように、オーバーフローを使用することによりnetoffを小さい値にできるのではないかと考えました。
しかし、これは以下のチェックにより阻まれます。
1 2 3 4 5 6 7 8 9 |
if (po->tp_version <= TPACKET_V2) { if (macoff + snaplen > po->rx_ring.frame_size) { … … snaplen = po->rx_ring.frame_size – macoff; if ((int)snaplen < 0) { snaplen = 0; do_vnet = false; } } |
図9.tpacket_rcv関数内の別の演算チェック
netoffをオーバーフローさせることによりまだmacoffをより小さい整数値にすることができるため、このチェックでは十分にメモリ破損を防ぐことができません。具体的には、macoffを、10バイトであるsizeof(struct virtio_net_hdr)より小さくし、virtio_net_hdr_from_skbを使用することにより、バッファの境界の後ろに書き込むことができます。
プリミティブ
macoffの値を制御することにより、最大でリングバッファの10バイト後ろまで、制御されたオフセット内のvirtio_net_hdr構造体を初期化できます。virtio_net_hdr_from_skb関数では、まず構造体全体をゼロにしてから、skb構造体に基づいて、構造体内のいくつかのフィールドを初期化します。
1 2 3 4 5 6 7 8 9 10 11 |
static inline int virtio_net_hdr_from_skb(const struct sk_buff *skb, struct virtio_net_hdr *hdr, bool little_endian, bool has_data_valid, int vlan_hlen) { memset(hdr, 0, sizeof(*hdr)); /* no info leak */ if (skb_is_gso(skb)) { … if (skb->ip_summed == CHECKSUM_PARTIAL) { … |
図10.virtio_net_hdr_from_skb関数の実装
ただし、構造体にゼロだけが書き込まれるようにskbを設定できます。これにより、__get_free_pages割り当ての後ろの1~10バイトをゼロにすることが可能になります。ヒープ操作を何も行わなくても、カーネルクラッシュがただちに発生します。
PoC
この脆弱性をトリガーするためのPoCコードについては、こちらのOpenwallのスレッドを参照してください。
パッチ
この不具合を修正するために、以下のパッチを提案しました。
考え方としては、netoffの型をunsigned shortからunsigned intに変更すれば、USHRT_MAXを超えるかどうかをチェックできるので、超えた場合はパケットをドロップし、それ以上の処理を防止できるというものです。
エクスプロイト方法について
この脆弱性を悪用する方法として、プリミティブをuse-after-free(解放済みメモリ使用)に変換することが考えられます。このために、いずれかのオブジェクトの参照カウントをデクリメントします。たとえば、オブジェクトのrefcount値が0x10001の場合、破損後は以下のようになります。
以下の図13に示すように、破損後のrefcount値は0x1になるので、1つ参照をreleaseするとオブジェクトが解放されます。
ただし、これが行われるためには、以下の制約が満たされる必要があります。
- refcountがオブジェクトの最後の1~10バイトに位置していなければなりません。
- オブジェクトをページの最後に割り当てられる必要があります。
- これは、get_free_pagesはページアライメントされたアドレスを返すためです。
複数のgrep式を使い、コードを手動で分析して、以下のオブジェクトが得られました。
1 2 3 4 5 6 7 |
struct sctp_shared_key { struct list_head key_list; struct sctp_auth_bytes *key; refcount_t refcnt; __u16 key_id; __u8 deactivated; }; |
図13. sctp_shared_key構造体の定義
このオブジェクトは上記の制約を満たしているようです。
- 権限のないユーザーコンテキストから、sctpサーバとクライアントを作成できます。
- 具体的には、オブジェクトはsctp_auth_shkey_create関数で割り当てられます。
- オブジェクトをページの最後に割り当てることができます。
- オブジェクトのサイズは32バイトであり、kmallocにより割り当てられます。つまり、オブジェクトはkmalloc-32キャッシュに割り当てられます。
- kmalloc-32スラブ キャッシュ ページをget_free_pages割り当ての後ろに割り当てられることを確認できました。つまり、そのスラブ キャッシュ ページの最後のオブジェクトを破損させることができます。
- 4096 % 32 = 0であるため、スラブページの最後には予備のスペースはなく、最後のオブジェクトはget_free_pages割り当ての直後に割り当てられます。4096 % 96 != 0であるため、96バイトなどのその他のスラブ キャッシュ サイズも適していません。
- refcntフィールドの最上位2バイトを破損させることができます。
- コンパイル後、key_idとdeactivatedのサイズはそれぞれ4バイトです。
- この不具合を使用して9~10バイトを破損させると、refcntフィールドの最上位バイト1~2を破損させることになります。
おわりに
Linuxカーネルにこのような単純な演算セキュリティの問題がまだ存在し、これまでに発見されていなかったことに驚きました。また、権限のないユーザー名前空間は、ローカル権限の昇格が可能な大きな攻撃対象領域を生み出すため、ディストリビューションで権限のないユーザー名前空間を有効にすべきかどうか検討する必要があります。
パロアルトネットワークスのCortex XDRは、エンドポイント上で脅威を阻止し、ネットワーク上のエンフォースメントとクラウドセキュリティを連携させることにより、サイバー攻撃の成功を阻止します。この不具合の悪用を阻止するために、Cortex XDRのBehavioral Threat Protection (BTP) (振る舞いベースの脅威防御) 機能およびLocal Privilege Escalation Protection (ローカル権限昇格防御)モジュールは、一連のイベントで悪意のある動作を監視し、攻撃を検出するとただちに攻撃を中断します。