/
カテゴリー

問題文  

あなたは某ISP企業 Aに転職をしました。
某ISP企業 Aでは複数のASとPublic IPを運用しています。

今日は入社初日で上長とオフィスで会うことになっていました。
待ち合わせの時刻にオフィスに到着して待っていたところ、あなたより1~2分遅れて上長が来ました。
そしてあなたは上長からこんなことを言われました。

👋

「入社初日で悪いんだが、今日倒れてしまった社員に変わってお客様へのトラブル対応を今日1日頼んでくれないか?
私はこれから外出してしまうのと他のチームメンバーは全員テレワークでここにはいない。
またチームで使っているコミュニケーションツールのアカウントも実はまだ発行していなくて、ネットワーク機器にアクセスする権限申請もまだ完全にはできていないんだ。
それでも君はとても優秀で最低限のことを言えばなにもかも対処できる人と聞いている。
それではあとは任せた、よろしく頼むぞ。」

あなたの業務は今日1日だけお客様から来るお問い合わせに対して対応しなくてはなりません。 そしてまだ権限などの業務手続きが終わっていない中、早速お客様からお問い合わせが来ました。

お客さんA 「あのールータ(csr-01)につなげているサーバ(server-01)についてですが、今日からあるサーバ(server-02)宛の通信ができなくなってしまいました。 原因を探してくれませんか?」

あなたはこの会社では様々なプロトコルでの通信制御を実施していると聞いたことあり、それが原因かもしれないけど詳しくは調べてみないとわからないと思いました。
そしてあなたは早速ネットワーク機器にログインして状況を確認することにしました。

またiBGP Routerというホスト名のルータがいますがこちらにはログインはすることができないようです。

初期状態  

server-01からServer-02へ通信ができない。

終了状態  

トラブルは解決しない。 原因を究明して報告すること。

解説  

あなたはこの会社では様々なプロトコルでの通信制御を実施していると聞いたことあり、それが原因かもしれないけど詳しくは調べてみないとわからないと思いました。

この問題では限られた競技時間内で問題文と問題環境から何が直接的な原因かを調査する問題となっています。
原因を調べるのに下記の項目を確認することが必要になります。

  • 各Serverの状態
  • 各Routerの状態

ルーティングの状態を確認するにはshow routeの確認と各Router・Serverから各Router・Serverの全インタフェースへPing/Tracerouteをすることで、 現在の状態を把握することができます。
これらを確認するとルーティングテーブルに問題はないことがわかり、次に通信制御等を確認します。

あなたはこの会社では様々なプロトコルでの通信制御を実施していると聞いたことあり、それが原因かもしれないけど詳しくは調べてみないとわからないと思いました。

またこの問題文から何かしらの通信制御がされていることが読み取れると思います。
ネットワークの通信制御を行う手法は複数挙げられますが、その中で何が使われているかを調査する必要があります。 各ルータでACLを確認しても今回の通信に関わるようなACLは存在していません。

csr-01~3>show access-lists
〜〜〜今回のトラブルに関係があるルールはない〜〜〜〜〜〜〜〜〜〜〜

某ISP企業 Aでは複数のASとPublic IPを運用しています。

また次にこの問題文からBGP運用を行っているとわかるのでBGPついて調べてみます。
トポロジ図からアクセスができない不明なiBGP Routerを1台確認することができ、show ip bgp summaryを見ると経路交換はしていないことが確認できます。 BGPを使った通信制御としてFlowspecがあるので、Flowspecについて調べてみます

またshow ip bgp neighborsでこのルータのネイバーの状態を確認するとFlowSpecルートを受け取っていることがわかります。

csr-02>show ip bgp neighbors iBGP_Router_IPAddress
 For address family: IPv4 Flowspec
  Session: iBGP_Router_IPAddress
  Index 1, Advertise bit 0
  1 update-group member
 Flowspec validation off
  Slow-peer detection is disabled
  Slow-peer split-update-group dynamic is disabled
                                 Sent       Rcvd
  Prefix activity:               ----       ----
    Prefixes Current:               0          1 (Consumes 124 bytes)
    Prefixes Total:                 0          1
    Implicit Withdraw:              0          0
    Explicit Withdraw:              0          0
    Used as bestpath:             n/a          1
    Used as multipath:            n/a          0
    Used as secondary:            n/a          0

                                   Outbound    Inbound
  Local Policy Denied Prefixes:    --------    -------
    Bestpath from this peer:              1        n/a
    Total:                                1          0

show flowspec ipv4 detailを確認してみるとserver-02宛への通信を落とすというポリシーがあり、 FlowSpecによる制御を行っていることがわかります。

csr-02>show flowspec ipv4 detail
AFI: IPv4
  Flow           :Dest:server-02/32
    Actions      :Traffic-rate: 0 bps  (bgp.1)
    Statistics                        (packets/bytes)
      Matched             :                   11/1078
      Dropped             :                   11/1078
 /
カテゴリー

解説

Linuxには、SSHの鍵情報をキャッシュし、次回からの接続を楽にしてくれる仕組みとしてSSH Key Agentというものがあります。 SSH接続を試みた際に鍵情報が指定されていなかった場合、GNOME Keyring に付属しているSSH Key Agentは .ssh/ 以下にある鍵を自動的に拾ってきて適切な鍵がないかどうか試しますが、この時実際にSSHサーバに認証を飛ばします。そして全ての鍵が無効だった場合、次の認証メソッドとしてパスワード認証などに移っていきます。
SSHサーバは通常1セッションあたりの認証試行上限回数が定められており、デフォルト値は6となっています。(https://man.openbsd.org/sshd_config#MaxAuthTries) .ssh/ 以下にある候補鍵の数が6以上かつ、その鍵ペア全てが無効な鍵だった場合、鍵を全て試し終わった時点で失敗回数は6回となります。この状態で次の鍵認証をリクエストしても既に試行上限を超えているため、常に Too many authentication failure のメッセージが返ってくる、というのが今回発生しているトラブルの原因です。

これを解決するには、公開鍵を明示的に指定して接続 (IdentityFile) すれば良いですが、~/.ssh/configに登録されていないサーバに接続するときも~/.ssh/以下にある鍵を試してしまうため、公開鍵が登録されていないサーバに接続することができなくなってしまいます。 この問題を回避するために、IdentitiesOnly というパラメータを設定することによって公開鍵認証に用いる鍵を明示的に指定する (IdentityFile に指定された鍵のみ使う) ことができます。 今回の問題設定では IdentityFile のみの指定や、使用されていない鍵 (~/.ssh/mars 等) の退避によっても終了条件を満たすことができますが、将来的に発生しうる問題を考慮する場合は IdentitiesOnly の指定をする方が望ましいです。

採点基準

  • 原因を説明できている 50%
    • Too many authentication failure が発生する原因 (試行した鍵が多すぎる) ことに言及
  • 接続できるようになっている 50%
    • 192.168.19.10
    • 192.168.19.20
    • これらへのSSH接続ができればよい
 /
カテゴリー

1次予選でも出題したコンテナへのポートフォワード問題です。本問題のコンテナはLXCからDockerへ変わりました。コンテナ型の仮想化には基本的にiptablesのラッパーが搭載されています。構成ファイルやコマンドに引数を指定するだけでポートフォワードできるのはこのためです。 今回は特殊な環境でトラブルシューティングを行う想定だったため、素のiptablesとebtablesを触る問題になりました。

問題文  

Dockerコンテナでウェブサーバを動かしているVMに、新しくコンテナを追加することになった。 docker run -d -p 8080:80 --name web2 nginx を実行してコンテナを立ち上げたいのだが、コンテナに疎通が取れないという。 原因を究明して新しく追加されたコンテナに疎通が取れるようにしてほしい。

初期状態  

  • 手元のPCから curl 192.168.13.1 を実行すると正常に応答する
  • VMで docker start web2 を実行したあと手元のPCから curl 192.168.13.1:8080 を実行しても正常に応答しない

終了状態  

  • VMを再起動しても以下が成り立つ
    • 手元のPCから curl 192.168.13.1 を実行すると正常に応答する
    • VMで docker start web2 を実行するとVMの8080番ポートが web2 コンテナの80番ポートへフォワードされ、手元のPCから curl 192.168.13.1:8080 を実行すると正常に応答する

アクセス情報  

VM名ホスト名ユーザパスワード
VM192.168.13.1userPlzComeToICTSC2020

解説  

docker start web2 をすると特にエラーは返ってこず、正常にコンテナが起動していることはわかるかと思います。

CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS
  NAMES
cb9075111a78        nginx               "nginx -g 'daemon of…"   About an hour ago   Up 1 second         0.0.0.0:8080->80/tcp
  web2
d732a7925959        nginx               "nginx -g 'daemon of…"   3 hours ago         Up 3 minutes        0.0.0.0:80->80/tcp
  web1

そこで iptables を見ると DOCKER-USER チェインに以下のルールが入っていることがわかります。

Chain DOCKER-USER (1 references)
target     prot opt source               destination
ACCEPT     all  --  anywhere             vm
ACCEPT     tcp  --  anywhere             10.213.213.213       tcp dpt:http
ACCEPT     all  --  anywhere             anywhere             ctstate RELATED,ESTABLISHED
DROP       all  --  anywhere             anywhere
RETURN     all  --  anywhere             anywhere


Dockerはiptablesを使ってポートフォワード等を実行しますが DOCKER-USER はDockerの作ったルールの前に適用されるチェインで、ユーザが利用するためのものです(参考: https://docs.docker.com/network/iptables/)。

これを見ると 10.213.213.213 へのパケット転送以外は基本的に許可されていないことがわかります。 このIPアドレスは web1 コンテナのものです。つまり web1 以外へのパケット転送を許可していないということです。 そこで web2 コンテナにもパケットの転送が許可されるようにルールを編集します。 例えば、以下のようにすればいかなる場合でもパケットの転送が許可されます。

sudo iptables -I DOCKER_USER 1 -j ACCEPT

しかし、これだけでは問題が解決しません。実はebtablesというパケットフィルタがARPパケットを落としているからです。 sudo ebtables -L とすると以下のルールが見えます。

Bridge table: filter

Bridge chain: INPUT, entries: 4, policy: ACCEPT
-i eth0 -j ACCEPT
-p ARP --arp-ip-src 10.213.213.213 --arp-ip-dst 10.213.213.1 -j ACCEPT
-p ARP --arp-ip-src 10.213.213.1 --arp-ip-dst 10.213.213.213 -j ACCEPT
-p ARP -j DROP

Bridge chain: FORWARD, entries: 0, policy: ACCEPT

Bridge chain: OUTPUT, entries: 4, policy: ACCEPT
-o eth0 -j ACCEPT
-p ARP --arp-ip-src 10.213.213.213 --arp-ip-dst 10.213.213.1 -j ACCEPT
-p ARP --arp-ip-src 10.213.213.1 --arp-ip-dst 10.213.213.213 -j ACCEPT
-p ARP -j DROP

これによると 10.213.213.1 と 10.213.213.213 の間でしかARPのやり取りができないことがわかります。 これは 10.213.213.1 は web1 コンテナが繋がっているブリッジです。

最も手っ取り早い方法としては sudo ebtables -F としてebtablesのルールを消し去ってしまうことです。

問題の終了条件としては、再起動しても web2 へ疎通が取れる必要がありました。 iptablesとebtablesのルールは通常再起動すれば消えますが、この問題ではそうではありません。

/etc/systemd/system/secure-firewall.service というサービスがVMの起動時に自動でルールを書き込むようになっています。 このサービスは /opt/secure_firewall.sh を実行するようになっています。 したがって secure-firewall.service を無効化するか secure_firewall.sh を書き換えて、パケットフィルタへの変更が永続化されるようにします。

本来は、既存のiptables/ebtablesの設定にならって、必要最小限の条件を許可するように設定するべきだと考えられますが、この問題では終了状態が達成されるようにルールを編集していればよしとしました。

環境  

Dockerコンテナ起動  

docker network create -d bridge my-net --gateway 10.213.213.1 --subnet 10.213.213.0/24
docker run --restart=always -p 80:80 --network=my-net  --ip=10.213.213.213 --mac-address=02:42:ac:11:00:02 --name web1 nginx

設定永続化のためサービスを定義  

/etc/systemd/system/secure-firewall.service

[Unit]
Description=secure firewall
After=docker.service
[Service]
ExecStart=/opt/secure_firewall.sh
[Install]
WantedBy=multi-user.target

sudo systemctl enable secure-firewall

設定スクリプト  

/opt/secure_firewall.sh

#!/bin/bash
iptables -I DOCKER-USER 1 -j DROP
iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -I DOCKER-USER 1 -p tcp --dport 80  -d 10.213.213.213  -j ACCEPT
iptables -I DOCKER-USER 1 -d 10.213.213.1  -j ACCEPT
ebtables -A INPUT -i eth0 -j ACCEPT
ebtables -A INPUT -p ARP --arp-ip-src 10.213.213.213 --arp-ip-dst 10.213.213.1 -j ACCEPT
ebtables -A INPUT -p ARP --arp-ip-src 10.213.213.1 --arp-ip-dst 10.213.213.213 -j ACCEPT
ebtables -A INPUT -p ARP -j DROP
ebtables -A OUTPUT -o eth0 -j ACCEPT
ebtables -A OUTPUT -p ARP --arp-ip-src 10.213.213.213 --arp-ip-dst 10.213.213.1 -j ACCEPT
ebtables -A OUTPUT -p ARP --arp-ip-src 10.213.213.1 --arp-ip-dst 10.213.213.213 -j ACCEPT
ebtables -A OUTPUT -p ARP -j DROP

終わりに  

皆さんiptablesについては報告してくださっていましたが、ebtablesについては2チームしか発見できていなかったようです。 あまり素で触ることのない技術ですが、楽しんでいただければ幸いです。

 /
カテゴリー

解説  

手元から192.168.9.1に対してcurlをしてみると、確かに応答が返ってきていないことが確認出来ます。また、コンテナの中でtcpdumpしてもパケットが見えないことが分かります。VMに対してSSHは出来ているはずなのでL3の疎通性には問題が無いはずです。つまり、XDPのプログラムで処理されてからtcpdumpで触れる部分までの間で何かパケットが消えるような問題が起こっているだろうと問題を切り分けることが出来ます。

そのために、後輩がどのようなプログラムを動かしていたのかを読んでみましょう。/opt/forwarderにはXDPのプログラムの動作に必要なファイルがまとめられています。Dockerfileを見てみると、/opt/forwarderにあるファイルを全てコンテナの中に入れています。また、CMDとして/controller.pyを指定していることが分かります。

# syntax=docker/dockerfile:experimental
FROM fedora:30

RUN --mount=type=cache,target=/var/cache/dnf dnf install -y bcc bpftool bpftrace clang iproute kmod python3-pyroute2

COPY * /

CMD "/controller.py"

controller.pyでは、forwarder.cやconfig.jsonを読み出し、コンパイルやNICへのアタッチを行っています。このプログラムはNICにプログラムをアタッチするために用意しているだけのコードなので、編集不可という制限をつけています。このプログラムには動作上問題になるようなものは含まれていません。

def main():
    with open("./forwarder.c", "r") as f:
        text = f.read()

    with open("./config.json", "r") as f:
        conf = json.loads(f.read())

    # eBPFプログラムやプログラム内の関数のロード
    b = BPF(text=text)
    ext_fn = b.load_func("entry_external", BPF.XDP)
    int_fn = b.load_func("entry_internal", BPF.XDP)

    # XDP_REDIRECTするためのdevmapの準備
    ip = IPRoute()
    devmap = b.get_table("devmap")
    for link in ip.get_links():
        idx = link["index"]
        devmap[c_int(idx)] = c_int(idx)

    # コンテナへの通信やコンテナからの通信をNATする際に用いるデータの準備
    dnat_entries = b.get_table("dnat_entries")
    snat_entries = b.get_table("snat_entries")

    for entry in conf["entries"]:
        f, t = get_endpoint(entry["from"]), get_endpoint(entry["to"])
        dnat_entries[f] = t
        snat_entries[t] = f

    # externalに指定されたインターフェースにext_fn(DNAT用のプログラム)をアタッチする
    for interface in conf["interfaces"]["external"]:
        b.attach_xdp(interface, ext_fn, 0)

    # externalに指定されたインターフェースにint_fn(SNAT用のプログラム)をアタッチする
    for interface in conf["interfaces"]["internal"]:
        b.attach_xdp(interface, int_fn, 0)

    # XDP内でprintkしたログを表示する
    b.trace_print()

そのため、実際に転送処理を行っているforwarder.cを見ていくことにしましょう。コメントにもある通りforwarder.cはDNAT用のentrypointとSNAT用のentrypointがあります。このentrypointからprocess_ethhdr関数を呼んで処理が開始されます。

// DNAT用のentrypoint
int entry_external(struct xdp_md *ctx) {
	return process_ethhdr(ctx, DIR_INBOUND);
}

// SNAT用のentrypoint
int entry_internal(struct xdp_md *ctx) {
	return process_ethhdr(ctx, DIR_OUTBOUND);
}

process_ethhdr関数は次のヘッダがIPかどうかを調べてからprocess_iphdr関数を呼び出します。そして、process_iphdr関数でも次のヘッダやパラメータを調べてからprocess_tcphdr関数を呼び出します。その後、process_tcphdr関数ではlookup_endpoint関数を呼び出します。assert_lenマクロはXDPの制約上必要になっているコードなので…

static inline int process_tcphdr(
		struct xdp_md *ctx, 
		struct ethhdr *eth,
		struct iphdr *ip,
		enum direction_t dir)
{
	void *data_end = (void *)(long)ctx->data_end;

	struct tcphdr *tcp = (struct tcphdr *)(ip + 1);
	assert_len(tcp, data_end);

	return lookup_endpoint(ctx, eth, ip, tcp, dir);
}

static inline int process_iphdr(
		struct xdp_md *ctx, 
		struct ethhdr *eth,
		enum direction_t dir)
{
	void *data_end = (void *)(long)ctx->data_end;

	struct iphdr *ip = (struct iphdr *)(eth + 1);
	assert_len(ip, data_end);

	if (ip->protocol != IPPROTO_TCP) return XDP_PASS;
	if (ip->version != 4) return XDP_DROP;
	if (ip->ihl != 5) return XDP_PASS;

	return process_tcphdr(ctx, eth, ip, dir);
}

static inline int process_ethhdr(
		struct xdp_md *ctx, 
		enum direction_t dir)
{
	void *data = (void *)(long)ctx->data;
	void *data_end = (void *)(long)ctx->data_end;

	struct ethhdr *eth = data;
	assert_len(eth, data_end);

	if (eth->h_proto != htons(ETH_P_IP)) return XDP_PASS;

	return process_iphdr(ctx, eth, dir);
}

lookup_endpoint関数では、dirの内容に合わせてdnat_entries, dnat_entriesをルックアップします。例えば、DIR_INBOUND(外部からVMにパケットが来た場合)はデスティネーションアドレスやポート番号を基にNATする先のアドレスやポート番号をルックアップします。その結果、値がない(val == NULL)である場合には、NATすることが出来ないため、XDP_PASSしてパケットの処理をカーネルに依頼します。NAT先がある場合、lookup_nexthop関数を呼び出します。

static inline int lookup_endpoint(
		struct xdp_md *ctx, 
		struct ethhdr *eth,
		struct iphdr *ip,
		struct tcphdr *tcp,
		enum direction_t dir)
{
	struct endpoint_t key = {}, *val = NULL;

	switch (dir) {
		case DIR_INBOUND:
			key.addr = ntohl(ip->daddr);
			key.port = ntohs(tcp->dest);
			val = dnat_entries.lookup(&key);
			break;
		case DIR_OUTBOUND:
			key.addr = ntohl(ip->saddr);
			key.port = ntohs(tcp->source);
			val = snat_entries.lookup(&key);
			break;
	}

	if (val == NULL) return XDP_PASS;
	return lookup_nexthop(ctx, eth, ip, tcp, val, dir);
}

lookup_nexthop関数ではstruct bpf_fib_lookup構造体に値を設定してからbpf_fib_lookup関数を呼び出します。この関数はカーネルの中で用意されているヘルパー関数でLinuxのFIBをルックアップすることが出来る関数です。もしその関数の返り値がBPF_FIB_LKUP_RET_NOT_FWDEDならパケットを転送する必要がない、つまりコンテナからパケットが転送されてきたけどVMで受け取るという状態なので、NATせずにパケットの処理をカーネルに依頼します。そうでないエラーコードの場合、カーネルに依頼しても意味がないのでこの段階でDropします。無事bpf_fib_lookup関数でパケットの転送先が分かったら、rewrite_packet関数を呼び出してパケットの書き換えを行います。

static inline int lookup_nexthop(
		struct xdp_md *ctx, 
		struct ethhdr *eth,
		struct iphdr *ip,
		struct tcphdr *tcp,
		struct endpoint_t *val,
		enum direction_t dir)
{
	struct bpf_fib_lookup params = {};
	params.family = AF_INET;
	params.ifindex = ctx->ingress_ifindex;

	switch (dir) {
		case DIR_INBOUND:
			params.ipv4_src = ip->saddr;
			params.ipv4_dst = htonl(val->addr);
			break;
		case DIR_OUTBOUND:
			params.ipv4_src = htonl(val->addr);
			params.ipv4_dst = ip->daddr;
			break;
		default:
			return XDP_DROP;
	}

	int ret = bpf_fib_lookup(ctx, &params, sizeof(params), 0);
	switch (ret) {
		case BPF_FIB_LKUP_RET_NOT_FWDED:
			return XDP_PASS;
		case BPF_FIB_LKUP_RET_FWD_DISABLED:
		case BPF_FIB_LKUP_RET_BLACKHOLE:
		case BPF_FIB_LKUP_RET_UNREACHABLE:
		case BPF_FIB_LKUP_RET_PROHIBIT:
		case BPF_FIB_LKUP_RET_FRAG_NEEDED:
		case BPF_FIB_LKUP_RET_UNSUPP_LWT:
			return XDP_DROP;
	}

	return rewrite_packet(ctx, eth, ip, tcp, val, &params, dir);
}

rewrite_packet関数では得られた情報を基にひたすらパケットを書き換えていきます。最後にdevmapをルックアップして、パケットをnexthopのあるI/Fにリダイレクトしています。

static inline int rewrite_packet(
		struct xdp_md *ctx, 
		struct ethhdr *eth,
		struct iphdr *ip,
		struct tcphdr *tcp,
		struct endpoint_t *val,
		struct bpf_fib_lookup *params,
		enum direction_t dir)
{
	__be64 ip_check, tcp_check, l3_diff, l4_diff;

	switch (dir) {
		case DIR_INBOUND:
			l3_diff = (~ip->daddr) & 0xffffffff;
			l4_diff = (~tcp->dest) & 0xffff;
			ip->daddr = htonl(val->addr);
			tcp->dest = htons(val->port);
			break;
		case DIR_OUTBOUND:
			l3_diff = (~ip->saddr) & 0xffffffff;
			l4_diff = (~tcp->source) & 0xffff;
			ip->saddr = htonl(val->addr);
			tcp->source = htons(val->port);
			break;
		default:
			return XDP_DROP;
	}

	l3_diff += htonl(val->addr);
	l4_diff += htons(val->port);

	l4_diff += l3_diff;

	ip_check = (~ip->check & 0xffff) + l3_diff;
	ip->check = ~fold_csum(ip_check) & 0xffff;

	tcp_check = (~tcp->check & 0xffff) + l4_diff;
	tcp->check = ~fold_csum(tcp_check) & 0xffff;

	for (int i = 0; i < 6; i++) {
		eth->h_source[i] = params->smac[i];
		eth->h_dest[i] = params->dmac[i];
	}

	return devmap.redirect_map(params->ifindex, 0);
}

一見すると正しく動いてくれそうに見えます。そのため、forwarder.cを書き換えてパケットの中身を調べてみましょう。

    bpf_trace_rintk("===\n");
    bpf_trace_printk("ip->saddr: %x\n, ip->saddr);
    bpf_trace_printk("ip->daddr: %x\n, ip->daddr);
    bpf_trace_printk("tcp->source: %x\n, tcp->source);
    bpf_trace_printk("tcp->dest: %x\n, tcp->dest);
    bpf_trace_printk("params->ifindex: %d\n, params->ifindex);
    
	return devmap.redirect_map(params->ifindex, 0);

再度コンテナをスタートし、ログを見てみると次のような表示が得られます。ネットワークパケットはビッグエンディアンであることに注意しながらこのログを見ていくと、最終的に192.168.9.254:55802から10.123.1.1:80へのパケットだということが分かります。また、params->ifindex(転送先のI/Fのインデックス)は4になっています。

$ sudo docker logs -f forwarder
b'          <idle>-0     [000] ..s.   324.937829: 0: ==='
b'          <idle>-0     [000] .Ns.   324.938507: 0: ip->saddr: fe09a8c0'
b'          <idle>-0     [000] .Ns.   324.938515: 0: ip->daddr: 1017b0a'
b'          <idle>-0     [000] .Ns.   324.938516: 0: tcp->source: fa9d'
b'          <idle>-0     [000] .Ns.   324.938516: 0: tcp->dest: 5000'
b'          <idle>-0     [000] .Ns.   324.938517: 0: params->ifindex: 4'
b'          <idle>-0     [000] ..s.   325.946305: 0: ==='
b'          <idle>-0     [000] .Ns.   325.946384: 0: ip->saddr: fe09a8c0'
b'          <idle>-0     [000] .Ns.   325.946385: 0: ip->daddr: 1017b0a'
b'          <idle>-0     [000] .Ns.   325.946385: 0: tcp->source: fa9d'
b'          <idle>-0     [000] .Ns.   325.946386: 0: tcp->dest: 5000'
b'          <idle>-0     [000] .Ns.   325.946387: 0: params->ifindex: 4'
b'          <idle>-0     [000] ..s.   327.962426: 0: ==='
b'          <idle>-0     [000] .Ns.   327.962508: 0: ip->saddr: fe09a8c0'
b'          <idle>-0     [000] .Ns.   327.962509: 0: ip->daddr: 1017b0a'
b'          <idle>-0     [000] .Ns.   327.962510: 0: tcp->source: fa9d'
b'          <idle>-0     [000] .Ns.   327.962510: 0: tcp->dest: 5000'
b'          <idle>-0     [000] .Ns.   327.962511: 0: params->ifindex: 4'
b'          <idle>-0     [000] ..s.   332.186120: 0: ==='
b'          <idle>-0     [000] .Ns.   332.186190: 0: ip->saddr: fe09a8c0'
b'          <idle>-0     [000] .Ns.   332.186191: 0: ip->daddr: 1017b0a'
b'          <idle>-0     [000] .Ns.   332.186192: 0: tcp->source: fa9d'
b'          <idle>-0     [000] .Ns.   332.186192: 0: tcp->dest: 5000'
b'          <idle>-0     [000] .Ns.   332.186193: 0: params->ifindex: 4'
^C

ipコマンドを使ってifindexが4のデバイスを調べてみるとve-m1だということが分かります。宛先が10.123.1.1:80のパケットがve-m1から送出されようとしているのは普通に考えると正しい挙動になっていそうだということが分かります。実際、forwarder.cは動作上問題になるようなものは含まれていません。

$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp/id:12 qdisc mq state UP group default qlen 1000
    link/ether 52:54:9a:e6:3d:83 brd ff:ff:ff:ff:ff:ff
    inet 192.168.9.1/24 brd 192.168.9.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::5054:9aff:fee6:3d83/64 scope link 
       valid_lft forever preferred_lft forever
3: ve-m2@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp/id:13 qdisc noqueue state UP group default qlen 1000
    link/ether e2:00:a0:72:c7:43 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 169.254.248.252/16 brd 169.254.255.255 scope link ve-m2
       valid_lft forever preferred_lft forever
    inet 10.123.2.254/24 scope global ve-m2
       valid_lft forever preferred_lft forever
4: ve-m1@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp/id:13 qdisc noqueue state UP group default qlen 1000
    link/ether a2:5e:3c:30:e4:23 brd ff:ff:ff:ff:ff:ff link-netnsid 1
    inet 169.254.87.30/16 brd 169.254.255.255 scope link ve-m1
       valid_lft forever preferred_lft forever
    inet 10.123.1.254/24 scope global ve-m1
       valid_lft forever preferred_lft forever
5: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:58:3a:60:6f brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
       valid_lft forever preferred_lft forever

では、正しくXDPのプログラムでの処理が行われたとすると、パケットはXDPのプログラムを離れてからtcpdumpで見えるまで、つまりソケットのレイヤーに到達する前のどこかで消えているだろうと問題の切り分けができます。

では、XDPのプログラムを離れる地点はどこで、その後パケットはどのような流れを追ってtcpdumpが行われている地点まで流れていくのでしょうか?それを追ってみてきましょう。eth0はvirtioドライバでLinuxカーネルに認識されているので/drivers/net/virtio_net.cを見ていきます。

Linuxではパケットの受信処理の際にはNAPIと呼ばれる仕組みを用いてパケットの受信処理を行っています。この仕組みのおかげで輻輳している状態でもスラッシングが起こらないなどの利点があります。このNAPIは大抵のNICのドライバーで利用されています。このNAPIを有効にするためにドライバーではnetif_napi_add関数を呼ぶ必要があります。virtio_netではこの部分でnetif_napi_add関数を呼んでいます。NAPIのポーリング用の関数としてvirtnet_poll関数が指定されているので、それを起点に読み進めていきます。

	INIT_DELAYED_WORK(&amp;vi->refill, refill_work);
	for (i = 0; i < vi->max_queue_pairs; i++) {
		vi->rq[i].pages = NULL;
		netif_napi_add(vi->dev, &amp;vi->rq[i].napi, virtnet_poll,
			       napi_weight);
		netif_tx_napi_add(vi->dev, &amp;vi->sq[i].napi, virtnet_poll_tx,
				  napi_tx ? napi_weight : 0);

		sg_init_table(vi->rq[i].sg, ARRAY_SIZE(vi->rq[i].sg));
		ewma_pkt_len_init(&amp;vi->rq[i].mrg_avg_pkt_len);
		sg_init_table(vi->sq[i].sg, ARRAY_SIZE(vi->sq[i].sg));

		u64_stats_init(&amp;vi->rq[i].stats.syncp);
		u64_stats_init(&amp;vi->sq[i].stats.syncp);
	}

virtnet_poll関数からvirtnet_receive関数, receive_buf関数, receive_small関数と進んでいくと、xdp_progという変数が見えてきます。この関数の中でbpf_prog_run_xdp関数が呼ばれXDPのプログラムが実行されます。

		xdp.data_hard_start = buf + VIRTNET_RX_PAD + vi->hdr_len;
		xdp.data = xdp.data_hard_start + xdp_headroom;
		xdp_set_data_meta_invalid(&amp;xdp);
		xdp.data_end = xdp.data + len;
		xdp.rxq = &amp;rq->xdp_rxq;
		orig_data = xdp.data;
		act = bpf_prog_run_xdp(xdp_prog, &amp;xdp);
		stats->xdp_packets++;

その後、actに従ってパケットの処理の先が変わります。今回はXDP_REDIRECTしているはずなので、その部分の処理を見ていきます。xdp_do_redirect関数が呼ばれていることが分かるのでその先を見ていきます。

		case XDP_REDIRECT:
			stats->xdp_redirects++;
			err = xdp_do_redirect(dev, &amp;xdp, xdp_prog);
			if (err)
				goto err_xdp;
			*xdp_xmit |= VIRTIO_XDP_REDIR;
			rcu_read_unlock();
			goto xdp_xmit;

xdp_do_redirect関数からxdp_do_redirect_map関数, __bpf_tx_xdp_map関数, dev_map_enqueue関数, bq_enqueue関数へと処理が進んでいきます。この関数内のlist_add関数によってXDPのパケットはリストに繋がれてパケットがflushされるのを待ちます。

このflush_listで検索をかけてみると、__dev_map_flush関数という関数が見つけられます。この関数から呼ばれているbq_xmit_all関数を見てみるとこの行で転送先デバイスに紐付けられたnetdev_opsのndo_xdp_xmit関数を呼び出していることが分かります。

	sent = dev->netdev_ops->ndo_xdp_xmit(dev, bq->count, bq->q, flags);

今はvethに対してリダイレクトしているので、vethのnet_device_opsを見てみましょう。

static const struct net_device_ops veth_netdev_ops = {
	.ndo_init            = veth_dev_init,
	.ndo_open            = veth_open,
	.ndo_stop            = veth_close,
	.ndo_start_xmit      = veth_xmit,
	.ndo_get_stats64     = veth_get_stats64,
	.ndo_set_rx_mode     = veth_set_multicast_list,
	.ndo_set_mac_address = eth_mac_addr,
#ifdef CONFIG_NET_POLL_CONTROLLER
	.ndo_poll_controller	= veth_poll_controller,
#endif
	.ndo_get_iflink		= veth_get_iflink,
	.ndo_fix_features	= veth_fix_features,
	.ndo_features_check	= passthru_features_check,
	.ndo_set_rx_headroom	= veth_set_rx_headroom,
	.ndo_bpf		= veth_xdp,
	.ndo_xdp_xmit		= veth_xdp_xmit,
};

すると、veth_xdp_xmitと呼ばれる関数が紐付いていることが分かります。この関数の内部で次のようなコードがあります。

	/* Non-NULL xdp_prog ensures that xdp_ring is initialized on receive
	 * side. This means an XDP program is loaded on the peer and the peer
	 * device is up.
	 */
	if (!rcu_access_pointer(rq->xdp_prog)) {
		ret = -ENXIO;
		goto drop;
	}

xdp_progはそのデバイスに紐付いているXDPのプログラムへのポインタです。それがNULLの時には-ENXIO(-6)を返してDropしている処理が入っています。実際に、bpftraceと呼ばれるツールを用いてこの関数からどんな値が返ってきているのかを調べてみると、-6が返ってきていることが分かります。

# bpftrace -e 'kretprobe:veth_xdp_xmit { printf("retval = %d\n", retval); }'
Attaching 1 probe...
retval = -6
retval = -6
^C

では、受信するvethのデバイスにXDPのプログラムがアタッチされていれば良さそうなので、プログラムを用意します。ここではXDP_PASS(2)を返すだけのプログラムを用意し、それをコンテナの中のNICにアタッチします。

$ cat main.c
int f() {
    return 2;
}
EOF
$ clang --target=bpf -c main.c

コンテナはただ単に様々なnamespaceを分離してプロセスのisolationをしているだけなので、コンテナ外から見ると普通にプロセスツリーの中にsystemdがあることが分かります。また、nsenterコマンドを用いることで、あるPIDのプロセスのnet namespaceに入るというような処理が可能です。そのため、コンテナの中にプログラムをattachするには次のようにすればよいです。

$ for pid in $(pgrep systemd-nspawn | xargs -n 1 pgrep -P); do
    sudo nsenter --net --target $pid ip link set xdp object main.o section .text dev host0
done

この状態でコンテナの中でtcpdumpしてみると次のような表示が得られます。

# tcpdump -vni host0
tcpdump: listening on host0, link-type EN10MB (Ethernet), capture size 262144 bytes
17:59:42.029623 IP (tos 0x0, ttl 64, id 38097, offset 0, flags [DF], proto TCP (6), length 60)
    192.168.9.254.40454 > 10.123.1.1.80: Flags [S], cksum 0x2338 (correct), seq 1532198029, win 29200, options [mss 1460,sackOK,TS val 1575487606 ecr 0,nop,wscale 7], length 0
17:59:42.029745 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    10.123.1.1.80 > 192.168.9.254.40454: Flags [S.], cksum 0xd650 (incorrect -> 0xc332), seq 3121682320, ack 1532198030, win 65160, options [mss 1460,sackOK,TS val 1668587109 ecr 1575487606,nop,wscale 7], length 0
17:59:43.035987 IP (tos 0x0, ttl 64, id 38098, offset 0, flags [DF], proto TCP (6), length 60)
    192.168.9.254.40454 > 10.123.1.1.80: Flags [S], cksum 0x1f4a (correct), seq 1532198029, win 29200, options [mss 1460,sackOK,TS val 1575488612 ecr 0,nop,wscale 7], length 0
17:59:43.036068 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    10.123.1.1.80 > 192.168.9.254.40454: Flags [S.], cksum 0xd650 (incorrect -> 0xbf43), seq 3121682320, ack 1532198030, win 65160, options [mss 1460,sackOK,TS val 1668588116 ecr 1575487606,nop,wscale 7], length 0
17:59:43.325308 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    10.123.1.1.80 > 192.168.9.254.40450: Flags [S.], cksum 0xd650 (incorrect -> 0x92fa), seq 3503547284, ack 4128794799, win 65160, options [mss 1460,sackOK,TS val 1668588405 ecr 1575471605,nop,wscale 7], length 0
17:59:43.581414 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    10.123.1.1.80 > 192.168.9.254.40452: Flags [S.], cksum 0xd650 (incorrect -> 0x47c2), seq 2126030842, ack 2787864494, win 65160, options [mss 1460,sackOK,TS val 1668588661 ecr 1575480015,nop,wscale 7], length 0
^C

注目すべき点はこの部分です。送信しているパケットのchecksumが合っていないといわれています。tx checksum offloadingが有効になっているとLinuxカーネルでは計算を行わずにハードウェアや先のデバイスでchecksumの計算を行うような仕組みになっています。そのため、送信時にchecksumがあっていないと表示されます。ですが、XDPのプログラムでは差分アップデートのみを行っているためchecksumが合わずパケットが経路のどこかでDropしてしまいます。

    10.123.1.1.80 > 192.168.9.254.40454: Flags [S.], cksum 0xd650 (incorrect -> 0xc332), seq 3121682320, ack 1532198030, win 65160, options [mss 1460,sackOK,TS val 1668587109 ecr 1575487606,nop,wscale 7], length 0

これに対応するために、vethのtx checksum offloadingを無効にします。

$ for pid in $(pgrep systemd-nspawn | xargs -n 1 pgrep -P); do
    sudo nsenter --net --target $pid ethtool -K host0 tx off
done

これでパケットが無事返ってくることが確認できます。

採点基準  

  • XDPとvethに起因する問題(70%)
    • setup-veth.serviceをrestartしてアドレスを振る(10%)
    • 通信が行えていない理由についてなんらかの言及をする(20%)
      • XDPのプログラムでは適切に処理が行われていることに言及する(10%)
        • devmap.redirect_mapでは適切なI/FにXDP_REDIRECTしていることに言及する
        • bpf_fib_lookupでは適切なルーティング結果が返ってきている
      • netns内ではパケットが受信できていないことに言及する(10%)
        • tcpdumpをしてみてもパケットが流れていない
    • vethのpeerにXDPのプログラムがattachされていないことに言及する(30%)
    • vethのpeerに通信に影響のないXDPのプログラムをattachできる(10%)
  • vethのtx checksum offloadingに起因する問題(30%)
    • netns内のvethのchecksumがおかしいことに言及する(10%)
    • netns内のvethのtx checksum offloadを無効化する(20%)
      • ソースコードを書き換えてchecksumを計算しても良い

作問者からのコメント  

この問題はいわゆる”全完絶対阻止する問”としてインターン期間中に遭遇した問題をアレンジした問題です。XDPのプログラムでは問題がなさそうという部分まで言及できているチームは1チームだけありましたが、peerのvethにXDPのプログラムがattachされていないことが原因だとは気づいていないと思います。Linuxカーネル内部のネットワーク処理の流れをある程度知っていないと解けない問題ですが、Linuxカーネルはすごく面白いのでぜひ興味を持ってほしいなと思います。

 /
カテゴリー

問題文  

Webアプリケーションをデプロイするために動作環境を用意したのだが、/opt/service/以下にある app アプリケーションを実行しても、正しく起動しないらしい。 また、担当者だったトラブル太郎さんは会社を去ってしまった。

原因を解明し、トラブルを修正してくれ。また、同じトラブルが再起動後にも発生しないようにしてくれ。

初期状態  

  • /opt/service に app , flag , flag.txt がある
  • /opt/service 以下で ./app を正しく実行できない

終了状態  

VMを再起動しても以下が成り立つ

  • ./app でアプリケーションが起動できる
  • curl localhost:3000 すると以下のように表示される(一行目は現在時刻/GMT)
2020-02-29 04:40:55
test
Great!!!!

補足  

  • セキュリティレベルが下がるようなことをしてはいけません
  • セキュリティを保つため、appや関連するファイルへ不必要な権限を与えてはいけません(プロセスをroot権限で動作させたり、ケイパビリティ与えるなど)
  • なぜ正しく動かなかった原因を報告してください。またその原因をどうやって特定したか具体的に報告してください。
  • 解決するにあたって、何をしたか(どのようなコマンドを打ったか、どこのファイルをどう編集したか等)具体的に報告してください
  • /opt/service 以下のファイルの内容を変更してはいけません

解説・解法  

1. 導入・ロール昇格  

SELinuxによって適切に権限が振られてない問題です。

まず、sshして/opt/service/appを実行してみます。

2020/02/29 07:17:42 ReadFile() error=open flag.txt: permission denied

以上のように表示されるため、flag.txtのパーミッションを確認しますが、オーナーは自分で、かつ権限は644です。ここで、おそらくSELinuxによってアクセス権限がないことがわかります。

/var/log/audit/audit.log を開こうとすると権限がなく、またauditdを起動しようとしても、systemctlが発行できないことに気づきます。 またgetenforceするとenforcingです。

ここまでくればSELinux/MACが原因と確定できます。

idコマンドを打つと、自分のSELinuxコンテキストが staff_u:staff_r:staff_t:s0-s0:c0.c1023 とわかります。CentOSの場合、 staff_uはデフォルトでstaff_r sysadm_r system_r unconfined_rの4つのロールに属しているので、まずはadmin用であるsysadm_rへロール昇格をします。

ロール昇格は、通常sudonewroleで行えるので、まずはsudoで試してみます。

$ sudo id -Z
staff_u:staff_r:staff_t:s0-s0:c0.c1023

ロールもドメインも変化していません。次はnewroleを試します。

$ newrole -r sysadm_r
$ id -Z
staff_u:sysadm_r:sysadm_t:s0-s0:c0.c1023

昇格できました。これで作業が始められます。

2. myapp_t へ myapp_flag_t の読み込み権限を与える  

無事staff_rからsysadm_rへ昇格できたので、auditdを再開させます。

$ sudo service auditd start
Permission denied
$ sudo systemctl start auditd
Permission denied
$ sudo tail -f /var/log/audit/audit.log
No such file or directory

auditが起動できず、またログも出ていないため、どのドメインの権限が不足しているかわかりません。audit2allowも使用できません。なので、selinuxからポリシーを吐き出させて静的に解析します。

$ ls -lZ /opt/service
-rwxr-xr-x. ictsc ictsc system_u:object_r:myapp_exec_t:s0 app
-rwxr-xr-x. ictsc ictsc system_u:object_r:usr_t:s0 flag
-rw-r--r--. ictsc ictsc system_u:object_r:myapp_flag_t:s0 flag.txt

ls -lZで、flag.txtファイルのコンテキストmyapp_flag_tがわかるので、myapp_flag_tに対する権限を持っているドメインを探す必要があります。

semodule -E myappでポリシーを抽出、dismodで中のポリシーを確認します。

アプリケーションはmyapp_tドメインで動作するとわかるので、flag.txt(myapp_flag_t)への読み込み許可(read open)を与えるポリシーを生成します。

$ cat hoge.te
policy_module(hoge, 1.0)

require {
        type myapp_t;
        type myapp_flag_t;
}

allow myapp_t myapp_flag_t:file { read open };

$ cp /usr/share/selinux/devel/Makefile .
$ make
$ sudo semodule -i hoge.pp

これで、app自体は動くようになります。

$ exit
$ ./app

3. myapp_tflagの実行権限を与える  

curlを投げて見ると、出力が足りません。

$ curl localhost:3000
2020-02-29 07:58:35
test

appがどのような動作をしているかわからないのでstraceをインストールし見てみます。

$ newrole -r sysadm_r
$ sudo yum install strace.x86_64
$ strace ./app

appがflagを実行しようとしています。 ただ、この状況を解決するために、myapp_tドメインにusr_t全てに対する権限を与えてしまいそうになりますが、その場合myapp_tがすべてのusr_tラベルを持つファイルを実行できてしまうため、セキュリティレベルが下がります。

なので、flagバイナリのコンテキストを押さえ込みます。新しくドメインを作ってもいいですが、既にあるmyapp_exec_tを使いましょう。

$ sudo semanage fcontext -a -t myapp_exec_t /opt/service/flag
$ sudo restorecon -FR /opt/service

restoreconができなかったので、自分に権限を与えます。 以下をteファイルへ追記して再インストールします。ちなみにこのポリシーは、audit2allowが使えないので自力でrefpolicyから推測します。

allow sysadm_sudo_t myapp_flag_t:file getattr;
allow sysadm_sudo_t usr_t:file relabelfrom;
allow sysadm_sudo_t myapp_exec_t:file relabelto;

また、requireに追記することも忘れないようにしまししょう。

以下に現在のteファイルを示します。

$ cat hoge.te
policy_module(hoge, 1.0)

require {
        type myapp_t;
        type myapp_flag_t;
        type myapp_exec_t;
        type usr_t;
        type sysadm_sudo_t;
}

allow myapp_t myapp_flag_t:file { read open };
allow sysadm_sudo_t myapp_flag_t:file getattr;
allow sysadm_sudo_t usr_t:file relabelfrom;
allow sysadm_sudo_t myapp_exec_t:file relabelto;

先ほどと同じ手順で、コンテキストをインストールできたら、appを起動します。

$ make
$ sudo semodule -i hoge.pp
$ exit
$ ./app

curlします。

$ curl localhost:3000
2020-02-29 08:16:38
test
Great!!!!

正しく起動できました。

まとめです。 appが正しく動かない原因は2つ。

  • app (myapp_t) が flag.txt を読めない
  • app (myapp_t) が flag を実行できない

前者はポリシーの作成、後者はfcontextを行いラベル変更で解決できました。

所感  

この問題で一番大事になる部分は、SELinuxの管理を行える権限がないstaff_rロールから、newroleコマンドでsysadm_rへロール昇格ができるかどうか、という点でした。 (結構、setenforceコマンドが実行できないのは問題の不備ではないのかという質問を頂きました)

また、惜しかったのは、問題の中で「不必要な権限を与えてはいけない」と書いてあるのに、usr_tドメインへのexecuteアトリビュート権限をmyapp_tドメインへ与えていたチームです。/opt/service/appは正しく動きますが、問題の指示とあっていないため満点にはしませんでした。

SELinuxは、結構マイナーで触る機会がない技術であるため、ここまで解かれるとは思っていませんでした。作問者として、大変嬉しく思います。