BPF を使って HTTP/2 フレームをダンプする

最近は [BPF](https://www.kernel.org/doc/Documentation/networking/filter.txt) に興味があって色々調べたりしています。先日、その流れで [BPF Compiler Collection (BCC)](https://github.com/iovisor/bcc) を眺めていたら、[sslsnif](https://github.com/iovisor/bcc/blob/master/tools/sslsniff.py) というツールが含まれているのに気が付きました。これは OpenSSL や GnuTLS の関数呼び出しをキャプチャして、TLS 通信の内容をダンプして表示するものです。

この sslsnif の仕組みを応用すれば、プログラムに一切の変更を加えることなく、様々なサーバーやクライアントで HTTP/2 フレームをダンプできるのではと考えました。そこで今回は BPF を使って HTTP/2 フレームをダンプできるかを検証します。

# セットアップ

今回は Ubuntu 16.04 上に BCC をインストールし、Python を使って HTTP/2 フレームをダンプするツールを実装します。Python をフロントエンドに選択したのは、高品質な HTTP/2 ライブラリである [hyper-h2](https://github.com/python-hyper/hyper-h2) があるためです。セットアップはまず、以下のように BCC のインストールをします。

```bash
$ echo "deb [trusted=yes] https://repo.iovisor.org/apt/xenial xenial-nightly main" | sudo tee /etc/apt/sources.list.d/iovisor.list
$ sudo apt-get update
$ sudo apt-get install -y bcc-tools
```

BCC がインストールできたので、続いて hyper-h2 に含まれる HTTP/2 フレームライブラリ hyperframe をインストールします。

```bash
$ sudo apt-get install -y python-pip
$ sudo pip install hyperframe
```

最後に動作確認用に [nginx](https://nginx.org/en/) をインストールしておきます。インストール後に HTTP/2 が有効になるように事前に設定をしておきます。

```bash
$ sudo apt-get install -y nginx
```

また、比較用に [H2O](https://h2o.examp1e.net/) もインストールしておきます。

```bash
$ sudo apt-get install -y git cmake build-essential autoconf pkg-config libyaml-dev zlib1g-dev
$ curl -L -O https://github.com/h2o/h2o/archive/v2.0.4.tar.gz
$ tar zxvf v2.0.4.tar.gz
$ cd h2o-2.0.4
$ cmake -DWITH_BUNDLED_SSL=on .
$ make
$ sudo make install
```

H2O は設定ファイルを適当に用意して、起動しておきます。H2O の設定方法は[公式ドキュメント](https://h2o.examp1e.net/configure.html)などを参考にしてください。

```bash
$ sudo h2o -m daemon -c /etc/h2o/h2o.conf
```

# 実装

環境のセットアップが完了したら、BCC を使ってプログラムを実装します。BCC を使用すれば Python のプログラム内で C で書かれたコードを BPF にコンパイルして実行できます。

今回の実装では、BPF を使用して OpenSSL や GnuTLS の関数をキャプチャし、その結果を Python のプログラムで受け取って、HTTP/2 フレームとしてデコードして表示する方法をとります。以下のコードが今回実装したコードです。

```python:h2sniff.py
#!/usr/bin/python

# This code is modified version of sslsniff.
# https://github.com/iovisor/bcc/blob/master/tools/sslsniff.py
#
# Licensed under the Apache License, Version 2.0 (the "License")

from __future__ import print_function
import ctypes as ct
from bcc import BPF
import time
import argparse
import hyperframe.frame

# BPF code
BPF_CODE = """
#include <linux/ptrace.h>
#include <linux/sched.h>

struct probe_SSL_data_t {
  u32 len;
  char buf[500];
};

BPF_PERF_OUTPUT(perf_SSL_write);
BPF_PERF_OUTPUT(perf_SSL_read);

BPF_HASH(bufs, u32, u64);

int probe_SSL_write(struct pt_regs *ctx, void *ssl, void *buf, int num) {
  u32 pid = bpf_get_current_pid_tgid();
  FILTER

  struct probe_SSL_data_t __data = {0};
  __data.len = num;

  if (buf != 0) {
    bpf_probe_read(&__data.buf, sizeof(__data.buf), buf);
  }

  perf_SSL_write.perf_submit(ctx, &__data, sizeof(__data));

  return 0;
}

int probe_SSL_read_enter(struct pt_regs *ctx, void *ssl, void *buf, int num) {
  u32 pid = bpf_get_current_pid_tgid();
  FILTER

  bufs.update(&pid, (u64*)&buf);
  return 0;
}

int probe_SSL_read_exit(struct pt_regs *ctx, void *ssl, void *buf, int num) {
  u32 pid = bpf_get_current_pid_tgid();
  FILTER

  u64 *bufp = bufs.lookup(&pid);
  if (bufp == 0) {
    return 0;
  }

  struct probe_SSL_data_t __data = {0};
  __data.len = PT_REGS_RC(ctx);

  if (bufp != 0) {
    bpf_probe_read(&__data.buf, sizeof(__data.buf), (char *)*bufp);
  }

  bufs.delete(&pid);

  perf_SSL_read.perf_submit(ctx, &__data, sizeof(__data));
  return 0;
}
"""

FRAMES = [
    "DATA",
    "HEADERS",
    "PRIORITY",
    "RST_STREAM",
    "SETTINGS",
    "PUSH_PROMISE",
    "PING",
    "GOAWAY",
    "WINDOW_UPDATE",
    "CONTINUATION",
    "ALT_SVC",   
]

MAX_BUF_SIZE = 500
TIME_START = time.time()

class Data(ct.Structure):
    _fields_ = [
        ("len", ct.c_uint),
        ("buf", ct.c_ubyte * MAX_BUF_SIZE),
    ]

def print_event_write(cpu, data, size):
    print_event(cpu, data, size, "send")

def print_event_read(cpu, data, size):
    print_event(cpu, data, size, "recv")

def print_event(cpu, data, size, rw):
    global FRAMES, TIME_START

    event = ct.cast(data, ct.POINTER(Data)).contents
    data = event.buf[0:event.len]

    if bytearray(data)[:24] == "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n":
    	data = data[24:]

    while len(data) > 9:
    	try:
    	    info = hyperframe.frame.Frame.parse_frame_header(bytearray(data)[:9])
        except:
            break

        frame = info[0]
        frame_len = info[1]

    	time_s = float(time.time() - TIME_START)

        flags = ",".join(frame.flags)

        last = 9 + frame_len 
        data = data[last:]

        if flags == "":
            print("[%-3.3f] %s %s frame <length=%d, stream_id=%d>" % (time_s, rw, FRAMES[frame.type], frame_len, frame.stream_id))
        else:
            print("[%-3.3f] %s %s frame <length=%d, stream_id=%d, flags=%s>" % (time_s, rw, FRAMES[frame.type], frame_len, frame.stream_id, flags))

examples = """examples:
    ./h2sniff                       # sniff OpenSSL and GnuTLS functions
    ./h2sniff -p 181                # sniff PID 181 only
    ./h2sniff -c curl               # sniff curl command only
    ./h2sniff -b /usr/local/bin/h2o # sniff h2o binary
    ./h2sniff --no-openssl          # don't show OpenSSL calls
    ./h2sniff --no-gnutls           # don't show GnuTLS calls
"""

parser = argparse.ArgumentParser(
    description="Sniff HTTP/2 frame",
    formatter_class=argparse.RawDescriptionHelpFormatter,
    epilog=examples)
parser.add_argument("-p", "--pid", help="sniff this PID only.")
parser.add_argument("-c", "--comm", help="sniff only commands matching string.")
parser.add_argument("-b", "--bin", help="sniff binary file.")
parser.add_argument("-o", "--no-openssl", action="store_false", dest="openssl", help="do not show OpenSSL calls.")
parser.add_argument("-g", "--no-gnutls", action="store_false", dest="gnutls", help="do not show GnuTLS calls.")
parser.add_argument('-d', '--debug', dest='debug', action='count', default=0, help='debug mode.')
args = parser.parse_args()

if args.pid:
    BPF_CODE = BPF_CODE.replace('FILTER', 'if (pid != %s) { return 0; }' % args.pid)
else:
    BPF_CODE = BPF_CODE.replace('FILTER', '')

if args.debug:
    print(BPF_CODE)

b = BPF(text=BPF_CODE)

if args.openssl:
    name = "ssl"
    if args.bin != None:
        name = args.bin

    try:
        b.attach_uprobe(name=name, sym="SSL_write", fn_name="probe_SSL_write")
        b.attach_uprobe(name=name, sym="SSL_read", fn_name="probe_SSL_read_enter")
        b.attach_uretprobe(name=name, sym="SSL_read", fn_name="probe_SSL_read_exit")
        print("OpenSSL: Enabled")
    except:
        pass

if args.gnutls:
    name = "gnutls"
    if args.bin != None:
        name = args.bin

    try:
        b.attach_uprobe(name=name, sym="gnutls_record_send", fn_name="probe_SSL_write")
        b.attach_uprobe(name=name, sym="gnutls_record_recv", fn_name="probe_SSL_read_enter")
        b.attach_uretprobe(name=name, sym="gnutls_record_recv", fn_name="probe_SSL_read_exit")
        print("GnuTLS: Enabled")
    except:
        pass

b["perf_SSL_write"].open_perf_buffer(print_event_write)
b["perf_SSL_read"].open_perf_buffer(print_event_read)

while 1:
    b.kprobe_poll()
```

# 動作確認

さっそく実装したプログラムを使用して HTTP/2 フレームをダンプしてみます。まず実装したプログラムを起動して、nginx の関数呼び出しのキャプチャを開始します。

```bash
$ sudo ./h2sniff.py -c nginx
OpenSSL: Enabled
```

この状態でブラウザや `nghttp` コマンドなどを使用して、nginx に対して HTTP/2 でリクエストを送信します。すると、以下のように HTTP/2 フレームの情報が出力されました。

```
[3.694] send SETTINGS frame <length=18, stream_id=0>
[3.695] send WINDOW_UPDATE frame <length=4, stream_id=0>
[3.697] send SETTINGS frame <length=0, stream_id=0, flags=ACK>
[3.698] send HEADERS frame <length=114, stream_id=13, flags=END_HEADERS>
[3.699] send DATA frame <length=2361, stream_id=13, flags=END_STREAM>
[3.699] recv SETTINGS frame <length=12, stream_id=0>
[3.700] recv PRIORITY frame <length=5, stream_id=3>
[3.700] recv PRIORITY frame <length=5, stream_id=5>
[3.701] recv PRIORITY frame <length=5, stream_id=7>
[3.701] recv PRIORITY frame <length=5, stream_id=9>
[3.702] recv PRIORITY frame <length=5, stream_id=11>
[3.702] recv HEADERS frame <length=41, stream_id=13, flags=END_STREAM,PRIORITY,END_HEADERS>
[3.703] recv GOAWAY frame <length=8, stream_id=0>
```

同じように、以下のようにして H2O の関数呼び出しもキャプチャしてみます。

```bash
$ sudo ./h2sniff.py -b /usr/local/bin/h2o
OpenSSL: Enabled
```

nginx と同様にこの状態でブラウザや `nghttp` コマンドなどを使用して、H2O に対して HTTP/2 でリクエストを送信すると、こちらも HTTP/2 フレームの情報が出力されました。

```
[2.342] recv SETTINGS frame <length=12, stream_id=0>
[2.343] recv PRIORITY frame <length=5, stream_id=3>
[2.344] recv PRIORITY frame <length=5, stream_id=5>
[2.345] recv PRIORITY frame <length=5, stream_id=7>
[2.345] recv PRIORITY frame <length=5, stream_id=9>
[2.346] recv PRIORITY frame <length=5, stream_id=11>
[2.346] recv HEADERS frame <length=37, stream_id=13, flags=END_STREAM,PRIORITY,END_HEADERS>
[2.347] recv GOAWAY frame <length=8, stream_id=0>
[2.347] send SETTINGS frame <length=12, stream_id=0>
[2.348] send SETTINGS frame <length=0, stream_id=0, flags=ACK>
[2.348] send HEADERS frame <length=94, stream_id=13, flags=END_HEADERS>
[2.349] send DATA frame <length=4652, stream_id=13, flags=END_STREAM>
```

以上のように、BPF を使用すれば、サーバーのプログラムを変更することなく、HTTP/2 フレームをダンプできることが分かりました。

# 問題点

BPF を使用して HTTP/2 フレームのダンプができることが分かりましたが、実装を通じてこの方法にはいくつか問題があることが分かりました。

**フレームの順序が入れ替わる**
先に示した H2O の動作確認結果をよく見ると8番目に GOAWAY フレームが出力されています。この GOAWAY フレームは実際はクライアントから最後に送信されており、本来は最後のフレームとして出力されるべきですが、そうなっていません。

これはおそらく BPF を使ったキャプチャの過程のどこかで、先に GOAWAY フレームの情報が処理されてしまい、入れ替わったものと考えられます。キャプチャデータの読み出しタイミングなどを調整する必要があるのかもしれません。

**512バイトを超えるデータが扱えない**
BPF スタック内では [512 バイト](https://github.com/torvalds/linux/blob/8fa3b6f9392bf6d90cb7b908e07bd90166639f0a/tools/include/linux/filter.h#L26-L27) を超えるデータを扱うことができません。そのため、512 バイトを超えるデータをキャプチャした時に、512 バイト以降に HTTP/2 フレームの境界があると、以降のフレームを正常にダンプできなくなってしまいます。

例えば 1024 バイトの DATA フレームの直後に GOAWAY フレームが含まれていると、DATA フレームの先頭 512 バイトまでしか扱えないため、その GOAWAY フレームの存在に気づくことができません。

また、BPF のコードはカーネルの内部で実行されることから、前のステップに戻るようなコード、つまりループなどが使えません。そのため BPF のコード内でフレームを小さく分割するのも難しいのではないかと考えています。

# まとめ

BPF を使って HTTP/2 フレームをダンプする方法について検証しました。BPF を使用すれば、サーバーやクライアントに手をいれる必要がなく、コマンド一つで HTTP/2 フレームをキャプチャできる非常に便利なツールが作れることがわかりました。

しかしながら、扱えるデータの長さに制限があることや、フレームの順序が入れ替わってしまうなどの問題があり、実用的に使用するには、これらの問題を解決する必要があります。