corCTF 2024 Web方向 题解WirteUp
Jay17 发表于 浙江 CTF 558浏览 · 2024-08-16 13:54

[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));

要包含所有相关模型,可以使用allnested选项:(也就是后文全部查找)

// 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,收工

0 条评论
某人
表情
可输入 255