はじめに
TIG 真野です。Go1.23連載の2本目です。
Go 1.22のマイナーアップデート、 archive/tar を取り上げます。
アップデートサマリ
- 新しく追加された
FileInfoNamesインタフェースを利用することで、FileInforHeader()がUname/Gnameの値をシステム依存から、アプリケーション側で制御可能になった #50102
私のようにGoでDBから値を取って来てJSONを返す典型的なWeb APIを実装する生活を送っていると、tarの文脈におけるUname, Gnameといった用語に触れることが少ないかなと思います(そうじゃない人はごめんなさい!)。そのため、基礎的な部分から説明していきます。
tarとは
tape archivesの略で、複数のファイルやディレクトリを1つにまとめる(アーカイブする)ことです。アーカイブしたファイルはTarballとも呼ばれます。他のアーカイブ手段はzipなどがあります。アーカイブは保存記録などと略され、tarもバックアップやデータ移行などを目的として磁気テープなどへの書き出しに用いられてきました。同名のLinuxの tarコマンドがあり、拡張子は .tar であるといったことが基本情報でしょうか。
参考: https://ja.wikipedia.org/wiki/Tar
TARファイルの構造
tarにはファイルの属性などのメタデータを持つ仕様があります。次のようにファイルの実体に対して、メタデータ項目がヘッダーとして付与されます。各ブロックは512byteごとに区切られ、余った場合は0パディングされることが特徴の1つです。
(https://techoverflow.net/2013/03/29/reading-tar-files-in-c/ より引用)
この形式をファイルの数だけ繰り返しでtarは構成されます。また、ファイルの終わりは end-of-archive を表す0パディングされたブロックが2つ付加されます(グレー色のブロック2つはそれを表現しています)。
(https://jackrabbit.apache.org/oak/docs/nodestore/segment/tar.html より引用)
ヘッダの中身ですが、tarが生まれてから歴史が長いこともあり複数の実装があり、その中の1つであるUStar(Unix Standard TARの略)形式は次のメタデータを持ちます。
| No | オフセット | サイズ | 内容 |
|---|---|---|---|
| 1 | 0 | 100 | ファイル名 |
| 2 | 100 | 8 | ファイルモード |
| 3 | 108 | 8 | 所有者 User ID(uid) |
| 4 | 116 | 8 | グループ User ID(gid) |
| 5 | 124 | 12 | ファイルサイズ(Byte) |
| 6 | 136 | 12 | 最終更新時刻(UNIX時間) |
| 7 | 148 | 8 | チェックサム |
| 8 | 156 | 1 | タイプフラグ |
| 9 | 157 | 100 | リンクされたファイルの名前 |
| 10 | 257 | 6 | UStarインジケーター(Magic) |
| 11 | 263 | 2 | UStarバージョン、「00」 |
| 12 | 265 | 32 | 所有者ユーザー名(uname) |
| 13 | 297 | 32 | 所有者グループ名(gname) |
| 14 | 329 | 8 | デバイスのメジャー番号 |
| 15 | 337 | 8 | デバイスのマイナー番号 |
| 16 | 345 | 155 | ファイル名のプレフィックス |
合計は500[byte]です。記載していませんが残りの12byteはパディングされて保持されます。
Uname、Gnameとは何か
先ほどの表にある、12.所有者ユーザ名(uname)・13.所有者グループ名(gname)が該当します。
Uname、Gnameのルール
さて、今回問題になるのはtar作成時の話です。
tarの作成時にはuname、 gname を取得するため、uid、gid を元に /etc/passwd、/etc/group を検索します。Go上では os/user の LookupId()と LookupGroupId() を呼び出しています。この操作により、TARファイルには、uid/gid と uname/gname の両方が格納されます。
今回のアップデートと関係はないですが、ちなみにtarの展開時における uname、gname と 3のuid、4の gid の扱いについてルールは以下です。
- UStarインジケーター(Magic)に “ustar” を含む場合、
unameとgnameフィールドにファイルの所有者とグループが入る uid、gidは、uname、gnameの値を元に/etc/passwd、/etc/groupからuname、gnameで探し、一致する名前があればその値用いる- 一致しなければメタデータに含まれる
uid、gidを用いる
困りどころ
#50102に背景の記載があります。
まずGoでは以下の前提がありました。
- Go1.10から、
tar/archiveのFileInfoHeader関数で、前章で述べたuid/gidからuname/gnameを検索する仕組みが入った
これにより発生した課題は次のように述べられています。
- chroot 環境では、
/etc/passwdおよび/etc/groupが存在しないか、その内容がホストの内容とまったく異なる可能性がある - 失敗した名前検索はキャッシュされないため、tarに追加されたファイルエントリ数だけ
/etc/passwd、/etc/groupが再解析されるため、パフォーマンス低下の懸念がある - glibcを静的リンクする場合、lookupuser同等の glibc メソッドが呼び出されるときに、実行時にいくつかのライブラリをdlopen(共有ライブラリを動的にロードする関数)しようとする。利用できない場合はpanicかクラッシュし、信頼できないchrootの場合(攻撃者の場合)は、悪意のあるライブラリに置き換えられてしまう恐れがある
- GNU tar の
--numeric-ownerオプションと同様に、ユーザー名/グループ名なし (数値の uid/gid のみ) の tarball を作成する必要な場合がある(※これは対応案のような?) - カスタム uid -> uname および gid -> gname検索関数を必要とする場合がある(※これも対応案のような)
2はキャッシュを実行する優れたCライブラリを使用するか、失敗した検索も(Go側で)キャッシュすることで軽減可能。3はビルドタグを使用することが解決できるが、os/userパッケージの利用全体に影響あり。
影響としてDockerのRemove local fork of Golang’s archive/tar #42402が上げられています。
2019年にコンテナブレーク(コンテナからホストに脱出できる)リスクがあると話題になったCVE-2019-14271があります。説明はDocker、これまでで最も深刻な cp コマンドの脆弱性CVE-2019-14271を修正が詳しいです。簡単に説明すると、当時の脆弱なバージョンのDockerを利用している+悪意のあるイメージ(libness_*.so ライブラリを悪意のあるコードに置き換えたもの)を利用+docker cp コマンドを実行することが条件です。docker cp コマンドは内部的に docker-tar というプロセスが動き、コンテナ・ホスト側をtarファイルを作成してやり取りします。このとき、symlink(シンボリックリンク)の問題があるので、docker-tarはコンテナのディレクトリにchrootする必要があります。そのため、tar作成時にglibcによりロードされる共有ライブラリを悪意のあるファイルに差し替えすることで、ホスト側のルート権限を取得できるとのことです(docker-tar はchroot しているが cgroups や seccomp はしていないため)。この対応として、archive/tar をフォークしてGo1.10より前のバージョンを維持しているそうです。
Go 1.23ではどうしようとしたか
プロポーサルでいくつかの案が書かれていました。
- ルックアップを無効化するビルドダグの導入
- archive/tar用の独自検索機能を提供
- ルックアップを無効化するオプションを提供(tar.NameLookup = falseなど)
- 互換性から難しいと思うが、 archive/tarからルックアップを削除する
議論の中で上記の選択肢はどれも問題があるか影響度が大きいということで、新しいインタフェースを追加していれば、こちらを優先して用いることにしようとなりました。
type FileInfoNames interface { |
上記が実装されていれば、uid/gid→/uname/gname のルックアップを行わせずに済むような制御を加えることができます。tarコマンドの --numeric-ownerオプション相当の挙動にする場合はともかく、やりたいことによっては引数に uid/gid が必要じゃないか? って思いましたが、fs.FileInfo からフィールドにアクセスできるようです。そのため、引数なしのシンプルなシグネチャです。
実装
まずimgフォルダ配下の画像ファイルを、distフォルダにtarでアーカイブします。まだGo 1.23のアップデート機能は利用しません。次のような構成です。
. |
package main |
作成したtarファイルを確認すると、gname/uname(mano/manoの部分) が入っています。
$ tar tfv dist/archive.tar |
コードを書き換えます。FileInfoNamesインタフェースを満たす emptyFileInfoNamesを宣言して利用し、uname/gname の値をカスタマイズします。この例では空文字を返すことにします。
package main |
tarファイルを確認すると、gname/uname は取得できないので、gid/uid (1000/1000の部分)が表示されています。
$ tar tfv dist/archive.tar |
無事 --numeric-owner オプション相当の動作になりました。
まとめ
archive/tar パッケージのアップデートについて説明するために、TARの概要、ファイルの構造、メタデータ、gname/uname の取得ルールと脆弱性、Go 1.23で行った対応と実装例について説明しました。
Go 1.23リリースと言いながら、あまりGoで関係ない内容ですが調べていて面白かったです。
次は棚井さんのiterパッケージです。
参考
tarについてもっと興味がある方向けのリンクです(Goは直接関係ないです)。
- tarで所有者(ユーザーID/グループID)が変わってしまう時は - Kujipedia
- 同じユーザー/グループ名が異なるuid/gidを持つマシンでtarの受け渡しで問題が生じるケースについてわかりやすい説明うをしてくれています
- https://qiita.com/ko1nksm/items/fbcff63639c5d141e76d
- tarの歴史についても学べて面白かったです
- NimでTarファイル作成を実装する ~ちゃぶだい返し(理論編)~
- 実装する時に考慮したことがまとめられていて面白かったです
- TAR(5)
- メタデータについてCの構造体が参考になりました