フューチャー技術ブログ

Go 1.23リリース連載 archive/tar

はじめに

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つです。

tarstructure.png

https://techoverflow.net/2013/03/29/reading-tar-files-in-c/ より引用)

この形式をファイルの数だけ繰り返しでtarは構成されます。また、ファイルの終わりは end-of-archive を表す0パディングされたブロックが2つ付加されます(グレー色のブロック2つはそれを表現しています)。

tar.png

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の作成時にはunamegname を取得するため、uidgid を元に /etc/passwd/etc/group を検索します。Go上では os/userLookupId()LookupGroupId() を呼び出しています。この操作により、TARファイルには、uid/giduname/gname の両方が格納されます。

今回のアップデートと関係はないですが、ちなみにtarの展開時における unamegname と 3のuid、4の gid の扱いについてルールは以下です。

  • UStarインジケーター(Magic)に “ustar” を含む場合、unamegname フィールドにファイルの所有者とグループが入る
  • uidgidは、unamegnameの値を元に /etc/passwd/etc/group から unamegname で探し、一致する名前があればその値用いる
  • 一致しなければメタデータに含まれるuidgid を用いる

困りどころ

#50102に背景の記載があります。

まずGoでは以下の前提がありました。

これにより発生した課題は次のように述べられています。

  1. chroot 環境では、/etc/passwd および /etc/group が存在しないか、その内容がホストの内容とまったく異なる可能性がある
  2. 失敗した名前検索はキャッシュされないため、tarに追加されたファイルエントリ数だけ /etc/passwd/etc/group が再解析されるため、パフォーマンス低下の懸念がある
  3. glibcを静的リンクする場合、lookupuser同等の glibc メソッドが呼び出されるときに、実行時にいくつかのライブラリをdlopen(共有ライブラリを動的にロードする関数)しようとする。利用できない場合はpanicかクラッシュし、信頼できないchrootの場合(攻撃者の場合)は、悪意のあるライブラリに置き換えられてしまう恐れがある
  4. GNU tar の --numeric-owner オプションと同様に、ユーザー名/グループ名なし (数値の uid/gid のみ) の tarball を作成する必要な場合がある(※これは対応案のような?)
  5. カスタム 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 しているが cgroupsseccomp はしていないため)。この対応として、archive/tar をフォークしてGo1.10より前のバージョンを維持しているそうです。

Go 1.23ではどうしようとしたか

プロポーサルでいくつかの案が書かれていました。

  • ルックアップを無効化するビルドダグの導入
  • archive/tar用の独自検索機能を提供
  • ルックアップを無効化するオプションを提供(tar.NameLookup = falseなど)
  • 互換性から難しいと思うが、 archive/tarからルックアップを削除する

議論の中で上記の選択肢はどれも問題があるか影響度が大きいということで、新しいインタフェースを追加していれば、こちらを優先して用いることにしようとなりました。

追加されたインターフェース
type FileInfoNames interface {
fs.FileInfo
// Uname should give a user name.
Uname() (string, error)
// Gname should give a group name.
Gname() (string, error)
}

上記が実装されていれば、uid/gid/uname/gname のルックアップを行わせずに済むような制御を加えることができます。tarコマンドの --numeric-ownerオプション相当の挙動にする場合はともかく、やりたいことによっては引数に uid/gid が必要じゃないか? って思いましたが、fs.FileInfo からフィールドにアクセスできるようです。そのため、引数なしのシンプルなシグネチャです。

実装

まずimgフォルダ配下の画像ファイルを、distフォルダにtarでアーカイブします。まだGo 1.23のアップデート機能は利用しません。次のような構成です。

.
└── tar123
├── dist
│   └── archive.tar
├── img
│   ├── image_1.png
│   ├── image_2.png
│   └── image_3.png
└── main.go
main.go
package main

import (
"archive/tar"
"fmt"
"io"
"log"
"os"
"path/filepath"
)

func main() {
dist, err := os.Create("dist/archive.tar")
if err != nil {
log.Fatal(err)
}
defer dist.Close()

tw := tar.NewWriter(dist)
defer tw.Close()

// 再帰的にファイルを取得する
if err := filepath.Walk("./img", func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("filepath walk: %w", err)
}

if info.IsDir() {
return nil // ディレクトリは無視
}

header, err := tar.FileInfoHeader(info, "")
if err != nil {
return fmt.Errorf("fileinfo header: %w", err)
}

// ヘッダを書き込み
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header: %w", err)
}

// ファイルを書き込み
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open source file: %w", err)
}
defer f.Close()
if _, err := io.Copy(tw, f); err != nil {
return fmt.Errorf("write tar file: %w", err)
}
return nil
}); err != nil {
log.Fatal(err)
}

}

作成したtarファイルを確認すると、gname/uname(mano/manoの部分) が入っています。

$ tar tfv dist/archive.tar
-rw-r--r-- mano/mano 87275 2024-07-12 13:25 image_1.png
-rw-r--r-- mano/mano 19253 2024-07-12 13:26 image_2.png
-rw-r--r-- mano/mano 213567 2024-07-12 13:26 image_3.png

コードを書き換えます。FileInfoNamesインタフェースを満たす emptyFileInfoNamesを宣言して利用し、uname/gname の値をカスタマイズします。この例では空文字を返すことにします。

package main

import (
"archive/tar"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
)

+ type emptyFileInfoNames struct {
+ fs.FileInfo
+ }
+
+ func (fi emptyFileInfoNames) Uname() (string, error) {
+ return "", nil
+ }
+
+ func (fi emptyFileInfoNames) Gname() (string, error) {
+ return "", nil
+ }
+
func main() {
dist, err := os.Create("dist/archive.tar")
if err != nil {
log.Fatal(err)
}
defer dist.Close()

tw := tar.NewWriter(dist)
defer tw.Close()

// 再帰的にファイルを取得する
if err := filepath.Walk("./img", func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("filepath walk: %w", err)
}

if info.IsDir() {
return nil // ディレクトリは無視
}

- header, err := tar.FileInfoHeader(info, "")
+ header, err := tar.FileInfoHeader(emptyFileInfoNames{
+ FileInfo: info,
+ }, "")
if err != nil {
return fmt.Errorf("fileinfo header: %w", err)
}

// ヘッダを書き込み
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header: %w", err)
}

// ファイルを書き込み
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open source file: %w", err)
}
defer f.Close()
if _, err := io.Copy(tw, f); err != nil {
return fmt.Errorf("write tar file: %w", err)
}
return nil
}); err != nil {
log.Fatal(err)
}

}

tarファイルを確認すると、gname/uname は取得できないので、gid/uid (1000/1000の部分)が表示されています。

$ tar tfv dist/archive.tar
-rw-r--r-- 1000/1000 87275 2024-07-12 13:25 image_1.png
-rw-r--r-- 1000/1000 19253 2024-07-12 13:26 image_2.png
-rw-r--r-- 1000/1000 213567 2024-07-12 13:26 image_3.png

無事 --numeric-owner オプション相当の動作になりました。

まとめ

archive/tar パッケージのアップデートについて説明するために、TARの概要、ファイルの構造、メタデータ、gname/uname の取得ルールと脆弱性、Go 1.23で行った対応と実装例について説明しました。

Go 1.23リリースと言いながら、あまりGoで関係ない内容ですが調べていて面白かったです。

次は棚井さんのiterパッケージです。

参考

tarについてもっと興味がある方向けのリンクです(Goは直接関係ないです)。