maplectf复现
mapleba ctf 2022[honksay]
进入题目

可以看到我们可以传入一个url,然后goose会去访问我们上传的url,像这种能够上传url的题目,一般就三种类型xss,ssrf,csrf。这道题目给了源码,我们先看源码
const express = require("express");
const cookieParser = require('cookie-parser');
const goose = require("./goose");
const clean = require('xss');
const app = express();
app.use(cookieParser());
app.use(express.urlencoded({extended:false}));
const PORT = process.env.PORT || 9988;
const headers = (req, res, next) => {
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
return next();
}
app.use(headers);
app.use(express.static('public'))
const template = (goosemsg, goosecount) => `
<html>
<head>
<style>
H1 { text-align: center }
.center {
display: block;
margin-left: auto;
margin-right: auto;
width: 50%;
}
body {
place-content:center;
background:#111;
}
* {
color:white;
}
</style>
</head>
${goosemsg === '' ? '': `<h1> ${goosemsg} </h1>`}
<img src='/images/goosevie.png' width='400' height='700' class='center'></img>
${goosecount === '' ? '': `<h1> You have honked ${goosecount} times today </h1>`}
<form action="/report" method=POST style="text-align: center;">
<label for="url">Did the goose say something bad? Give us feedback.</label>
<br>
<input type="text" id="site" name="url" style-"height:300"><br><br>
<input type="submit" value="Submit" style="color:black">
</form>
</html>
`;
app.get('/', (req, res) => {
if (req.cookies.honk){
//construct object
let finalhonk = {};
if (typeof(req.cookies.honk) === 'object'){
finalhonk = req.cookies.honk
} else {
finalhonk = {
message: clean(req.cookies.honk),
amountoftimeshonked: req.cookies.honkcount.toString()
};
}
res.send(template(finalhonk.message, finalhonk.amountoftimeshonked));
} else {
const initialhonk = 'HONK';
res.cookie('honk', initialhonk, {
httpOnly: true
});
res.cookie('honkcount', 0, {
httpOnly: true
});
res.redirect('/');
}
});
app.get('/changehonk', (req, res) => {
res.cookie('honk', req.query.newhonk, {
httpOnly: true
});
res.cookie('honkcount', 0, {
httpOnly: true
});
res.redirect('/');
});
app.post('/report', (req, res) => {
const url = req.body.url;
goose.visit(url);
res.send('honk');
});
app.listen(PORT, () => console.log((new Date())+`: Web/honksay server listening on port ${PORT}`));
app.js
const puppeteer = require('puppeteer');
const FLAG = process.env.FLAG || "maple{fake}";
async function visit(url) {
let browser, page;
return new Promise(async (resolve, reject) => {
try {
browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-default-apps',
'--disable-dev-shm-usage',
'--disable-extensions',
'--disable-gpu',
'--disable-sync',
'--disable-translate',
'--hide-scrollbars',
'--metrics-recording-only',
'--mute-audio',
'--no-first-run',
'--safebrowsing-disable-auto-update'
]
});
page = await browser.newPage();
await page.setCookie({
name: 'flag',
value: FLAG,
domain: 'localhost',
samesite: 'none'
});
await page.goto(url, {waitUntil : 'networkidle2' }).catch(e => console.log(e));
console.log(page.cookies());
await new Promise(resolve => setTimeout(resolve, 500));
console.log("admin is visiting url:");
console.log(url);
await page.close();
console.log("admin visited url");
page = null;
} catch (err){
console.log(err);
} finally {
if (page) await page.close();
console.log("page closed");
if (browser) await browser.close();
console.log("browser closed");
//no rejectz
resolve();
console.log("resolved");
}
});
};
module.exports = { visit }
goose.js
可以看到flag是写在goose的cookie里的,这就说明这道题是要我们用xss把goose的cookie带出来,但是xss的前提是需要执行js代码。如果我们直接传入xss语句,goose会直接访问我们传入的语句,并不会执行js代码,所以我们现在需要寻找一个能够执行js代码的地方,也就是能渲染到html的地方。
app.get('/', (req, res) => {
if (req.cookies.honk){
//construct object
let finalhonk = {};
if (typeof(req.cookies.honk) === 'object'){
finalhonk = req.cookies.honk
} else {
finalhonk = {
message: clean(req.cookies.honk),
amountoftimeshonked: req.cookies.honkcount.toString()
};
}
res.send(template(finalhonk.message, finalhonk.amountoftimeshonked));
我们可以看到,再/路由中用了template我们跟进到template
const template = (goosemsg, goosecount) => `
<html>
<head>
<style>
H1 { text-align: center }
.center {
display: block;
margin-left: auto;
margin-right: auto;
width: 50%;
}
body {
place-content:center;
background:#111;
}
* {
color:white;
}
</style>
</head>
${goosemsg === '' ? '': `<h1> ${goosemsg} </h1>`}
<img src='/images/goosevie.png' width='400' height='700' class='center'></img>
${goosecount === '' ? '': `<h1> You have honked ${goosecount} times today </h1>`}
<form action="/report" method=POST style="text-align: center;">
<label for="url">Did the goose say something bad? Give us feedback.</label>
<br>
<input type="text" id="site" name="url" style-"height:300"><br><br>
<input type="submit" value="Submit" style="color:black">
</form>
</html>
`;
发现是一个html的渲染,我们可以传入两个参数进行渲染,在/路由是由finalhonk.message和finalhonk.amountoftimeshonked这两个参数进行渲染的
我们只要让这两个参数变成xss语句,再让goose去访问/路由就行了。接下来看看这两个值是否是我们可以控制的
let finalhonk = {};
if (typeof(req.cookies.honk) === 'object'){
finalhonk = req.cookies.honk
} else {
finalhonk = {
message: clean(req.cookies.honk),
amountoftimeshonked: req.cookies.honkcount.toString()
};
}
可以看到finalhonk是从cookie中得到的,但是我们只能更改自己的cookie,不能改goose的cookie,所以我们还要找一个能更改cookie的路由
app.get('/changehonk', (req, res) => {
res.cookie('honk', req.query.newhonk, {
httpOnly: true
});
res.cookie('honkcount', 0, {
httpOnly: true
});
res.redirect('/');
});
我们找到了changehonk这个路由可以通过get的newhonk参数来更改cookie,这样我们就能进行xss了

先尝试能不能弹窗

成功
接下来试试能不能带出cookie,记得要url编码一下


成功了
接着试试goose


发现带不出cookie,但是用fetch发送请求,把cookie放在body里就可以。

bookstore
给了源码

在这里写了flag我们看看init.sql

发现把flag写到了book的text里
观察index.js的源码之后发现并没有能够直接显示text的地方,那么这道题应该就是要sql注入
import express from 'express'
import bodyParser from 'body-parser'
import session from 'express-session'
import crypto from 'crypto'
import DB from './db.js'
import { validateEmail, validateLogin } from './validator.js';
const PORT = process.env.PORT || 3000
const db = new DB();
let validBooks = await db.getBooks();
validBooks = validBooks.map(book => {
return {
id: book.id,
title: book.title,
author: book.author,
price: book.price
}
});
const app = express();
app.set('view engine', 'ejs');
app.use(session({
secret: crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: true
}));
app.use(express.static('static'))
app.use(bodyParser.urlencoded({ extended: true }));
app.get('/', (req, res) => {
res.render('index');
});
app.get('/login', (req, res) => {
res.render('login', {
message: ''
});
})
app.post('/login', (req, res) => {
const username = req.body.username;
const password = req.body.password;
if (!validateLogin(username, password)) {
res.render('/login', {
message: 'Invalid username or password'
});
} else {
db.getUser(username, password, (user) => {
const userData = {
username: user.username,
password: user.password,
money: 0, // POOR
books: []
}
if (user) {
req.session.user = userData;
res.redirect('/books')
} else {
res.render('/login', {
message: 'User not found'
});
}
});
}
});
app.get('/register', (req, res) => {
res.render('register', {
message: '',
link: false
});
})
app.post('/register', (req, res) => {
const username = req.body.username;
const password = req.body.password;
if (!validateLogin(username, password)) {
res.send('Invalid username or password');
} else {
db.register(username, password).then(() => {
res.render('register', {
message: 'Registration Successful',
link: true
})
}).catch((err) => {
res.render('register', {
message: 'Registration Failed: ' + err,
link: false
})
})
}
});
app.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/');
});
app.get('/books', (req, res) => {
if (!req.session.user) {
res.redirect("/login")
} else {
res.render('books', {
ownedBooks: req.session.user.books,
books: validBooks
});
}
});
app.get('/catalogue', (req, res) => {
if (!req.session.user) {
res.redirect('/login');
} else {
res.render('catalogue', {
ownedBooks: req.session.user.books,
books: validBooks
});
}
});
app.post('/purchase', (req, res) => {
const bookID = req.body?.bookID || '0';
if (!validBooks.find(book => book.id == bookID)) {
res.send('Invalid book ID');
} else {
const user = req.session.user;
user.books.push(bookID);
req.session.user = user;
res.render('catalogue', {
ownedBooks: req.session.user.books,
books: validBooks
});
}
return;
});
app.post('/download-ebook', (req, res) => {
const option = req.body?.option ?? '';
const email = req.body?.email ?? '';
const bookID = req.body?.bookID ?? 1;
const user = req.session?.user ?? { books: [] };
if (!validBooks.find(book => book.id == bookID)) {
res.send('Invalid book ID');
return;
} /* else if (!user.books.includes(bookID)) {
res.send('You do not have this book');
return;
} */
switch (option) {
case 'direct':
res.write('Direct downloads currently unavailable. Please wait until the established publish date!');
break;
case 'kindle':
if (validateEmail(email)) {
db.insertEmail(email, bookID).then((err) => {
if (err) {
res.send('Error: ' + err);
} else {
res.send("Email saved! We'll send you a download link once the book has been published!")
}
}).catch((err) => {
res.send('Error: ' + err);
})
} else {
res.send("Invalid email address")
}
break;
default:
res.send('Invalid option');
break;
}
});
app.listen(PORT, () => {
console.log(`listening on port ${PORT}`)
});
我们要寻找注入点,看到db.js
import mysql from 'mysql'
const LIMIT = 500;
export default class DB {
constructor() {
this.adminDB = mysql.createConnection({
connectionLimit: 100,
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_ADMIN_USER || 'root',
password: process.env.DB_ADMIN_PASS || 'Rooted123!',
database: process.env.DB_NAME || 'bookstore',
});
this.db = mysql.createPool({
connectionLimit: 100,
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'player',
password: process.env.DB_PASS || 'Player123!',
database: process.env.DB_NAME || 'bookstore',
});
}
register(username, password) {
return new Promise((resolve, reject) => {
this.adminDB.query(`INSERT INTO users(username, password) VALUES('${username}', '${password}')`, (err) => {
if (err) {
console.log(err)
reject(err);
} else {
resolve(null);
}
})
})
}
getUser(username, password, callback) {
const query = `
SELECT * FROM users WHERE username = '${username}' AND password = '${password}';
`;
this.db.query(query, (err, user) => {
callback(user);
});
}
getBooks() {
const query = `SELECT id, title, author, price FROM books;`;
return new Promise((resolve, reject) => {
this.db.query(query, (error, rows) => {
if (error) {
reject(error);
} else {
resolve(rows);
}
}).on('error', (err) => {
reject(err);
})
})
}
insertEmail(email, book_id) {
const query = `INSERT INTO requests(email, book_id) VALUES('${email}', '${book_id}');`;
return new Promise((resolve, reject) => {
this.db.query(query, (error) => {
if (error != null) {
reject(error);
} else {
resolve(null);
}
})
})
}
purge() {
// disappoints users
const countQuery = "SELECT COUNT(*) FROM requests;"
return new Promise((resolve, reject) => {
this.adminDB.query(countQuery, (error, row) => {
if (error != null) {
reject(error);
} else {
if (row.COUNT > LIMIT) {
const deleteQuery = `DELETE FROM requests;`
this.db.query(deleteQuery, (err) => {
if (err != null) {
reject(err);
} else {
resolve(null);
}
})
}
}
})
})
}
}
我们能够发现可以注入的地方就只有3个username,passsword和email。
但是
在使用username,password和email之前,他都调用了validator.js中的函数对我们传入的数值进行了过滤,我们看到validator.js中的内容
import validator from 'validator'
export function validateEmail(email) {
return validator.isEmail(email)
}
function validateUsername(username) {
return validator.isAlphanumeric(username, 'en-US') && username.length > 3 && username.length < 30
}
function validatePassword(password) {
return validator.isAlphanumeric(password, 'en-US') && password.length > 6 && password.length < 30
}
export function validateLogin(username, password) {
return username && password && validateUsername(username) && validatePassword(password)
}
调用了validator库,对username和password的检测是isAlphanumeric,
对email的检测是isEmail。我们看看validator的文档


isAlphanumeric规定了username和password·只能是字母和数字,而且在validator.js还自己增加了长度限制,在username和password进行注入几乎是不可能的。而isEmail对格式进行了规定在加上一点小过滤也没有长度的限制,所以在email处尝试注入
payload
"',1 and substring((select texts from books where id=1),1,99));#@a.aa


浙公网安备 33010602011771号