Skip to main content

RPM Package Manager:SnykによるRPMパッケージのセキュリティスキャン

著者
Headshot of Ivan Stanev

Ivan Stanev

2020年11月13日

0 分で読めます

コンテナイメージのスキャンでは、SnykはOSディストリビューション、ソフトウェアパッケージマネージャー、インストール済みアプリケーション、そしてアプリケーションの依存関係など、さまざまな情報を検出できます。RPMはLinuxエコシステムで最も一般的なパッケージマネージャーの1つであり、Snykで完全にサポートされています。

RPM Package Managerとは?

RPMは、Red Hat Enterprise Linux、Fedora、CentOSなど、多くのLinuxディストリビューションで使用されています。Red Hat Linuxで生まれ、当初はRedHat Package Managerの略称でしたが、現在はRPM Package Managerを表す再帰的頭字語となっています。RPMはパッケージマネージャーを指す場合と、アプリケーションの配布に使用されるrpmファイル形式を指す場合があります。

RPMで使われるデータ形式とは?

RPMパッケージマネージャーの扱いが特に難しいのは、依存関係が人間に読み取れる形式(JSONやXMLなど)で一覧表示されないためです。代わりに、パッケージはBerkeleyDBデータベース内にバイナリ形式で保存されます。そのため、データベースとデータベースエントリーの正確なバイナリ形式とレイアウトがわからなければ、パッケージを読み取ることはできません。

RPMデータベースを読み取るオープンソースコードはありましたが、Snykの技術スタックはNode.jsとTypeScriptをベースとしているため、簡単には利用できませんでした。BerkeleyDB用のCバインディングをコンパイルして使用する方法もありますが、クロスコンパイルやメンテナンスのオーバーヘッドを避けたかったのです。また、必要なのはRPMパッケージを読み取るライブラリだけで、RPMとBerkeleyDBが提供する機能のごく一部にすぎませんでした。

オープンソースの力を借りて、RPMで使われるBerkeleyDBのライセンスではコードを自由に読み、変更できることがわかったため、私たちは機能を自分たちで再現することにしました。最終的に課題を解決し、完全なオープンソースのTypeScriptライブラリを公開しました。こちらのSnykのGitHub上のrpm-parserリポジトリでご覧いただけます。

このブログでは、その方法を紹介し、RPMデータベースの内部構造を解き明かす手順を説明します。低レベルなビットやバイトをいじる作業に備えてください! ?️

RPMデータベースの構造は?

BerkeleyDBはバイナリデータベースですが、どうすれば意味のある内容を取り出せるでしょうか?バイナリファイル形式は通常、ファイルの先頭を調べて識別します。たとえば、ファイルの種類を示す特別なマーカー(「マジックナンバー」など)が見つかることがあります。これを調べる最善の方法は、ソースコードを読み、RPMデータベースのコピーで試すことです。RPMが作成するBerkeleyDBデータベースは/var/lib/rpm/Packagesに保存されているため、まずその内容を調べます。BerkeleyDBのソースコードによると、ファイルの先頭数バイトには次の情報が含まれています。

typedef struct _dbmeta33 {
  DB_LSN  lsn;          /* 00-07: LSN. */  db_pgno_t pgno;         /* 08-11: Current page number. */  u_int32_t magic;        /* 12-15: Magic number. */  u_int32_t version;    /* 16-19: Version. */  u_int32_t pagesize;    /* 20-23: Pagesize. */  u_int8_t  encrypt_alg;  /*    24: Encryption algorithm. */  u_int8_t  type;         /*    25: Page type. */  /* omitted */}

BerkeleyDBは、B-Tree、Hash(HStore)、Queueのほか、比較的新しいバージョンではその他の種類のデータベースもサポートしています。データベースの種類ごとにマジックナンバーは異なり、B-Treeは0x053162、Hashは0x061561、Queueは0x042253です。開いたRPMデータベースはすべてHash型であることがわかりました。/var/lib/rpm/Packagesファイルの12バイト目から0x061561が見つかり、さらにtypeには0x08が格納されています。ソースコードの型定義では、これは次のように示されています。

#defineP_HASHMETA8/* Hash metadata page. */

型定義内のpagesizeフィールドと、その後に続くlast_pgno(最終ページ番号)から、データベースが同じサイズのページに分割されているとわかります。つまり、個々のデータ項目が複数のデータベースページにまたがることがあり、ページの順序もおそらく順不同です(後ほど分析する際に確認します)。

この汎用データベースメタデータは、どのデータベース型でも共通していることがわかりました。そのため、最初の_dbmeta33セクションはデータベース型に応じて拡張されます。Hashデータベースだけに注目するので、次の追加データを確認できます。

typedef struct _hashmeta33 {
  DBMETA dbmeta;    /* 00-71: Generic meta-data page header. */  /* omitted */  u_int32_t nelem;    /* 88-91: Number of keys in hash table */  /* omitted */  u_int8_t iv[DB_IV_BYTES];     /* 476-495: Crypto IV */  u_int8_t chksum[DB_MAC_KEY];  /* 496-511: Page chksum */}

nelem(要素数)から、データベース内の要素数もわかります。一方、iv(初期化ベクトル)とchksum(チェックサム)は、暗号化が使われている可能性を示唆しています。

これらのバイトを読み取り、後で出力できるようJavaScriptオブジェクトに格納すると、次のようになります。

{
  /* omitted */  "crypto_magic": 0,
  "iv": [ /* lots of zeros */ ],
  "chksum": [ /* lots of zeros */ ],
  "dbmeta": {
    "pgno": 0,
    "magic": 398689, /* 0x061561, which is the Hash DB magic number */    "version": 4,
    "pagesize": 4096,
    "encrypt_alg": 0,
    "type": 8, /* Hash DB */    "free": 1259,
    "last_pgno": 3837,
    "nparts": 0,
    "key_count": 146,
    "record_count": 146,
    "flags": 0,
    "uid": [ /* a bunch of bytes */ ],
    /* omitted */  }
}

実際には、少なくともデフォルトではRPMデータベースの暗号化は行われていないため、このケースは完全に無視できます。

ここまでで、データベースのレイアウトは次のようになります。

メタデータページの後に不明なページがページサイズごとの間隔で配置された、データベースのハッシュレイアウトを示す図。

ページのサイズは4096バイトで、最初のページにはデータベースのメタデータが格納されています。次のページに進み、どのようなデータがあるか確認しましょう。BerkeleyDBのソースコードも参考になり、次のレイアウトが示されています。

*+-----------------------------------+
*|    lsn    |   pgno    | prev pgno |
*+-----------------------------------+
*| next pgno |  entries  | hf offset |
*+-----------------------------------+
*|   level   |   type    |   chksum  |
*+-----------------------------------+
*|    iv     |   index   | free -->  |
*+-----------+-----------------------+
*|         F R E E A R E A          |
*+-----------------------------------+
*|              <-- free |   item    |
*+-----------------------------------+
*|   item    |   item    |   item    |
*+-----------------------------------+

型定義から、それぞれのエントリーのサイズがわかります。適切なバイト数を読み取ってJavaScriptオブジェクトにマッピングすると、次のようになります。

{
  "pgno": 1,
  "prev_pgno": 0,
  "next_pgno": 0,
  "entries": 148,
  "hf_offset": 2845,
  "level": 0,
  "type": 13,
  /* omitted */}

type: 13に注目してください。これはまったく新しいページ型を示しており、型定義では次のように記されています。

#defineP_HASH    13  /* Sorted hash page. */

prev_pgnoとnext_pgnoは0に設定されています。つまり、データ(内容はともかく)はこのページ内にすべて格納されているため、収集のために他のページへ移動する必要はありません。

hf_offsetの値に注目してください。ページに格納されたデータ全体を見ると、約2850バイト付近がゼロになっており、これはhf_offsetの値と一致します。つまり、hf_offsetはページ上のデータの終端を示しています。後ほど複数ページにまたがるデータ項目を読み取る際、データの開始位置と終了位置を把握するうえで役立ちます。

各ページの先頭には共通の26バイトのページヘッダーがあり、残りの4070バイトの一部またはすべてにデータが格納されています。Hash型のページには、現在のページ上のバイト位置やオフセットを指すエントリーを含むインデックスがあります。このオフセットはページの反対側にあるデータを指しています。Hashページ上でのデータの保存方法は、スタックとヒープの構造に似ています。

メモリ内のスタックとヒープを示す図。スタックは低位アドレス方向に、ヒープは高位アドレス方向に伸びています。

インデックスはページのヒープ領域に保存され、データ(インデックス内のオフセットが指す先)はスタック領域に保存されます。

インデックスにはキーと値のペアが1つずつ並んでいます。キーはデータベース内の内部識別子で、値は現在のページ内のオフセットを指します。各オフセットに何が格納されているかを解釈するために、次の型定義を利用できます。

typedef struct _hoffpage {
  u_int8_t  type;    /*    00: Page type and delete flag. */  u_int8_t  unused[3];  /* 01-03: Padding, unused. */  db_pgno_t pgno;       /* 04-07: Offpage page number. */  u_int32_t tlen;       /* 08-11: Total length of item. */} HOFFPAGE;

この情報があれば、BerkeleyDB全体のどこにデータが格納されているか、少なくともその開始位置を特定できます。データは特定のページ番号から始まり、長さがページサイズを超える場合は複数ページにまたがります(ページ0から読み取ったpagesizeを思い出してください)。

ここまでで、データベースの構造は次のようになります。

メタデータページ、ソート済みハッシュページ、不明なページ、およびデータを指すオフセットを含むインデックスを示すデータベースハッシュファイルの図。

次に、データインデックス(struct __hoffpageで表される)からエントリーを1つ選び、そのエントリーが指すページを見てみましょう。ページ番号と各ページのサイズがわかっているので、そのページを読み込み、内容を確認できます。

{
  "pgno": 3,
  "prev_pgno": 0,
  "next_pgno": 4,
  "entries": 1,
  "hf_offset": 4070,
  "level": 0,
  "type": 7,
  /* omitted */}

next_pgnoに値が設定されており、残りのデータが格納された次のページを指していることに注目してください。hf_offsetの値は4070です。これは、このページ全体がデータで埋まっていることを意味します(ページサイズ4096バイトから共通ページヘッダーの26バイトを引いた4070バイトです)。したがって、データ全体を取得するには、next_pgnoをたどって複数のページを巡り、すべてのバイトをつなぎ合わせる必要があります。

データベースの最終的な構造は次のとおりです。

DBハッシュのメタページ、ソート済みハッシュページ、複数のオーバーフローページをサイズラベル付きで示したデータベースページレイアウトの図

データを読み取るために必要な情報は以上です。連結されたオーバーフローページをすべてたどると、実際のRPMパッケージであるバイナリブロブのリストが得られます。次はパッケージを読み取り、パッケージ名、バージョン、アーキテクチャなどの情報を抽出します。

RPMパッケージのメタデータを解析する

ここからはBerkeleyDBの話を離れ、RPMパッケージそのものとメタデータ形式に移ります。RPMパッケージの構造を理解するために、RPMファイル形式のドキュメントを参照します。

rpmファイルは、リード、署名ヘッダー、ペイロードヘッダー、そしてペイロード(実際のデータ)の4つのセクションで構成されます。2つのヘッダーセクションは同じ形式です。それぞれの形式は以下のセクションで説明されています。各セクションはファイル内に順番に並び、セクションの前後や間に追加情報はありません。

試行錯誤の結果、BerkeleyDBのバイナリブロブに格納されているのは、実際には部分的なペイロードヘッダーと完全なペイロードの2項目だとわかりました。リードと署名ヘッダーは完全に省略されています。RPMのドキュメントを見ると、ペイロードヘッダーの構造は次のとおりです。

+---+---+---+---+---+---+---+---+---+---+---+---+
|HM1|HM2|HM3|VER|   RESERVED    |  INDEXCOUNT   | (more ->)
+---+---+---+---+---+---+---+---+---+---+---+---+

+---+---+---+---+===============+============+
|   STORESIZE   | Index Entries | Data Store |
+---+---+---+---+===============+============+

HMx(ヘッダーマジック)の各エントリー、バージョン、予約済みバイトもすべて欠落していることがわかりました。代わりに、indexcount(ヘッダーインデックスエントリーの数)、storesize(データストアのサイズ、バイト単位)、インデックスエントリー、データストアを含む部分的なヘッダーが得られます。

ここで重要なのは、データがビッグエンディアン形式で保存されている点です。そのため、各エントリーは明示的にその形式で読み取り、解釈する必要があります。

これまでにBerkeleyDBから収集したバイナリブロブは、擬似コードで表すと次のようになります。

// pseudocode:
blob {
  indexcount: int32be;
  storesize: int32be;
  index_entries: int8[];
data: int8[];
}

最初の2項目は簡単に読み取れますが、index_entriesを読むにはそのレイアウトを理解する必要があります。RPMファイル形式のドキュメントには、次のように記載されています。

+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
|      TAG      |     TYPE      |     OFFSET    |     COUNT     |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+

// pseudocode:
index_entry {
  tag: int32be;
  type: uint32be;
  offset: int32be;
  count: uint32be;
}

各index_entryは16バイトです。この情報とindexcountの値があれば、RPMヘッダーのエントリーをすべて取得できます。

JavaScriptですべてのインデックスエントリーを読み取り、次のようなリストを作成できるようになりました。

[
  { tag: 1000, type: 6, offset: 2, count: 1 },
  { tag: 1004, type: 9, offset: 27, count: 1 },
  { tag: 1006, type: 4, offset: 168, count: 1 },
  /* omitted */]

RPMのドキュメントを見ると、各tagの値が何を表すかを示す表があります。タグはパッケージ名(1000)、バージョン(1001)、アーキテクチャ(1022)などの情報を示します。typeもRPMのドキュメントで確認でき、string(6)、int32(4)、さらにはnull型(0)などのエントリーがあります。

次にoffsetを使い、各エントリーのデータ開始位置に移動します。このとき、データの位置に正しくたどり着くため、オフセット計算にindexcountも含めます。そしてtypeの値に応じて、その後に続くバイト配列を適切に処理します。たとえばstring型では、C言語系のソフトウェアで一般的な文字列の終端を示す0バイトに達するまで、バイト列を読み取ります。一方、int32型なら4バイトだけを読み取ればよいとわかります。

これらの情報を使ってインデックス内のすべてのエントリーを順に処理すれば、依存関係を構築するのに十分なパッケージデータを抽出できます。エントリーの例を1つ紹介します。

{
  name: 'libgcc',
  version: '7.3.1',
  release: '6.amzn2.0.4',
  size: 179192,
  arch: 'x86_64'
}

同じ処理をすべてのBerkeleyDBブロブに対して繰り返すことで、パッケージ情報をすべて抽出し、依存関係のリストを作成できます。

まとめ

RPMパッケージマネージャーの内部構造を深く掘り下げ、データのバイナリレイアウトと、インストール済みパッケージの情報が内部でどのように表現されているかを確認しました。これは、Snykがコンテナイメージ内の依存関係の一覧を読み取り、解析する際に用いているのと同じプロセスです。Snykは無料でご利用いただけます。アカウントに登録してください。この処理を担うオープンソースライブラリ、GitHub上のRPM parserもぜひご覧ください。

CTFを始めよう

オンデマンドのバーチャル入門ワークショップを視聴して、CTFの課題の解き方を学びましょう。

続きを読む

feature customer snowflake
Article

開発初期からセキュリティを確保:Snowflake Cortex Code向けSnyk Studioインテグレーションを発表

Snyk StudioがSnowflake Cortex Codeと連携し、開発中にAI生成コード、依存関係、コンテナの脆弱性をスキャンします。

blog feature pypi spoof
Article

オープンソースのメンテナーに、Snyk AI Security Platformをすべて無料で提供

オープンソースのメンテナーは、実際の脆弱性レポートに追われ、優先順位付けや修正、修正版のリリースを迅速に進めるための支援を必要としています。SnykのSecure Developer Programでは、条件を満たすプロジェクトにSnyk AI Security Platformを無料で提供します。

feature insights announcement
Blog

Node-gypサプライチェーン侵害:binding.gypに潜む自己増殖型npmワーム

新たなnpmワームがbinding.gypを悪用し、インストール時にnode-gypを起動。ライフサイクルスクリプトを使わずに悪意あるパッケージからコードを実行します。認証情報を窃取し、GitHub上に潜伏し、メンテナー間で自己増殖します。