RPMDBを解析した話

OSS開発をしている中で、RPMDBの実装を理解する必要があり調べたことを記事に残そうと思います。
世界にこの記事を読んで嬉しい方は、万に一人もいないと思います。

今回の記事はRPMDBを解析する背景と、初めてC言語で書かれた実装を真面目に解析した話をしようかなと思います。

背景

個人的に Trivy というコンテナ脆弱性ツールの開発に少しだけ参加しており、その中で以下のようなIssueが立ちました。

github.com

Issueを要約すると、「Red Hatの8系で脆弱性が誤検知している」というものです。

原因としては、Red Hat 8/Fedora 28から導入されたModularityという機能によって一部のパッケージのバージョン体系が変更されたことによって脆弱性検知が誤作動を起こしました。

興味がある方のために少し詳しく記載しますが、興味のない方は飛ばして 「本題」を読んでください。

始めに、Trivyのコンテナイメージの脆弱性検知プロセスについてざっくり説明します。

  1. コンテナイメージを取得
  2. コンテナイメージからOSを識別するために必要なファイルを抽出
  3. OS毎のパッケージ管理システムのデータベースファイル(造語)を抽出
  4. データベースファイルを解析し、インストールされているパッケージ情報を取得
  5. 脆弱性データベースとパッケージ情報を突合し脆弱性を検知

1と2と3の解説はスキップします。

4.についてはRed Hatの場合は /var/lib/rpm/Packages ファイルを解析することによってインストールされたパッケージ情報を取得できます。
詳細は Trivyの開発者である、@knqyf263 のブログに触れられていたはずなのでこちらを読んでください...

knqyf263.hatenablog.com

5.について説明します。
まず始めに、Trivyで利用するRed Hat脆弱性データベースには基本的に脆弱性が影響するバージョンが記載されています。
例えば CVE-2018-7584の脆弱性を例に挙げます。

https://github.com/aquasecurity/vuln-list/blob/37d8ac3154161acd547d306cd536b8024c4cc251/redhat/2018/CVE-2018-7584.json#L1-L38

{
      "product_name": "Red Hat Enterprise Linux 7",
      "release_date": "2020-03-31T00:00:00Z",
      "advisory": "RHSA-2020:1112",
      "package": "php-0:5.4.16-48.el7",
      "cpe": "cpe:/o:redhat:enterprise_linux:7"
    },

↑は脆弱性情報の一部を抜粋していますが、 意味としては 「RHSA-2020:1112(CVE-2018-7584) は phpの 0:5.4.16-48に影響する」になります。
つまり phpの5.4.16-48以下のバージョンは全て、CVE-2018-7548に影響し、5.4.16-48より大きいバージョンでは修正されているということです。
Trivyはこの脆弱性情報を元に現在コンテナにインストールされているパッケージと脆弱性情報のバージョンを比較して脆弱性検知を行っています。

次に同じ脆弱性の情報として以下のようなことが記載されています。

    {
      "product_name": "Red Hat Software Collections for Red Hat Enterprise Linux 7.5 EUS",
      "release_date": "2019-08-19T00:00:00Z",
      "advisory": "RHSA-2019:2519",
      "package": "rh-php71-php-0:7.1.30-1.el7",
      "cpe": "cpe:/a:redhat:rhel_software_collections:3"
    },

これは phpの7.1.30-1 以下のバージョンに RHSA-2020:1112(CVE-2018-7584) が影響するという脆弱性情報です。
この場合一見すると先ほどの脆弱性情報と矛盾が生じます。

xxx <= 5.4.16-48
xxx <= 7.1.30-1

例えば 5.4.17 は一つ目の情報だと修正済みに見えますが、2つ目の情報を元にすると影響しそうな気がします。 しかし実際は問題ありません。 なぜなら、パッケージが異なるからです。
一つ目は php、二つ目は rh-php71-php という同じphpに見えてRed Hat上では明確に異なるパッケージとして認識されるからです。

Red Hat 7系以前では、phpをインストールすると必ず 5.4.xx がインストールされ、rh-php71をインストールすると 7.1.xx がインストールされる世界でした。
しかし、Red Hat 8系からこの仕様が Modularity によって変わりました。

rheb.hatenablog.com

ざっくりいいますと、 Modularという概念によって、phpというパッケージで 7.2.xx や 7.3.xx、 7.4.xx がインストールできるようになりました。
これによってphp 7.4の脆弱性情報も php 7.2 の情報も同じ php パッケージとして認識されるようになり、既存の脆弱性検知手法だと、7.2の修正バージョンが 7.4の脆弱性情報によって脆弱なバージョンとして扱われることになってしまいました。

解決策として、 Modular は既存の脆弱性検知ロジックとは異なる手法で検知する必要があります。
しかし既存の Trivy が利用しているライブラリでは、インストールされているパッケージが既存手法でインストールされたのか、Modularによってインストールされたのか判断することができません。

ちなみに rpm の以下のコマンドでModuarかどうか判断が可能です。

# centos:8でもModularが利用できます。
$ docker run --it --rm centos:8 /bin/bash 
$ dnf install php
$ rpm -q php --qf "%{NAME} %{VERSION} %{MODULARITYLABEL}\n\n"
php 7.2.24 php:7.2:8020020200507003613:2c7ca891

$ rpm -q glibc --qf "%{NAME} %{VERSION} %{MODULARITYLABEL}\n"
glibc 2.28 (none)

phpは Modular でインストールされ、 glibcは既存手法でインストールされています。

ということで、Trivy でrpmコマンドにおける MODULARITYLABEL (modularのタグ)を取得するための実装をしました。
ここから本題に入ります。

本題

TrivyではRed HatベースのOSにインストールされているパッケージを解析するために rpmdbの解析ライブラリを用いています。  

github.com

ライブラリの説明などは省略して、rpmdbの説明をしたいと思いますので、雑にPRだけ貼っておきます。

https://github.com/knqyf263/go-rpmdb/pull/4

このPRを書くためにrpmソースコードを読んだのでそこで学んだことをまとめます。

実装

自分は今回初めてC言語で書かれた実装を真面目に解析したので、どういったプロセスで解析したのかも書こうかなと思います。

まず、rpmコマンドで普通にModularの情報を取得するにはどうすればいいのか?それはどうやって動作しているのかを調べました。

実行コマンド

rpm -qa --qf "%{MODULARITYLABEL}"

このコマンドでModularの情報を取得できることはわかったので、これを元に実装を調査し、同じプログラムを書けばいいと考えました。

コマンドからの実装を調査

始めに、プログラムの開始地点を探します。 ここですね。 https://github.com/rpm-software-management/rpm/blob/master/rpm.c#L61

この時はまだGDBというツールを使ったことがなかったので、rpmをビルドして脳死printfデバッグをしました。
俺もrpmをbuildしてdebugしたい!!って人用にbuild用のdockerを公開しています。

https://github.com/masahiro331/build-rpm-docker

rpmdbをパースする重要な処理がここから始まっていることがわかりました。

https://github.com/rpm-software-management/rpm/blob/rpm-4.15.1-release/lib/header.c#L2040

正確にはこの2行を実行してパースしたHeaderの中に欲しい情報が入っています。

    /* Sanity checks on header intro. */
    if (hdrblobInit(b, bsize, 0, 0, &hblob, &buf) == RPMRC_OK)
    hdrblobImport(&hblob, (flags & HEADERIMPORT_FAST), &h, &buf);

ということでここから各関数での実装をつらつら書こうかなと思います。
RPMDBのデータ構造だけみたいんじゃという方は最後のほうまで飛ばしてもらえれば、雑な構造図だけ貼ってます。

hdrblobInit

https://github.com/rpm-software-management/rpm/blob/rpm-4.15.1-release/lib/header.c#L1970

長いコードではないので貼っちゃいます。

rpmRC hdrblobInit(const void *uh, size_t uc,
        rpmTagVal regionTag, int exact_size,
        struct hdrblob_s *blob, char **emsg)
{
    rpmRC rc = RPMRC_FAIL;

    memset(blob, 0, sizeof(*blob));
    blob->ei = (int32_t *) uh; /* discards const */
    blob->il = ntohl(blob->ei[0]);
    blob->dl = ntohl(blob->ei[1]);
    blob->pe = (entryInfo) &(blob->ei[2]);
    blob->pvlen = sizeof(blob->il) + sizeof(blob->dl) +
          (blob->il * sizeof(*blob->pe)) + blob->dl;
    blob->dataStart = (uint8_t *) (blob->pe + blob->il);
    blob->dataEnd = blob->dataStart + blob->dl;

    /* Is the blob the right size? */
    if (blob->pvlen >= headerMaxbytes || (uc && blob->pvlen != uc)) {
    rasprintf(emsg, _("blob size(%d): BAD, 8 + 16 * il(%d) + dl(%d)"),
            blob->pvlen, blob->il, blob->dl);
    goto exit;
    }

    if (hdrblobVerifyRegion(regionTag, exact_size, blob, emsg) == RPMRC_FAIL)
    goto exit;

    /* Sanity check the rest of the header structure. */
    if (hdrblobVerifyInfo(blob, emsg))
    goto exit;

    rc = RPMRC_OK;

exit:
    return rc;
}

重要なのは引数に渡ってきている hdrblob_s構造体のblobポインタに値を埋めていく作業です。

typedef struct hdrblob_s * hdrblob;
struct hdrblob_s {
    int32_t *ei;
    int32_t il;
    int32_t dl;
    entryInfo pe;
    int32_t pvlen;
    uint8_t *dataStart;
    uint8_t *dataEnd;

    rpmTagVal regionTag;
    int32_t ril;
    int32_t rdl;
};

blob->eiにはデータの先頭ポインタ
blob->il には index Length
blob->dl には data Length
blob->pe には 先頭の entryInfo_s らしきもの
entryInfoの構造体をみていると、どうやらなんらかのデータの型やサイズ、開始位置が記述されてそう

struct entryInfo_s {
    rpm_tag_t tag;      /*!< Tag identifier. */
    rpm_tagtype_t type;     /*!< Tag data type. */
    int32_t offset;     /*!< Offset into data segment (ondisk only). */
    rpm_count_t count;      /*!< Number of tag elements. */
};

blob->pvlen には全体のサイズ
blob->dataStart は データ部の開始
少し難しいのですが、 pe[il]と読み替えてよさそうで、どうやら il個あるentryInfoの最後のアドレスを指し示してそう
blob->dataEnd は dataStart + dlなのでdata部の最後っぽさ

ここまで文字で書きましたが、いったん図に落として整理してみたいと思います。

f:id:masahiro331:20201220041935p:plain

ただ、まだ重要な行が4行ほど残ってます。

    if (hdrblobVerifyRegion(regionTag, exact_size, blob, emsg) == RPMRC_FAIL)
    goto exit;

    /* Sanity check the rest of the header structure. */
    if (hdrblobVerifyInfo(blob, emsg))
    goto exit;

最初は構造体をパースするにあたり、Verify**みたいな関数名だったので、無視していたのですが内部で非常に重要な値を取っているのでみていきます。

hdrblobVerifyRegion

https://github.com/rpm-software-management/rpm/blob/rpm-4.15.1-release/lib/header.c#L1787

処理の中に重要な処理がいくつか紛れ込んでいるのですが、今回は一つだけ一番大事なやつをメモ書いておきます

一部抜粋

    struct entryInfo_s trailer, einfo;

    memset(&trailer, 0, sizeof(trailer));
    regionEnd = blob->dataStart + einfo.offset;
    (void) memcpy(&trailer, regionEnd, REGION_TAG_COUNT);
    regionEnd += REGION_TAG_COUNT;
    blob->rdl = regionEnd - blob->dataStart;

    ei2h(&trailer, &einfo);
    /* Trailer offset is negative and has a special meaning */
    einfo.offset = -einfo.offset;
    /* Some old packages have HEADERIMAGE in signature region trailer, fix up */
    if (regionTag == RPMTAG_HEADERSIGNATURES && einfo.tag == RPMTAG_HEADERIMAGE)
    einfo.tag = RPMTAG_HEADERSIGNATURES;
    if (!(einfo.tag == regionTag &&
      einfo.type == REGION_TAG_TYPE && einfo.count == REGION_TAG_COUNT))
    {
    rasprintf(buf,
        _("region trailer: BAD, tag %d type %d offset %d count %d"),
        einfo.tag, einfo.type, einfo.offset, einfo.count);
    goto exit;
    }

    /* Does the region actually fit within the header? */
    blob->ril = einfo.offset/sizeof(*blob->pe);

処理を説明する前に einfoについて説明します。
einfoは先ほどの hdrblobInit でil, dl, pe の順番にパースした時のpeが入っています。
便宜的にこの 先頭のeinfo(entryInfo_s) を「Regionメタデータ」と呼びます

Regionメタデータの Offset値とdataStartを足した場所に trailerと呼ばれる entryInfo_s構造体が存在するようです。
※ REGION_TAG_COUNTはentryInfo_sのサイズ

trailerを取得し、その値を用いることで最後の ril値を計算することができます。
何気なく計算しているril値ですが、非常に重要な値でした。

// trailerをeinfoに代入しなおしているため einfo.offsetと記述されている
blob->ril = einfo.offset/sizeof(*blob->pe);

rilは region Index Lengthの略だと思うのですが、値をみていると、peの配列の特定の番地を示す値のようです。
ここまでの実装を再度図に起こします。

f:id:masahiro331:20201220044303p:plain

hdrblobVerifyInfo

こちらは本当に値の検証だけなのでスキップします。

hdrblobImport

ここまでで、パース処理のhdrblobInitが終わりました。次にhdrblobImportを見ていきます。

    /* Sanity checks on header intro. */
    if (hdrblobInit(b, bsize, 0, 0, &hblob, &buf) == RPMRC_OK)
    hdrblobImport(&hblob, (flags & HEADERIMPORT_FAST), &h, &buf);

https://github.com/rpm-software-management/rpm/blob/rpm-4.15.1-release/lib/header.c#L877

正直長いのと疲れてきたので、パパッと説明します。

初手のindexEntry構造体を宣言しています。

    indexEntry entry; 

どうやら、entryInfoと実際のデータとその長さを持っているみたいですね。

typedef struct indexEntry_s * indexEntry;
struct indexEntry_s {
    struct entryInfo_s info;    /*!< Description of tag data. */
    rpm_data_t data;        /*!< Location of tag data. */
    int length;         /*!< No. bytes of data. */
    int rdlen;          /*!< No. bytes of data in region. */
};

始めの if文です。あくまでも仮説ですが、互換性のために残してそうな実装です。

    entry = h->index;
    if (!(htonl(blob->pe->tag) < RPMTAG_HEADERI18NTABLE)) {

実際この if文に入るケースは gpg-pubkeyなどの少し特殊なパッケージをパースする際に入るので今回は無視します。

次に、elseを見ます。こちらがやっかいです。

 int32_t ril;

    h->flags &= ~HEADERFLAG_LEGACY;
    ei2h(blob->pe, &entry->info);
    ril = (entry->info.offset != 0) ? blob->ril : blob->il;

余談ですが、rpmのコードは至る所に spaceと tabが混在していて読んでいてキレ散らかしそうになります。
詳しくないんですが、こういう書き方が一般的なんですかね。。。?

戻ります。 regionメタデータ(先頭のpe)のoffsetが0ではない時、rilにはblob->rilを代入し、0の時はblob->ilを代入しています。 その後、entryの値を埋めて regionSwab関数を呼んでいます。

 entry->info.offset = -(ril * sizeof(*blob->pe)); /* negative offset */
    entry->data = blob->pe;
    entry->length = blob->pvlen - sizeof(blob->il) - sizeof(blob->dl);
    rdlen = regionSwab(entry+1, ril-1, 0, blob->pe+1,
               blob->dataStart, blob->dataEnd,
               entry->info.offset, fast);
    if (rdlen < 0)
        goto errxit;
    entry->rdlen = rdlen;

    if (ril < h->indexUsed) {
        indexEntry newEntry = entry + ril;
        int ne = (h->indexUsed - ril);
        int rid = entry->info.offset+1;

        /* Load dribble entries from region. */
        rdlen = regionSwab(newEntry, ne, rdlen, blob->pe+ril,
                blob->dataStart, blob->dataEnd, rid, fast);
        if (rdlen < 0)
        goto errxit;

ここら辺コードで説明するのが疲れてきたので、図に起こします。

f:id:masahiro331:20201220051226p:plain

大事なのは trailerを挟んで2回 regionSwabを実行していることにあります。 regionSwabは IndexEntries配列にデータを埋めていく作業をしてくれる関数です。

https://github.com/rpm-software-management/rpm/blob/rpm-4.15.1-release/lib/header.c#L495

少しだけ実装に気をつけたことを記述しておくと、regionSwab内でデータのサイズを計算する処理があるんですが、データの型によって実装を分ける必要があるということです。

 /* The offset optimization is only relevant for string types */
    if (fast && il > 1 && typeSizes[ie.info.type] == -1) {
        ie.length = ntohl(pe[1].offset) - ie.info.offset;
    } else {
        ie.length = dataLength(ie.info.type, ie.data, ie.info.count,
                   1, dataEnd);
    }

アクセス効率化のためにアラインメントするので、rpmdbもそれを意識したメモリ配置にしている。 データ型のサイズと実際のbinary上のデータサイズが異なるため、char型以外のデータの場合は都度計算し直す必要があります。

ここまでの処理でrpmdbをパースすることができました。 あとは本題の Modularの情報をとるだけです。

https://github.com/rpm-software-management/rpm/blob/rpm-4.15.1-release/lib/rpmtag.h#L375

indexEntry->info->tagが 5096 になるものを探して idnexEntry->dataをstringでキャストすれば完了です。

最後にこちらがPRになります。

github.com

以上、rpmdbを読んだ話でした。

あとがきてきなもの

ブログになれてないので、後半すごく辛かったです。しばらくは3行以上の日本語が読めそうにありません。
本当はもっと詳しく実装を紹介したいのですが、うまく文章が書けないなって部分や、実はまだまだ実装に対する理解度が足りないなと反省しているところです。
もう少し読み進めて随時、ブログの修正をしたいと思います。