Flatt Security Blog

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

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

CVE-2021-20181 の技術的解説

f:id:flattsecurity:20210707162932p:plain

こんにちは。株式会社Flatt Securityセキュリティエンジニアの志賀(@Ga_ryo_) です。

本記事では、最近公開されたCVE-2021-20181の技術的な解説をしていきたいと思います。本脆弱性は、自分が発見し、Zero Day Initiative を経由してベンダーに報告しました。本記事は、脆弱性の危険性を通知する目的ではなく、あくまで技術的観点での学びを共有する事を目的としています。

  • 脆弱性解説
  • PoC概要
  • 修正
  • まとめ
  • おわりに
  • 参考
  • 読む前に

    事前に言っておくと、権限昇格のExploitは書いていません。正直な話をすると結構難しそうで諦めました。また、あくまで脆弱性が発生した周辺の実装に関する技術的な学びを共有する目的なので、ご理解ください。解説に関して誤りや疑問点があれば、個人宛に連絡をいただけると幸いです。

    また、本記事においてQEMUと言った時は基本的にKVMと併せて使うものを意味しています。所謂CPUごとエミュレートする使用方法に関しては考慮していませんので、ご理解ください。

    本記事中におけるコードは基本的に以下の時点でのQEMUのソースコードを参照しています。

    qemu/qemu

    概要

    QEMUのVirtFS(Host/Guest間のFile共有)機能におけるRace Conditionの脆弱性。

    前提条件

    1. Guestマシン内での特権コード実行
    2. HostマシンによってVirtFS経由のファイル共有が行われている
    が必要です。

    影響

    少なくともHostのQEMUプロセスにおけるセグメンテーションフォルト、潜在的に権限昇格の可能性あり。

    Virtioとは

    Virtioとは、その名の通りI/Oを仮想化するための仕組みであり、その仮想化I/Oを経由してHost/Guest間でのネットワークデバイスや、ブロックデバイス、メモリバルーンデバイスなどが準仮想化デバイスとして実装されています。

    詳しい仕様は以下のサイト(スライド)が非常に分かりやすくまとめられているので、こちらを参照すると良いと思います。

    VirtFSとは

    VirtFSとは、データの受け渡しにVirtioを、プロトコルに9Pを利用した準仮想化ファイルシステムインターフェースです。9P自体は非常にシンプルなプロトコルになっていて、WSL(2は分かりませんが)でも使われていたようです。

    http://ericvh.github.io/9p-rfc/

    QEMU Coroutine

    各種スレッド

    この脆弱性を理解する上で、QEMUのスレッドモデルの理解が必要ですので簡単に説明します。

    まず、QEMUはGuestマシンにおけるvCPUの数だけスレッドを作成します。この辺りのコードを詳細に追いかけるのは、以下のブログがよくまとまっているので参考にすると良いと思います。

    試しに-smp 2のオプションを付けてKVMを起動し、GDBからQEMUプロセスをデバッグしてみます。

    
    (gdb) info threads
      Id   Target Id         Frame
    * 1    Thread 0x7f6876efdb00 (LWP 24964) "qemu-system-x86" 0x00007f6875655cf6 in __GI_ppoll (fds=0x1d4ed80, nfds=5, timeout=, sigmask=0x0) at ../sysdeps/unix/sysv/linux/ppoll.c:39
      2    Thread 0x7f68752ce700 (LWP 24965) "qemu-system-x86" syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
      3    Thread 0x7f6874acd700 (LWP 24968) "qemu-system-x86" 0x00007f68756575d7 in ioctl () at ../sysdeps/unix/syscall-template.S:78
      4    Thread 0x7f686ffff700 (LWP 24969) "qemu-system-x86" 0x00007f68756575d7 in ioctl () at ../sysdeps/unix/syscall-template.S:78
    (gdb) bt
    #0  0x00007f6875655cf6 in __GI_ppoll (fds=0x1d4ed80, nfds=5, timeout=, sigmask=0x0) at ../sysdeps/unix/sysv/linux/ppoll.c:39
    #1  0x00000000009e400a in qemu_poll_ns (fds=0x1d4ed80, nfds=5, timeout=1000000000) at util/qemu-timer.c:348
    #2  0x00000000009e5052 in os_host_main_loop_wait (timeout=1000000000) at util/main-loop.c:237
    #3  0x00000000009e5175 in main_loop_wait (nonblocking=0) at util/main-loop.c:518
    #4  0x00000000005fad58 in main_loop () at vl.c:1810
    #5  0x0000000000602256 in main (argc=17, argv=0x7fff7895fe88, envp=0x7fff7895ff18) at vl.c:4471
    (gdb) thread 2
    [Switching to thread 2 (Thread 0x7f68752ce700 (LWP 24965))]
    #0  syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
    38	../sysdeps/unix/sysv/linux/x86_64/syscall.S: No such file or directory.
    (gdb) bt
    #0  syscall () at ../sysdeps/unix/sysv/linux/x86_64/syscall.S:38
    #1  0x00000000009ea44e in qemu_futex_wait (f=0x1401378 , val=4294967295) at /home/garyo/work/qemu-4.2.0/include/qemu/futex.h:29
    #2  0x00000000009ea615 in qemu_event_wait (ev=0x1401378 ) at util/qemu-thread-posix.c:459
    #3  0x0000000000a045b5 in call_rcu_thread (opaque=0x0) at util/rcu.c:260
    #4  0x00000000009ea7c8 in qemu_thread_start (args=0x1bb2ff0) at util/qemu-thread-posix.c:519
    #5  0x00007f68759396db in start_thread (arg=0x7f68752ce700) at pthread_create.c:463
    #6  0x00007f687566288f in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95
    (gdb) thread 3
    [Switching to thread 3 (Thread 0x7f6874acd700 (LWP 24968))]
    #0  0x00007f68756575d7 in ioctl () at ../sysdeps/unix/syscall-template.S:78
    78	../sysdeps/unix/syscall-template.S: No such file or directory.
    (gdb) bt
    #0  0x00007f68756575d7 in ioctl () at ../sysdeps/unix/syscall-template.S:78
    #1  0x000000000048f19e in kvm_vcpu_ioctl (cpu=0x1d019e0, type=44672) at /home/garyo/work/qemu-4.2.0-nopie/accel/kvm/kvm-all.c:2473
    #2  0x000000000048ea77 in kvm_cpu_exec (cpu=0x1d019e0) at /home/garyo/work/qemu-4.2.0-nopie/accel/kvm/kvm-all.c:2310
    #3  0x00000000004644f3 in qemu_kvm_cpu_thread_fn (arg=0x1d019e0) at /home/garyo/work/qemu-4.2.0-nopie/cpus.c:1318
    #4  0x00000000009ea7c8 in qemu_thread_start (args=0x1d2a4f0) at util/qemu-thread-posix.c:519
    #5  0x00007f68759396db in start_thread (arg=0x7f6874acd700) at pthread_create.c:463
    #6  0x00007f687566288f in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95
    (gdb) thread 4
    [Switching to thread 4 (Thread 0x7f686ffff700 (LWP 24969))]
    #0  0x00007f68756575d7 in ioctl () at ../sysdeps/unix/syscall-template.S:78
    78	in ../sysdeps/unix/syscall-template.S
    (gdb) bt
    #0  0x00007f68756575d7 in ioctl () at ../sysdeps/unix/syscall-template.S:78
    #1  0x000000000048f19e in kvm_vcpu_ioctl (cpu=0x1d547d0, type=44672) at /home/garyo/work/qemu-4.2.0-nopie/accel/kvm/kvm-all.c:2473
    #2  0x000000000048ea77 in kvm_cpu_exec (cpu=0x1d547d0) at /home/garyo/work/qemu-4.2.0-nopie/accel/kvm/kvm-all.c:2310
    #3  0x00000000004644f3 in qemu_kvm_cpu_thread_fn (arg=0x1d547d0) at /home/garyo/work/qemu-4.2.0-nopie/cpus.c:1318
    #4  0x00000000009ea7c8 in qemu_thread_start (args=0x1d7c660) at util/qemu-thread-posix.c:519
    #5  0x00007f68759396db in start_thread (arg=0x7f686ffff700) at pthread_create.c:463
    #6  0x00007f687566288f in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95
    
    

    スレッド3,4がvCPUを実行しているthreadであることが分かります。

    スレッド1が所謂メインスレッドになっており、前述のブログにて紹介されているように、/dev/kvmからのイベントをハンドリングするようになっています。スレッド2はRCU周りの内部イベントのハンドラのようですが、今回は重要ではないので省略します。

    今回の脆弱性を理解する上で最も大切なスレッドは実はまだ起動していません。それはCoroutineの中断(yield)時に用いられるワーカースレッドです。Coroutine自体はQEMU独自の機能では無いので、説明は省略しますが、一般にイベント駆動のプログラミングにおいてcallback hellを避けたりするためのものです。以下の資料を参考にすると良いでしょう。

    メッセージハンドラの呼ばれ方

    先述の通り、QEMUにおいてメインスレッドは/dev/kvmからのイベントを待ちます。VirtioのHostへの通知はioeventfdという機能(PIO/MMIOをeventfdに変換)を通じてメインスレッドへのイベントを発生させるため、Virtioの機能は基本的にメインスレッドで実行されるということになります。

    詳しくみていきます。

    まず最初のイベント発行部分を見てみましょう。起点はGuestマシン側のVirtioデバイス(PCIデバイス)へのI/Oによる例外(VMExit)となるので、Virtioへ実際にデータを流そうとしたvCPUに対応するスレッド(上記のGDBの出力だとthread3か4)で以下のコードが実行されます。

    int kvm_cpu_exec(CPUState *cpu)
    {
        struct kvm_run *run = cpu->kvm_run;
        int ret, run_ret;
        ......
        do {
            ......
            run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
            ......
            switch (run->exit_reason) {
            case KVM_EXIT_IO:
                DPRINTF("handle_io\n");
                /* Called outside BQL */
                kvm_handle_io(run->io.port, attrs,
                                (uint8_t *)run + run->io.data_offset,
                                run->io.direction,
                                run->io.size,
                                run->io.count);
                ret = 0;
                break;
            case KVM_EXIT_MMIO:
                DPRINTF("handle_mmio\n");
                /* Called outside BQL */
                address_space_rw(&address_space_memory,
                                    run->mmio.phys_addr, attrs,
                                    run->mmio.data,
                                    run->mmio.len,
                                    run->mmio.is_write);
                ret = 0;
                break;
            ......
            }
        } while (ret == 0);
        ......
        return ret;
    }
    
    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/accel/kvm/kvm-all.c#L2307-L2460

    諸々のPCIデバイスの初期化処理が正常に完了した場合、以下のstep7のようにDRIVER_OKをSTATUSに書き込む事になります。

    該当コードは上記の記事のようにI/O Spaceへの書き込みであれば、以下のようになって、virtio_pci_start_ioeventfd()関数が呼び出されます。

    static void virtio_ioport_write(void *opaque, uint32_t addr, uint32_t val)
    {
        VirtIOPCIProxy *proxy = opaque;
        VirtIODevice *vdev = virtio_bus_get_device(&proxy->bus);
        hwaddr pa;
    
        switch (addr) {
        ......
        case VIRTIO_PCI_STATUS:
            if (!(val & VIRTIO_CONFIG_S_DRIVER_OK)) {
                virtio_pci_stop_ioeventfd(proxy);
            }
    
            virtio_set_status(vdev, val & 0xFF);
    
            if (val & VIRTIO_CONFIG_S_DRIVER_OK) {
                virtio_pci_start_ioeventfd(proxy);
            }
        ......
        }
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/virtio/virtio-pci.c#L295-L371

    ただし、自分の検証していた環境では少なくともPIOではなくてMMIOのハンドラからSTATUSを書き換えていました。具体的には、vCPUのスレッドでKVM_EXIT_MMIOでexitしており、address_space_rwの書き込みハンドラとしてvirtio_pci_common_writeが呼ばれて、ここのSTATUSにDRIVER_OKを書き込むことでvirtio_pci_start_ioeventfd()関数が呼び出されるという流れになっているようでした。どうやら、PCIデバイスがlegacyなのかmodernなのかによってここは変わっているようでした(深くは追いかけていません)。

    static void virtio_pci_common_write(void *opaque, hwaddr addr,
                                        uint64_t val, unsigned size)
    {
        VirtIOPCIProxy *proxy = opaque;
        VirtIODevice *vdev = virtio_bus_get_device(&proxy->bus);
    
        switch (addr) {
        ......
        case VIRTIO_PCI_COMMON_STATUS:
            if (!(val & VIRTIO_CONFIG_S_DRIVER_OK)) {
                virtio_pci_stop_ioeventfd(proxy);
            }
    
            virtio_set_status(vdev, val & 0xFF);
    
            if (val & VIRTIO_CONFIG_S_DRIVER_OK) {
                virtio_pci_start_ioeventfd(proxy);
            }
            ......
        }
    }
    

    そして、virtio_pci_start_ioeventfd()virtio_bus_start_ioeventfd()virtio_device_start_ioeventfd_impl()と呼び出されて、以下のコードによってイベント通知用fd(host_notifierが持つfd)に対してハンドラを登録していきます。

    static int virtio_device_start_ioeventfd_impl(VirtIODevice *vdev)
    {
        ......
        for (n = 0; n < VIRTIO_QUEUE_MAX; n++) {
            VirtQueue *vq = &vdev->vq[n];
            if (!virtio_queue_get_num(vdev, n)) {
                continue;
            }
            r = virtio_bus_set_host_notifier(qbus, n, true);
            if (r < 0) {
                err = r;
                goto assign_error;
            }
            event_notifier_set_handler(&vq->host_notifier,
                                        virtio_queue_host_notifier_read);
        }
        ......
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/virtio/virtio.c#L3687-L3739

    最終的にmemory_region_add_eventfdが呼び出されて、eventfdをmemory 領域に紐付けます。これで、このMMIO領域(virtio-pci-notify)への書き込み時にはeventfdへの通知が走るようになるようです。

    
    (gdb) thread
    [Current thread is 4 (Thread 0x7f6965957700 (LWP 6043))]
    (gdb) bt 20
    #0  virtio_pci_ioeventfd_assign (d=0x395fb00, notifier=0x7f46c921e088, n=0, assign=true) at hw/virtio/virtio-pci.c:231
    #1  0x00000000007ff450 in virtio_bus_set_host_notifier (bus=0x3967bf8, n=0, assign=true) at hw/virtio/virtio-bus.c:282
    #2  0x0000000000512868 in virtio_device_start_ioeventfd_impl (vdev=0x3967c70) at /home/garyo/work/qemu-4.2.0-nopie/hw/virtio/virtio.c:3586
    #3  0x00000000007ff16a in virtio_bus_start_ioeventfd (bus=0x3967bf8) at hw/virtio/virtio-bus.c:222
    #4  0x0000000000801513 in virtio_pci_start_ioeventfd (proxy=0x395fb00) at hw/virtio/virtio-pci.c:287
    #5  0x0000000000803976 in virtio_pci_common_write (opaque=0x395fb00, addr=20, val=15, size=1) at hw/virtio/virtio-pci.c:1244
    #6  0x00000000004732f7 in memory_region_write_accessor (mr=0x39604d0, addr=20, value=0x7f46c67ce838, size=1, shift=0, mask=255, attrs=...) at /home/garyo/work/qemu-4.2.0-nopie/memory.c:483
    #7  0x00000000004734de in access_with_adjusted_size (addr=20, value=0x7f46c67ce838, size=1, access_size_min=1, access_size_max=4, access_fn=0x473237 , mr=0x39604d0, attrs=...) at /home/garyo/work/qemu-4.2.0-nopie/memory.c:544
    #8  0x0000000000476488 in memory_region_dispatch_write (mr=0x39604d0, addr=20, data=15, op=MO_8, attrs=...) at /home/garyo/work/qemu-4.2.0-nopie/memory.c:1475
    #9  0x0000000000414941 in flatview_write_continue (fv=0x7f46b80e7870, addr=4261412884, attrs=..., buf=0x7f46c940b028 "\017", len=1, addr1=20, l=1, mr=0x39604d0) at /home/garyo/work/qemu-4.2.0-nopie/exec.c:3129
    #10 0x0000000000414a86 in flatview_write (fv=0x7f46b80e7870, addr=4261412884, attrs=..., buf=0x7f46c940b028 "\017", len=1) at /home/garyo/work/qemu-4.2.0-nopie/exec.c:3169
    #11 0x0000000000414dd3 in address_space_write (as=0x13d2fc0 , addr=4261412884, attrs=..., buf=0x7f46c940b028 "\017", len=1) at /home/garyo/work/qemu-4.2.0-nopie/exec.c:3259
    #12 0x0000000000414e40 in address_space_rw (as=0x13d2fc0 , addr=4261412884, attrs=..., buf=0x7f46c940b028 "\017", len=1, is_write=true) at /home/garyo/work/qemu-4.2.0-nopie/exec.c:3269
    #13 0x000000000048ec27 in kvm_cpu_exec (cpu=0x2cd57d0) at /home/garyo/work/qemu-4.2.0-nopie/accel/kvm/kvm-all.c:2360
    #14 0x00000000004644f3 in qemu_kvm_cpu_thread_fn (arg=0x2cd57d0) at /home/garyo/work/qemu-4.2.0-nopie/cpus.c:1318
    #15 0x00000000009ea7c8 in qemu_thread_start (args=0x2cfd660) at util/qemu-thread-posix.c:519
    #16 0x00007f46c7e3c6db in start_thread (arg=0x7f46c67cf700) at pthread_create.c:463
    #17 0x00007f46c7b6588f in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95
    
    

    メインスレッドではイベントを常にpollしているので、通知用のvirtio_queue_host_notifier_read()とVirtFSのメッセージハンドラ(v9fs_version)がメインスレッドで実行されるのを確認してみます。Thread1なので、確かにメインスレッドがVirtioの通知のハンドラ実行に用いられているようです。

    
    (gdb) b *virtio_queue_host_notifier_read
    Breakpoint 1 at 0x5120c5: file /home/garyo/work/qemu-4.2.0-nopie/hw/virtio/virtio.c, line 3429.
    (gdb) b v9fs_version
    Breakpoint 1 at 0x62931a: file hw/9pfs/9p.c, line 1344.
    (gdb) c
    Continuing.
    
    Thread 1 "qemu-system-x86" hit Breakpoint 1, virtio_queue_host_notifier_read (n=0x222f854)
        at /home/garyo/work/qemu-4.2.0-nopie/hw/virtio/virtio.c:3429
    3429	{
    (gdb) bt
    #0  virtio_queue_host_notifier_read (n=0x222f854) at /home/garyo/work/qemu-4.2.0-nopie/hw/virtio/virtio.c:3429
    #1  0x00000000009e653e in aio_dispatch_handlers (ctx=0x222f7b0) at util/aio-posix.c:429
    #2  0x00000000009e66d1 in aio_dispatch (ctx=0x222f7b0) at util/aio-posix.c:460
    #3  0x00000000009e1d88 in aio_ctx_dispatch (source=0x222f7b0, callback=0x0, user_data=0x0) at util/async.c:260
    #4  0x00007fc821fd0417 in g_main_context_dispatch () from /usr/lib/x86_64-linux-gnu/libglib-2.0.so.0
    #5  0x00000000009e4ff6 in glib_pollfds_poll () at util/main-loop.c:219
    #6  0x00000000009e5070 in os_host_main_loop_wait (timeout=1000000000) at util/main-loop.c:242
    #7  0x00000000009e5175 in main_loop_wait (nonblocking=0) at util/main-loop.c:518
    #8  0x00000000005fad58 in main_loop () at vl.c:1810
    #9  0x0000000000602256 in main (argc=17, argv=0x7ffe93e388c8, envp=0x7ffe93e38958) at vl.c:4471
    (gdb) c
    Continuing.
    
    Thread 1 "qemu-system-x86" hit Breakpoint 1, v9fs_version (opaque=0x3d11e60) at hw/9pfs/9p.c:1344
    (gdb)
    
    

    Coroutineの利用

    先述の通り、9Pプロトコルにおける各種メッセージのハンドラはメインスレッドで実行されます。しかし、ブロックデバイスのI/O操作などは同期的に実行してしまうとメインスレッドのパフォーマンスに大きな影響を及ぼしてしまいます。そこでQEMUではCoroutineを利用し、時間のかかりそうなシステムコールなどは一旦ワーカースレッドにタスクを渡して、メインスレッドでは関数を中断(yield)するようになっています。

    詳しくみていきます。

    先ほどのようにメインスレッドで実行されるハンドラに対してブレークポイントを貼ってみると、バックトレースがおかしくなっている事が分かります。

    
    (gdb) b v9fs_version
    Breakpoint 1 at 0x62931a: file hw/9pfs/9p.c, line 1344.
    (gdb) c
    Continuing.
    
    Thread 1 "qemu-system-x86" hit Breakpoint 1, v9fs_version (opaque=0x3d11e60) at hw/9pfs/9p.c:1344
    1344	{
    (gdb) bt
    #0  v9fs_version (opaque=0x3d11e60) at hw/9pfs/9p.c:1344
    #1  0x0000000000a06c95 in coroutine_trampoline (i0=61299360, i1=0) at util/coroutine-ucontext.c:115
    #2  0x00007f2f640ff6b0 in ?? () from /lib/x86_64-linux-gnu/libc.so.6
    #3  0x00007fff3cff3b00 in ?? ()
    #4  0x0000000000000000 in ?? ()
    
    

    v9fs_version()関数を見てみると、どうやらcoroutine_fnという値がついていて、この関数はcoroutineで呼ばれているっぽいことが分かります。

    static void coroutine_fn v9fs_version(void *opaque)
    
    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L1343

    先ほどのバックトレースを見れば分かりますが、どうやらucontextによるコンテキスト切り替えでCoroutineを実装しているようです(Windowsの場合はFiberを使っている)。

    呼び出し元のpdu_submit()関数を見ると、Coroutineの実行を開始するような処理が入っているため、ここからCoroutineとして関数が動くという事がわかります。

    void pdu_submit(V9fsPDU *pdu, P9MsgHeader *hdr)
    {
        Coroutine *co;
        CoroutineEntry *handler;
        V9fsState *s = pdu->s;
    
        pdu->size = le32_to_cpu(hdr->size_le);
        pdu->id = hdr->id;
        pdu->tag = le16_to_cpu(hdr->tag_le);
    
        if (pdu->id >= ARRAY_SIZE(pdu_co_handlers) ||
            (pdu_co_handlers[pdu->id] == NULL)) {
            handler = v9fs_op_not_supp;
        } else if (is_ro_export(&s->ctx) && !is_read_only_op(pdu)) {
            handler = v9fs_fs_ro;
        } else {
            handler = pdu_co_handlers[pdu->id];
        }
    
        qemu_co_queue_init(&pdu->complete);
        co = qemu_coroutine_create(handler, pdu);
        qemu_coroutine_enter(co);
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L3998-L4020

    次に、Coroutineを中断している処理を見てみましょう。v9fs_version()は初期化処理くらいしかされないので、実際にファイルシステムにアクセスするv9fs_read()などを参照します。

    static void coroutine_fn v9fs_read(void *opaque)
    {
        ......
        if (fidp->fid_type == P9_FID_DIR) {
            ......
        } else if (fidp->fid_type == P9_FID_FILE) {
            ......
                do {
                    len = v9fs_co_preadv(pdu, fidp, qiov.iov, qiov.niov, off);
                    if (len >= 0) {
                        off   += len;
                        count += len;
                    }
                } while (len == -EINTR && !pdu->cancelled);
                if (len < 0) {
                    /* IO error return the error */
                    err = len;
                    goto out_free_iovec;
                }
            } while (count < max_count && len > 0);
            ......
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L2238-L2323

    ファイルであればv9fs_co_preadv()を呼び出すようになっています。この関数を見てみると、v9fs_co_run_in_worker()というマクロを利用している事がわかります。

    int coroutine_fn v9fs_co_preadv(V9fsPDU *pdu, V9fsFidState *fidp,
                                    struct iovec *iov, int iovcnt, int64_t offset)
    {
        int err;
        V9fsState *s = pdu->s;
    
        if (v9fs_request_cancelled(pdu)) {
            return -EINTR;
        }
        fsdev_co_throttle_request(s->ctx.fst, false, iov, iovcnt);
        v9fs_co_run_in_worker(
            {
                err = s->ops->preadv(&s->ctx, &fidp->fs, iov, iovcnt, offset);
                if (err < 0) {
                    err = -errno;
                }
            });
        return err;
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/cofile.c#L262-L280

    /*
    * we want to use bottom half because we want to make sure the below
    * sequence of events.
    *
    *   1. Yield the coroutine in the QEMU thread.
    *   2. Submit the coroutine to a worker thread.
    *   3. Enter the coroutine in the worker thread.
    * we cannot swap step 1 and 2, because that would imply worker thread
    * can enter coroutine while step1 is still running
    */
    #define v9fs_co_run_in_worker(code_block)                               \
        do {                                                                \
            QEMUBH *co_bh;                                                  \
            co_bh = qemu_bh_new(co_run_in_worker_bh,                        \
                                qemu_coroutine_self());                     \
            qemu_bh_schedule(co_bh);                                        \
            /*                                                              \
             * yield in qemu thread and re-enter back                       \
             * in worker thread                                             \
             */                                                             \
            qemu_coroutine_yield();                                         \
            qemu_bh_delete(co_bh);                                          \
            code_block;                                                     \
            /* re-enter back to qemu thread */                              \
            qemu_coroutine_yield();                                         \
        } while (0)
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/coth.h#L22-L47

    このv9fs_co_run_in_worker()というマクロに、「時間のかかる処理をワーカースレッドに渡して中断する」という処理が入っています。v9fs_co_run_in_worker()マクロの実装及びコメントをみてみると、以下のようなことを行っていると分かります。

    1. co_run_in_worker_bh(qemu_coroutine_self())をスケジュールする(3. で実行される)
    2. qemu_coroutine_yield()によってこのCoroutineを中断する(Coroutine呼び出し元に戻る)
    3. 1.でスケジュールされた関数を実行し、このCoroutineの継続処理をワーカースレッドに任せる
    4. ワーカースレッドはcode_blockを実行し、Coroutineを再度中断する
    5. メインスレッドはCoroutineを再開する

    4.で中断した後は、co_run_in_worker_bh()がそもそもワーカー側の処理が終了した時にメインスレッド側で呼ぶcallback関数を登録していて、その関数(coroutine_enter_cb())が再度Coroutineの処理を継続するようになっているようです。

    VirtFSにおけるファイル共有

    脆弱性の解説に入る前に、最後にVirtFSがファイルを共有する際の具体的な構造と機能について解説します。

    V9fsFidState構造体

    VirtFSでは、Host/Guest間で共有するファイルごとにGuest側でfidを、Host側でV9fsFidState構造体を保持します。これは一般的なLinux上のプロセスにおける、fdに対するkernel上のfile構造体のような関係性と理解すれば良いです。これは共有フォルダごとに存在するV9fsState構造体の下に片方向リストとして繋がっています。

    以下に構造体のメンバーとイメージを示します。

    struct V9fsFidState
    {
        int fid_type;
        int32_t fid;
        V9fsPath path;
        V9fsFidOpenState fs;
        V9fsFidOpenState fs_reclaim;
        int flags;
        int open_flags;
        uid_t uid;
        int ref;
        int clunked;
        V9fsFidState *next;
        V9fsFidState *rclm_lst;
    };
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.h#L234-L248

    f:id:flattsecurity:20210220210835p:plain

    reclaim

    VirtFSにおいて、共有するファイルをGuest側からオープンする際には当然Host側、つまりQEMUプロセスからもオープンされます。この時、Guestから大量のオープンをリクエストするとどうなるでしょうか。当然QEMUプロセスも保持できるファイル記述子の数が限られているため、どこかでファイルを開けなくなってしまいます。その問題に対処するために、VirtFSではreclaimという機能が備わっています。

    int coroutine_fn v9fs_co_open(V9fsPDU *pdu, V9fsFidState *fidp, int flags)
    {
        ......
        if (!err) {
            total_open_fd++;
            if (total_open_fd > open_fd_hw) {
                v9fs_reclaim_fd(pdu);
            }
        }
        return err;
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/cofile.c#L98-L124

    open_fd_hwという値を超えてファイルをオープンした場合、開きすぎと判断してv9fs_reclaim_fd()が呼び出され、可能な限りのfdをcloseし、fdの値として不正な値(-1)を代入しています。

    void coroutine_fn v9fs_reclaim_fd(V9fsPDU *pdu)
    {
        int reclaim_count = 0;
        V9fsState *s = pdu->s;
        ......
        for (f = s->fid_list; f; f = f->next) {
            ......
            if (f->fid_type == P9_FID_FILE) {
                if (f->fs.fd != -1) {
                    ......
                    f->ref++;
                    f->rclm_lst = reclaim_list;
                    reclaim_list = f;
                    f->fs_reclaim.fd = f->fs.fd;
                    f->fs.fd = -1;
                    reclaim_count++;
                }
            }......
        }
        ......
        while (reclaim_list) {
            f = reclaim_list;
            reclaim_list = f->rclm_lst;
            if (f->fid_type == P9_FID_FILE) {
                v9fs_co_close(pdu, &f->fs_reclaim);
            }......
        }
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L419-L498

    f:id:flattsecurity:20210220210903p:plain

    unreclaim

    上記reclaimを行うとfdが不正な値になるため、ファイルの読み書きは失敗してしまいます。そこでreclaim済みのファイルに対して読み書きなどが発生した時にはQEMUプロセス側で再オープンする処理が走ります。(get_fid()関数呼び出し時にv9fs_reopen_fid()関数が呼ばれ、fd==-1の場合にopenを呼ぶ)

    static V9fsFidState *coroutine_fn get_fid(V9fsPDU *pdu, int32_t fid)
    {
        ......
        for (f = s->fid_list; f; f = f->next) {
            ......
            if (f->fid == fid) {
                ......
                err = v9fs_reopen_fid(pdu, f);
                if (err < 0) {
                    f->ref--;
                    return NULL;
                }
                ......
                return f;
            }
        }
        return NULL;
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L256-L291

    static int coroutine_fn v9fs_reopen_fid(V9fsPDU *pdu, V9fsFidState *f)
    {
        int err = 1;
        if (f->fid_type == P9_FID_FILE) {
            if (f->fs.fd == -1) {
                do {
                    err = v9fs_co_open(pdu, f, f->open_flags);
                } while (err == -EINTR && !pdu->cancelled);
            }
        }......
        return err;
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L237-L254

    f:id:flattsecurity:20210220210942p:plain

    unreclaim処理は、ファイルを削除する時にも走ることがあります。Linuxにおいてunlink(ファイル削除)は全てのファイル記述子が閉じられた際に実際のファイル削除処理が走ります。これはunlinkシステムコールを実行しても既に開いてあるファイル記述子は有効であることを意味します。つまり、同一ファイルを複数回オープンしていて、いずれかが既にreclaimされている場合、ファイル記述子は既に閉じられているため、この状態でunlinkシステムコールを実行すると参照しているfidが存在するにも関わらずファイルを削除してしまうという事になります。この状態ではreopenによるunreclaimの失敗(ファイルは削除済み)、または後から同一パスに配置された別のファイルを参照してしまう事になりかねません。

    そのため、このような状況でreclaimされていない方のfidを指定して削除リクエストを送信すると、以下のようにunlink前にv9fs_mark_fids_unreclaim()関数が呼び出され、同一パスのファイルを全て再オープンする処理が走ります。

    static void coroutine_fn v9fs_remove(void *opaque)
    {
        ......
        /*
            * IF the file is unlinked, we cannot reopen
            * the file later. So don't reclaim fd
            */
        err = v9fs_mark_fids_unreclaim(pdu, &fidp->path);
        if (err < 0) {
            goto out_err;
        }
        err = v9fs_co_remove(pdu, &fidp->path);
        ......
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L2960-L3002

    v9fs_mark_fids_unreclaim()関数では、片方向リストを辿り、ファイル名が一致する場合にv9fs_reopen_fid()関数によって再オープンしています。

    static int coroutine_fn v9fs_mark_fids_unreclaim(V9fsPDU *pdu, V9fsPath *path)
    {
        int err;
        V9fsState *s = pdu->s;
        V9fsFidState *fidp, head_fid;
    
        head_fid.next = s->fid_list;
        for (fidp = s->fid_list; fidp; fidp = fidp->next) {
            if (fidp->path.size != path->size) {
                continue;
            }
            if (!memcmp(fidp->path.data, path->data, path->size)) {
                /* Mark the fid non reclaimable. */
                fidp->flags |= FID_NON_RECLAIMABLE;
    
                /* reopen the file/dir if already closed */
                err = v9fs_reopen_fid(pdu, fidp);
                if (err < 0) {
                    return err;
                }
                /*
                    * Go back to head of fid list because
                    * the list could have got updated when
                    * switched to the worker thread
                    */
                if (err == 0) {
                    fidp = &head_fid;
                }
            }
        }
        return 0;
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L500-L531

    f:id:flattsecurity:20210220211038p:plain

    clunk

    最後にclunkという機能を紹介します。

    これは実質closeシステムコール相当のリクエストと考えれば良いです。Host/Guest間でのファイル共有によって開いたファイルは当然不要になった際に破棄する必要があります。そうでなければメモリは解放できませんし、永遠に片方向リストが繋がってしまい、リストを辿る時間が長くなってしまうからです。

    このclunkでは、V9fsFidState構造体をリンクリストから外す処理(unlink)が走ります。

    f:id:flattsecurity:20210220211128p:plain

    当然ですが、リストの先頭をclunkすることも可能です。

    f:id:flattsecurity:20210220211347p:plain

    脆弱性解説

    前提知識を全て解説したので、ここから実際の脆弱性を解説します。

    実際の脆弱性は、先ほど説明したv9fs_mark_fids_unreclaim()関数に存在します。

    static int coroutine_fn v9fs_mark_fids_unreclaim(V9fsPDU *pdu, V9fsPath *path)
    {
        int err;
        V9fsState *s = pdu->s;
        V9fsFidState *fidp, head_fid;
    
        head_fid.next = s->fid_list;
        for (fidp = s->fid_list; fidp; fidp = fidp->next) {
            if (fidp->path.size != path->size) {
                continue;
            }
            if (!memcmp(fidp->path.data, path->data, path->size)) {
                /* Mark the fid non reclaimable. */
                fidp->flags |= FID_NON_RECLAIMABLE;
    
                /* reopen the file/dir if already closed */
                err = v9fs_reopen_fid(pdu, fidp);
                if (err < 0) {
                    return err;
                }
                /*
                    * Go back to head of fid list because
                    * the list could have got updated when
                    * switched to the worker thread
                    */
                if (err == 0) {
                    fidp = &head_fid;
                }
            }
        }
        return 0;
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/9p.c#L500-L531

    v9fs_reopen_fid()関数を呼んだ後の部分が重要です。ワーカースレッドに遷移した時にfidリストが更新されている可能性があるとコメントされています。ここで、先ほどのQEMU Coroutineの話が出てきます。v9fs_reopen_fid()関数を追いかけてみると、ファイルの場合にv9fs_co_open()関数を呼び出します。v9fs_co_open()関数の中身を見てみると、先ほどのv9fs_co_run_in_worker()マクロが使用されており、ここでメインスレッドが中断する事がわかります。

    int coroutine_fn v9fs_co_open(V9fsPDU *pdu, V9fsFidState *fidp, int flags)
    {
        ......
        v9fs_co_run_in_worker(
            {
                err = s->ops->open(&s->ctx, &fidp->path, flags, &fidp->fs);
                if (err == -1) {
                    err = -errno;
                } else {
                    err = 0;
                }
            });
        ......
    }
    

    https://github.com/qemu/qemu/blob/01e38186ecb1fc6275720c5425332eed280ea93d/hw/9pfs/cofile.c#L98-L124

    つまり、v9fs_reopen_fid()関数を呼び出す際にはCoroutineは一時中断されて処理をワーカースレッドに任せるため、v9fs_co_run_in_worker()以降の行が実行される前にメインスレッドは他のイベント処理を行っている可能性があります。この時に行われる処理がリンクリストを更新する可能性があるため、v9fs_mark_fids_unreclaim()関数では再度リストを先頭から確認するようになっています。

    さて、勘の良い方は既にお気づきかもしれませんが、この先頭に戻す処理には大きな問題があります。それは、関数の実行開始時点でのfid_listをスタック上に保存してしまっている点です。

    先ほど申し上げた通り、v9fs_reopen_fid()関数の次の行が実行されている時には別のイベント処理が行われている可能性があります。そしてその処理が、リストの先頭をclunkする処理だった場合、fid_listはunlink処理によって差し変わっており、古いV9fsFidState構造体は既にfree済みである可能性があると言うわけです。

    f:id:flattsecurity:20210220211403p:plain

    PoC概要

    だいたい以下の方針でLinuxのLoadable Kernel Moduleを書いて、QEMUプロセスがクラッシュすることまでは確認しました。

    1. ファイルAを大量にオープンし、relcaimさせる。
    2. ファイルAの削除リクエストとQueueの先頭をclunkするリクエストをVirtioのリングバッファに置き、キックする(yield後、すぐにclunkさせたいため)。
    3. 同一名ファイルが大量に存在するため、reopenするたびに何度も先頭へ戻ってループを再開するようになる。そのため、そのうちclunkによるメモリの解放が完了する。
    4. QEMUプロセス上でGuest側の任意のデータに対してmalloc(sizeof(V9fsFidState))を実行してくれる機能を探して、clunkされたV9fsFidState構造体を再利用する。
    5. 再利用された次のループで任意に改変された構造体が利用される。

    f:id:flattsecurity:20210220211452p:plain

    修正

    単にリストの先頭を保存することをやめ、loopを最初から回し直す仕様にして対処したようです。

    https://git.qemu.org/?p=qemu.git;a=commitdiff;h=89fbea8737e8f7b954745a1ffc4238d377055305

    まとめ

    VirtFSの共有設定がされていない限りは攻撃経路がありませんが、VirtFS機能を用いており、Guestマシンを信用できない場合には更新をした方が良いです。

    今後も技術的に面白いものがあれば発信していきたいと思います。

    おわりに

    Flatt Securityはセキュリティ診断サービスを提供しています。

    GCP・AWS・AzureといったパブリッククラウドやFirebase・AmplifyといったmBaaSだけでなく、Webアプリケーション・スマートフォンアプリケーション・スマートフォンゲーム・ネットワーク・IoTを対象として、顧客情報の流出やデータ改ざんに繋がる脆弱性がないかセキュリティエンジニアが診断します。

    セキュリティ診断について相談したい方は、ぜひこちらからお問い合わせください。

    参考

    https://www.linux-kvm.org/page/Virtio

    https://www.linux-kvm.org/images/d/dd/KvmForum2007%24kvm_pv_drv.pdf

    https://www.slideshare.net/enukane/virtio-study

    http://syuu1228.github.io/howto_implement_hypervisor/

    http://rkx1209.hatenablog.com/entry/2016/01/01/101456

    https://bugs.launchpad.net/qemu/+bug/1911666

    https://git.qemu.org/?p=qemu.git;a=commitdiff;h=89fbea8737e8f7b954745a1ffc4238d377055305