みつきんのメモ

組み込みエンジニアです。Interface誌で「My オリジナルLinuxの作り方」連載中

Yocto Project Kirkstone(4.0) タスクのネットワークアクセス制限の実現方法

はじめに

前回で、Kirkstoneではdo_fetch以外のタスクからは原則ネットワークアクセスができなくなっていることについて調査した。

Release 4.0 (kirkstone)には下記のように記載があり、カーネル機能を使用しているとのことだったが、具体的にどのような機能が使用されているかはドキュメントには見当たらなかった。

Network access from tasks is now disabled by default on kernels which support this feature (on most recent distros such as CentOS 8 and Debian 11 onwards). This means that tasks accessing the network need to be marked as such with the network flag.

興味が湧いたので調べてみた。

bitbakeの実装

該当箇所の特定

poky/bitbakeディレクトリ配下でnetworkgrepしてみる。するとbitbake-workerというスクリプトに怪しい行を見つけた。

$ grep -n -r 'network' .
... (snip) ...
./bin/bitbake-worker:264:                if not the_data.getVarFlag(taskname, 'network', False):
... (snip) ...

この行事体は、tasknameで指定されたタスクにnetworkというフラグが立っているかをチェックしているだけだが、 この付近でネットワークアクセスをチェックしているということが推測できる。

if not the_data.getVarFlag(taskname, 'network', False):
    if bb.utils.is_local_uid(uid):
        logger.debug("Attempting to disable network for %s" % taskname)
        bb.utils.disable_network(uid, gid)
    else:
        logger.debug("Skipping disable network for %s since %s is not a local uid." % (taskname, uid))

bb.utils.disable_network(uid, gid)が怪しい。

bb.utils.disable_networkの実装

これが本体と仮定して実装を眺めてみる。この関数は./lib/bb/utils.pyで実装されている。

def disable_network(uid=None, gid=None):
    """
    Disable networking in the current process if the kernel supports it, else
    just return after logging to debug. To do this we need to create a new user
    namespace, then map back to the original uid/gid.
    """
    libc = ctypes.CDLL('libc.so.6')

    # From sched.h
    # New user namespace
    CLONE_NEWUSER = 0x10000000
    # New network namespace
    CLONE_NEWNET = 0x40000000

    if uid is None:
        uid = os.getuid()
    if gid is None:
        gid = os.getgid()

    ret = libc.unshare(CLONE_NEWNET | CLONE_NEWUSER)
    if ret != 0:
        logger.debug("System doesn't suport disabling network without admin privs")
        return
    with open("/proc/self/uid_map", "w") as f:
        f.write("%s %s 1" % (uid, uid))
    with open("/proc/self/setgroups", "w") as f:
        f.write("deny")
    with open("/proc/self/gid_map", "w") as f:
        f.write("%s %s 1" % (gid, gid))

処理の流れやログから判断すると、この部分がネットワーク制限および、カーネルがサポートしているかをチェックしている部分になる。

    ret = libc.unshare(CLONE_NEWNET | CLONE_NEWUSER)
    if ret != 0:
        logger.debug("System doesn't suport disabling network without admin privs")
        return

libcのunshare関数を呼び出している。

仕組みとしてはタスクを実行するのプロセスのユーザーとネットワークについて名前空間を分離するということをしているようだ。 詳しいわけではないのでざっくりの理解だが、 名前空間を分離することで、分離されたネットワークに対してアクセスすることになるため、 プログラムからは見かけ上、正常にネットワークアクセスできるが、実際には外にパケットが飛んでいかないため、接続エラーになるようなイメージ。

実際に前回試した時のエラーが下記のような感じとなっている。

| Cloning into 'helloworld'...
| fatal: unable to access 'https://github.com/mickey-happygolucky/helloworld.git/': Could not resolve host: github.com
| Cloning into 'helloworld'...
| fatal: unable to access 'https://github.com/mickey-happygolucky/helloworld.git/': Could not resolve host: github.com
| Cloning into 'helloworld'...
| fatal: unable to access 'https://github.com/mickey-happygolucky/helloworld.git/': Could not resolve host: github.com
| -- Had to git clone more than once:
|           3 times.
| CMake Error at /home/mickey/work/yocto/kirkstone/build/tmp/work/core2-64-poky-linux/example/0.1-r0/build/helloworld/tmp/helloworld-gitclone.cmake:31 (message):
|   Failed to clone repository:
|   'https://github.com/mickey-happygolucky/helloworld.git'
(...snip...)
Summary: 1 task failed:
  /home/mickey/work/yocto/kirkstone/build/meta-test/recipes-example/example/example_0.1.bb:do_compile

おそらくその理解で問題ないだろう。

使用されるカーネル機能

libc.unshare(CLONE_NEWNET | CLONE_NEWUSER)によってどのようなカーネル機能が必要となるかを調べる。

google先生に聞いたらドンピシャですごいテストコードが出てきた。

# Requires at least kernel configuration options:
#   CONFIG_NAMESPACES=y
#   CONFIG_NET_NS=y
#   CONFIG_UTS_NS=y
def IfPossibleEnterNewNetworkNamespace():
  """Instantiate and transition into a fresh new network namespace if possible."""
  sys.stdout.write('Creating clean namespace... ')
  try:
    UnShare(CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWNET)
  except OSError as err:
    print('failed: %s (likely: no privs or lack of kernel support).' % err)
    return False

UnShareもbitbakeのコードのlibc.unshare(CLONE_NEWNET | CLONE_NEWUSER)と同じようにlibcのunshare関数を呼び出すだけのものと推測できる。

コメントのカーネルコンフィグと、UnShareに渡しているフラグはおそらく1:1になっているので、CLONE_NEWNETに対応するカーネル機能はCONFIG_NET_NS=yと読み取ることができる。

Ubuntuカーネルの対応状況

Ubuntu 20.04のカーネルはこの機能に対応しているのか確認してみる。

$ grep 'CONFIG_NET_NS' /boot/config-$(uname -r)
CONFIG_NET_NS=y
CONFIG_NET_NSH=m

対応していることがわかる。

既に修正済みだが前回の記事で、下記のように記載してしまった。

Ubuntu 20.04の環境では、全く問題なくビルドできてしまった。

これは実際には間違いで、Ubuntu 20.04 + CROPS の環境では問題なくビルドができたということだった。

CROPSはDockerコンテナを使用しているということが大きな違いとなる。

なぜUbuntu 20.04カーネルを使用していてもCROPS経由だとこの機能をすり抜けたのか。これはunshareに原因がある。

Dockerコンテナは既に名前空間が分離された状態になるため、コンテナ上でunshareを実行した場合、ホスト上で実行した場合と結果が変わることがある。(筆者は変わらないケースを確認していないが。)

ホスト上とコンテナ上でのunshareの挙動の違い

まず下記のようなプログラムを作成して挙動を確認する。

#include <iostream>
#include <sched.h>

int main() {
    auto ret = ::unshare(CLONE_NEWNET|CLONE_NEWUSER);
    std::cout << "unshare: ret=" << ret << std::endl;
    return 0;
}

main.cppとして保存して下記のようにコンパイルする。

$ g++ ./main.cpp -O -o call_unshare

ホスト上での実行結果

$ ./call_unshare 
unshare: ret = 0

問題なく0(成功)が返る。

Dockerコンテナ上での実行

$ docker run --rm -it -v "$(pwd):/work" ubuntu:20.04 bash -c '/work/call_unshare'
unshare: ret = -1

想定した通りに-1(失敗)が返る。

bitbakeは下記の処理によって、コンテナ上で実行される場合、システムが機能をサポートしていないとみなしてしまう。

    ret = libc.unshare(CLONE_NEWNET | CLONE_NEWUSER)
    if ret != 0:
        logger.debug("System doesn't suport disabling network without admin privs")
        return

ある意味抜け穴。

まとめ

  • Kirkstoneで実装されたタスクのネットワークアクセス制限の実装を調べた。
  • unshare(CLONE_NEWNET | CLONE_NEWUSER)によってネットワークの名前空間を分離することで実現していることがわかった。
  • Ubuntu 20.04 + CROPSでこのチェックが機能しなかった原因はDockerコンテナ上でunshareが失敗するため。
  • コンテナ上ではネットワークアクセス制限のチェックが素通りしてしまうことがわかった。