TL;DR

overlayfsでマウントされるディレクトリの内容は普通に上書きできるし,Dockerはビルド時に再確認とかしないのでちゃんと守ろう.

agenda

about

Dockerのイメージをビルドする際,主にDockerfileの行毎に操作がキャッシュされる.

高速化の為になるべく一行に操作をまとめる(例: apt-get install -y hoge fuga piyo foo bar baz qux quux foobar...)など,普段からキャッシュを意識することは多いだろう.

参考

しかし,そのキャッシュがどこでどのように管理され,使われているのか.という点については,ありがたい事に意識せずともDockerを使えている.

本記事では,そのキャッシュの保存先と使われ方,そしてキャッシュに対するAttack Vectorについて説明する.

検証環境は以下

Linux ubuntu-jammy 5.13.0-19-generic #19-Ubuntu SMP Thu Oct 7 21:58:00 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
Docker version 20.10.7, build 20.10.7-0ubuntu5

cache

aboutで説明したように,Dockerはイメージのビルド時にキャッシュを行っている.

変更された部分とそれ以降の操作のみが更新される.

今回は以下のような非常に簡単なコンテナを例に説明を行う.

Dockerfile

FROM nginx:1.21.4
COPY content.html /usr/share/nginx/html

content.html

<!DOCTYPE HTML>
<html>
<body>
    <h1>I'm a general web page.</h1>
</body>
</html>

静的なWebページをnginxを使って配信するようなもの.

これ自体はサンプルのような非常に単純なものだが,ここから学べることは多い.

実際にimageをbuildしてみる.

root@ubuntu-jammy:~/docker_test# docker image build -t example .
Sending build context to Docker daemon  3.072kB
Step 1/2 : FROM nginx:1.21.4
1.21.4: Pulling from library/nginx
7d63c13d9b9b: Pull complete
15641ef07d80: Pull complete
392f7fc44052: Pull complete
8765c7b04ad8: Pull complete
8ddffa52b5c7: Pull complete
353f1054328a: Pull complete
Digest: sha256:6ff52ff9299052a1454df88f6a46adefedac67dd7350cfaf510b9f1fdd1dafab
Status: Downloaded newer image for nginx:1.21.4
 ---> 04661cdce581
Step 2/2 : COPY content.html /usr/share/nginx/html
 ---> 2dde5de6508f
Successfully built 2dde5de6508f
Successfully tagged example:latest

一度目のビルドでは,特筆するような点も無く,レジストリからベースとなるイメージをpullし,Dockerfileに書かれている通り,content.htmlをコピーしている(このコピー先が後々重要になる).

コンテナを実行してみると,想定通りの挙動をしているのがわかる.

root@ubuntu-jammy:~/docker_test# docker run -td --rm -p 80:80 example
53f148284a653b403d2480c4759f62edc7abf495b2166020143747ecfe06420e
root@ubuntu-jammy:~/docker_test# curl http://localhost:80/content.html
<!DOCTYPE HTML>
<html>
<body>
        <h1>I'm a general web page.</h1>
</body>
</html>

さて,ここからが本題のcacheの話.

一度コンテナをkillし,content.htmlを書き換えてみる.

こんな貧相なページが今時一般的を名乗るのはどうかと思うので,generalからexampleに書き換えてみる.

root@ubuntu-jammy:~/docker_test# sed -i 's/general/example/g' content.html
root@ubuntu-jammy:~/docker_test# cat content.html
<!DOCTYPE HTML>
<html>
<body>
        <h1>I'm a example web page.</h1>
</body>
</html>
root@ubuntu-jammy:~/docker_test# docker image build -t example .
Sending build context to Docker daemon  3.072kB
Step 1/2 : FROM nginx:1.21.4
 ---> 04661cdce581
Step 2/2 : COPY content.html /usr/share/nginx/html
 ---> 49cba37526ed
Successfully built 49cba37526ed
Successfully tagged example:latest

コンテナを実行し,接続してみると

root@ubuntu-jammy:~/docker_test# docker run -td --rm -p 80:80 example
6d5c67f97f0717d7d20f75c7eac5ccdb351d6dfdb77c88d02d3c4890ceffc6a7
root@ubuntu-jammy:~/docker_test# curl http://localhost:80/content.html
<!DOCTYPE HTML>
<html>
<body>
        <h1>I'm a example web page.</h1>
</body>
</html>

しっかりとCOPY対象となるファイルの更新も追従し,一致しない場合は更新されている事がわかる.

さて,これらのファイルがどこに保存されているのかを探してみる.

root@ubuntu-jammy:~/docker_test# find /var/lib/docker/ -name "content.html"
/var/lib/docker/overlay2/3a1becce555355ab763134eacb1f50e716895c2f28141eea1a2826be43ee7a72/merged/usr/share/nginx/html/content.html
/var/lib/docker/overlay2/64663e6ea64ffa64d2cc4159ced8aef9aad1b1c7ff3101bcebe494aba500c13b/diff/usr/share/nginx/html/content.html
/var/lib/docker/overlay2/1d12bd16862c49663e06dd54d0760899751c4dc6751a5a1d5e0af1a8a0681504/diff/usr/share/nginx/html/content.html

わかりやすい名前をつけたので,これを元にfindしてみると,/var/lib/docker/overlay2以下にあることがわかった.

mount

前章で見つけたoverlay2にあるディレクトリがどうやって使われるかというと,名前の通りoverlayfsによってコンテナにマウントされる.

DockerとOverlayFSについては,ドキュメントを読むとわかる.

overlayfsには

の4つのディレクトリがあり

これらを重ね合わせて一つのディレクトリのように振る舞う.

Dockerでの割り当ては,非常にわかりやすいドキュメントの図を借りる.

overlay-construct

実行中のコンテナにマウントされているoverlayfsの情報は,

root@ubuntu-jammy:~/docker_test# mount |grep overlay
overlay on /var/lib/docker/overlay2/3a1becce555355ab763134eacb1f50e716895c2f28141eea1a2826be43ee7a72/merged type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/PY5YN23JCAOJGMIQCYRULF2UUG:/var/lib/docker/overlay2/l/SFTJ7ZNUPJWBAX5S7JQNSKQKSI:/var/lib/docker/overlay2/l/VH73CF5WOJCTM5NL2XCJQ4VBTL:/var/lib/docker/overlay2/l/EAP2AAJERWAXYJUUAQ4H6AK77Q:/var/lib/docker/overlay2/l/FODK5RVEJKMG2IXFYMX54AMGZD:/var/lib/docker/overlay2/l/YVOWKUSMURD23MIDARBBP224IE:/var/lib/docker/overlay2/l/JSSCHZGUXNN655DO4UUH656VW5:/var/lib/docker/overlay2/l/UQEORYLGWWOACPZRMIV6EIFDFU,upperdir=/var/lib/docker/overlay2/3a1becce555355ab763134eacb1f50e716895c2f28141eea1a2826be43ee7a72/diff,workdir=/var/lib/docker/overlay2/3a1becce555355ab763134eacb1f50e716895c2f28141eea1a2826be43ee7a72/work)

のように取得できる.

attack

マウントされるディレクトリがわかったので,その内容を見たり書き換えたりしたくなるのは好奇心旺盛なオタクとしては当然の事.

改めて,mountの情報をちょっとわかりやすくすると

overlay on /var/lib/docker/overlay2/3a1becce555355ab763134eacb1f50e716895c2f28141eea1a2826be43ee7a72/merged

といった感じで,lowerdirが多いのがわかる

lowerdirの情報を見てみると,それぞれ/var/lib/docker/overlay2/以下のディレクトリへのリンクとなっている

root@ubuntu-jammy:~/docker_test# ls -l /var/lib/docker/overlay2/l
total 40
lrwxrwxrwx 1 root root 72 Nov 15 11:37 EAP2AAJERWAXYJUUAQ4H6AK77Q -> ../6632727f6a71360d32c979af4f307fbc98e0115ad1bd1d8f7776a0a8224a7812/diff
lrwxrwxrwx 1 root root 72 Nov 15 11:37 FODK5RVEJKMG2IXFYMX54AMGZD -> ../1cef8729ff1d4c130aa199087771082adea6483da3639d46debba2fd2f5c24ae/diff
lrwxrwxrwx 1 root root 72 Nov 15 11:37 JSSCHZGUXNN655DO4UUH656VW5 -> ../868df438860763112d80d2f3f7d00c340ec9fdb264db072baa15783f09b87d69/diff
lrwxrwxrwx 1 root root 72 Nov 25 14:08 L3ZCGP35TEETJO76AEONNB4SOL -> ../3a1becce555355ab763134eacb1f50e716895c2f28141eea1a2826be43ee7a72/diff
lrwxrwxrwx 1 root root 77 Nov 25 14:08 PY5YN23JCAOJGMIQCYRULF2UUG -> ../3a1becce555355ab763134eacb1f50e716895c2f28141eea1a2826be43ee7a72-init/diff
lrwxrwxrwx 1 root root 72 Nov 15 11:41 SFTJ7ZNUPJWBAX5S7JQNSKQKSI -> ../1d12bd16862c49663e06dd54d0760899751c4dc6751a5a1d5e0af1a8a0681504/diff
lrwxrwxrwx 1 root root 72 Nov 15 11:37 UQEORYLGWWOACPZRMIV6EIFDFU -> ../7083a6ff9aa49604626eae2eea3b4749a028129ca86de974f1cb76123351f90a/diff
lrwxrwxrwx 1 root root 72 Nov 15 11:37 V7AVUGDX3ZLGPBFLVJVZ2L45FZ -> ../64663e6ea64ffa64d2cc4159ced8aef9aad1b1c7ff3101bcebe494aba500c13b/diff
lrwxrwxrwx 1 root root 72 Nov 15 11:37 VH73CF5WOJCTM5NL2XCJQ4VBTL -> ../979b5dc3733647e285e2f22436c11aba2b6a30eaec3449ce0946008b6f4ead68/diff
lrwxrwxrwx 1 root root 72 Nov 15 11:37 YVOWKUSMURD23MIDARBBP224IE -> ../ef66da561d73f85ce62231f2674b14f8bbe6794a8f167c24b4b451fcd3be113c/diff

content.htmlがある64663e6ea64ffa64d2cc4159ced8aef9aad1b1c7ff3101bcebe494aba500c13b/diff1d12bd16862c49663e06dd54d0760899751c4dc6751a5a1d5e0af1a8a0681504/diffも含まれているのがわかる.

それぞれ内容を見てみると,変更に対応しているのがわかる.

root@ubuntu-jammy:~/docker_test# cat /var/lib/docker/overlay2/64663e6ea64ffa64d2cc4159ced8aef9aad1b1c7ff3101bcebe494aba500c13b/diff/usr/share/nginx/html/content.html
<!DOCTYPE HTML>
<html>
<body>
        <h1>I'm a general web page.</h1>
</body>
</html>
root@ubuntu-jammy:~/docker_test# cat /var/lib/docker/overlay2/1d12bd16862c49663e06dd54d0760899751c4dc6751a5a1d5e0af1a8a0681504/diff/usr/share/nginx/html/content.html
<!DOCTYPE HTML>
<html>
<body>
        <h1>I'm a example web page.</h1>
</body>
</html>

おそらく差分毎にディレクトリが生えてきてキャッシュされているんじゃないかと推測できる.

楽しそうなのでこのキャッシュを書き換えてみる

仮説

DockerfileでCOPYしているファイルの変更は検知している上,そちらの方が優先順位は高そうなので,ベースとなるキャッシュとの違いがあればビルド用の内容で上書きされるのでは?

実証

root@ubuntu-jammy:~/docker_test# sed -i 's/example/hacked/g' /var/lib/docker/overlay2/1d12bd16862c49663e06dd54d0760899751c4dc6751a5a1d5e0af1a8a0681504/diff/usr/share/nginx/html/content.html
root@ubuntu-jammy:~/docker_test# cat /var/lib/docker/overlay2/1d12bd16862c49663e06dd54d0760899751c4dc6751a5a1d5e0af1a8a0681504/diff/usr/share/nginx/html/content.html
<!DOCTYPE HTML>
<html>
<body>
        <h1>I'm a hacked web page.</h1>
</body>
</html>
root@ubuntu-jammy:~/docker_test# docker run -td --rm -p 80:80 example
8d064de9a8d4a64eccea80eb32714bacddb82914d78f2fd4ca60568f960ace82
root@ubuntu-jammy:~/docker_test# curl http://localhost:80/content.html
<!DOCTYPE HTML>
<html>
<body>
        <h1>I'm a hacked web page.</h1>
</body>
</html>

ここまでは想定通り.

マウントされるディレクトリを書き換えているので,当然コンテナ内のファイルも書き換わっている.

改めてimageをbuildしてみる.ここでは上書きされてほしい.

root@ubuntu-jammy:~/docker_test# docker image build -t example .
Sending build context to Docker daemon  3.072kB
Step 1/2 : FROM nginx:1.21.4
 ---> 04661cdce581
Step 2/2 : COPY content.html /usr/share/nginx/html
 ---> Using cache
 ---> 49cba37526ed
Successfully built 49cba37526ed
Successfully tagged example:latest
root@ubuntu-jammy:~/docker_test# docker image build -t example .
Sending build context to Docker daemon  3.072kB
Step 1/2 : FROM nginx:1.21.4
 ---> 04661cdce581
Step 2/2 : COPY content.html /usr/share/nginx/html
 ---> Using cache
 ---> 49cba37526ed
Successfully built 49cba37526ed
Successfully tagged example:latest
root@ubuntu-jammy:~/docker_test# docker run -td --rm -p 80:80 example
08a2134b097becbb39bf5e72dde6ab378bbf62b13f482c10cb471868143190f7
root@ubuntu-jammy:~/docker_test# curl http://localhost:80/content.html
<!DOCTYPE HTML>
<html>
<body>
        <h1>I'm a hacked web page.</h1>
</body>
</html>

Oops!

Using cacheの文言で怪しさはあったが,残念ながらマウントされるディレクトリの変更は検知できていないようだ.

まだコードを読んでないので想像にすぎないが,docker image buildで検証されるcacheは,image buildのコンテキストだけで,実際に/var/lib/docker/overlay2へ変更を加えるのは検証後なのでは?

影響

攻撃への転用はまだ確認されていないらしい(security@docker.comに連絡したりした).

そもそもrootが必要なので割と軽視されがちだが,Post Exploitの永続化等,攻撃に転用される可能性は0では無い.

緩和策

とりあえずimageのbuild時にcacheを使用しないようにする(--no-cache)とか,/var/lib/docker/overlay2/を監視(ex: fantom)するとか.


この記事はIPFactory Advent Calendar 2021の12/01分です.

IPFactoryというサークルについてはこちらをご覧ください.

明日はn01e0による,なにかです.