(You can read this article in English too.)

免責事項

Denoを開発しているDeno Land Inc.は、脆弱性報奨金制度等を実施しておらず、脆弱性の診断行為に関する明示的な許可を出していません。

本記事は、公開されている情報を元に脆弱性の存在を推測し、実際に攻撃/検証することなく潜在的な脆弱性として報告した問題に関して説明したものであり、無許可の脆弱性診断行為を推奨することを意図したものではありません。
Deno Land Inc.が開発するサービスや製品に脆弱性を見つけた場合は、[email protected]へ報告してください。1

また、脆弱性に関する情報の検証が行えていないため、本記事に含まれる情報は不正確である可能性が存在します。2

要約

deno.land/xが動作しているシステム上の任意のファイルを読み取ることが可能な脆弱性、及びDenoのencoding/yamlにおけるCode Injectionを発見した。
このうち、deno.land/xの脆弱性が悪用された場合、モジュールをS3へ格納する際に使用されているAWSの認証情報を窃取することができたため、結果としてdeno.land/x上の任意のパッケージを改竄することが可能となっていた。

調査理由

Denoの公式サイトに記載されているDeno is a simple, modern and secure runtime for JavaScript and TypeScript that uses V8 and is built in Rust.という文言を読み、どれぐらいセキュアなのかが気になったため、調査を行うことにした。

調査範囲

調査を行うにあたって、関連するソフトウェアすべてを調査する時間はなかったため、以下のリポジトリに絞ってコードを流し読みすることにした。

調査結果

簡単な調査を行った結果、Deno本体、Denoの標準モジュール、deno.land/xのコードにそれぞれ脆弱性を見つけた。
そのうち、Deno本体に存在した脆弱性に関しては既知の問題だった3ため、本記事では紹介しないが、その他の2件に関しては以下で説明する。

Denoの標準モジュール

Denoには、本体とは別にDeno standard libraryというモジュールが存在する。
このモジュールは、Deno本体とは別のリポジトリで管理されており、Denoの本体には組み込まれていない。

このモジュール内に存在する、YAMLをパースするためのパッケージ(encoding/yaml)に脆弱性が存在し、悪意あるYAMLを読み込んだ際に任意のコードを実行することが可能となっていた。

encoding/yamlの脆弱性 (CVE-2021-42139)

encoding/yamlには、デフォルトで読み込まれているスキーマとは別に、拡張スキーマが存在する。
このスキーマは、以下のようなコードを書くことにより使用することができる。

import {
  EXTENDED_SCHEMA,
  parse,
} from "https://deno.land/std@$STD_VERSION/encoding/yaml.ts";

const data = parse(
  `
  regexp:
    simple: !!js/regexp foobar
    modifiers: !!js/regexp /foobar/mi
  undefined: !!js/undefined ~
  function: !!js/function >
    function foobar() {
      return 'hello world!';
    }
`,
  { schema: EXTENDED_SCHEMA },
);

この拡張スキーマを使用することにより、正規表現やundefined、JavaScriptの関数をYAMLで表現することが可能になる。
これらの機能のうち、関数をパースする際の処理が以下のようになっていたため、単純なJavaScriptを記述することにより、任意のコードを実行することが可能となっていた。

function reconstructFunction(code: string) {
  const func = new Function(`return ${code}`)();
  if (!(func instanceof Function)) {
    throw new TypeError(`Expected function but got ${typeof func}: ${code}`);
  }
  return func;
}

本脆弱性は、このプルリクエストにおいて、EXTENDED_SCHEMAにおける関数のサポートを削除することにより修正された。

deno.land/x

deno.land/xは、Denoのモジュールをホストできるサービスで、GitHubのリリースと連動して自動でモジュールを更新することができる。
Denoのインストールスクリプトを含む多数のモジュールをホストしており、Denoのエコシステムの中心部分となっている。

GitHubと連携した自動更新機能は、デフォルトでリポジトリのすべてのファイルをdeno.land/xへアップロードする。
しかしながら、リポジトリ全体をアップロードしてしまうと余分なファイルも含まれてしまい、ファイルサイズが大きくなってしまう。
そこで、この問題に対応するために、アップロードするディレクトリを指定できるsubdirパラメータが存在する。

このパラメータの値が適切にサニタイズされないままパスに連結されており、システム上の任意のファイルを読み取ることが可能となっていた。

subdirパラメータのパストラバーサル

subdirパラメータは、以下のコードに到達するまで一切の変更を受けずにclonePathへと連結される。

    // Create path that has possible subdir prefix
    const path = (subdir === undefined ? clonePath : join(
      clonePath,
      subdir.replace(
        /(^\/|\/$)/g,
        "",
      ),
    ));

ここで作成されたpathは、以下のコードで用いられる。

    // Walk all files in the repository (that start with the subdir if present)
    const entries = [];
    for await (
      const entry of walk(path, {
        includeFiles: true,
        includeDirs: true,
      })
    ) {
      entries.push(entry);
    }

    console.log("Total files in repo", entries.length);

    const directory: DirectoryListingFile[] = [];

    await collectAsyncIterable(pooledMap(100, entries, async (entry) => {
      const filename = entry.path.substring(path.length);

      // If this is a file in the .git folder, ignore it
      if (filename.startsWith("/.git/") || filename === "/.git") return;

      if (entry.isFile) {
        const stat = await Deno.stat(entry.path);
        directory.push({ path: filename, size: stat.size, type: "file" });
      } else {
        directory.push({ path: filename, size: undefined, type: "dir" });
      }
    }));

このコードは、上記で生成したパスの配下に存在するファイルの情報をdirectoryという名前の配列に追加しており、このdirectoryは以下で用いられる。

    await collectAsyncIterable(pooledMap(65, directory, async (entry) => {
      if (entry.type === "file") {
        const file = await Deno.open(join(path, entry.path));
        const body = await Deno.readAll(file);
        await uploadVersionRaw(
          moduleName,
          version,
          entry.path,
          body,
        );
        file.close();
      }
    }));

このコードでは、typefileに設定されているファイル情報のpathが指し示す場所に存在するファイルを読み取り、それをuploadVersionRaw関数へ渡している。
この関数は、S3にpublic-readの状態でファイルのコンテンツをアップロードする。

これらのコードからわかるように、subdirパラメータに../../../../../../../etcといったような値が設定されていた場合、/etcディレクトリ内のファイルが全てアップロードされる。4

これを用いることにより、悪意のあるユーザーがsubdirパラメータに../../../../../../../proc/self等を指定し、/proc/self/environからAWSの認証情報を摂取した上で任意のモジュールのファイルを書き換えることが可能となっていた。

本脆弱性は、このプルリクエストにおいてsubdirパラメータを正規化するように変更することで修正された。
なお、本脆弱性の修正後にDenoの開発チームがAWS認証情報のアクセスログを調査し、本脆弱性が悪用されていないことを確認した。

まとめ

今回の記事では、Denoに関連したプロジェクトに存在した脆弱性に関して解説しました。
これらの事例からは、どんなにプロジェクトがセキュアを掲げていても、脆弱性を作り込まないようにするというのは難しいということが分かるかと思います。

本記事に関する質問/感想はTwitter(@ryotkak)までお願いいたします。

タイムライン

日付 (日本時間)出来事
2021/09/13脆弱性の発見
2021/09/14脆弱性の報告
2021/09/14脆弱性が修正される
2021/09/15脆弱性の開示を行っても問題ないかを確認
2021/10/07開示許可が出る
2021/11/30本記事の公開

  1. https://github.com/denoland/deno/issues/12058 ↩︎

  2. Deno Land Inc.への確認は行っていますが、完全な正確性を保証するものではありません。 ↩︎

  3. https://github.com/denoland/deno/issues/9607 ↩︎

  4. 実際には、権限等の問題で途中で処理が中断されると思われるが、その時点までにアップロードされたファイルはそのままS3上に保持される。 ↩︎