浅谈Sequelize下的SQL注入漏洞利用
1315609050541697 发表于 河南 CTF 588浏览 · 2024-07-31 09:58

基础介绍

Sequelize是针对node.js和io.js提供的ORM框架。具体就是突出一个支持广泛,配置和查询方法统一。它支持的数据库包括:PostgreSQL、 MySQL、MariaDB、 SQLite 和 MSSQL。

基本使用

参考官方文档 :Sequelize

初始化 new Sequlize({}) 配置

  • { define } 及是 Model.init 配置。define 只能定义 Model.init 里 options 的相关内容
  • 默认会给模型添加id/createdAt/updatedAt等字段,可以更改

    • 原始查询const [results, metadata] = sequelize.query(mysql)
  • sequelize.fn(fn, args) 用于创建数据库函数对象。在where/order中使用
  • sequelize.col(string) 创建数据库列对象。建议使用 table.colum 完整命名方式
    • attributes: {[Sequelize.fn('DISTINCT', Sequelize.col('external_info.status_code')), 'statusCode']}
    • [Sequelize.fn('count', Sequelize.col('id')), 'num']

关联查询

A.hasOne(B, { /* 参数 */ });
A.belongsTo(B, { /* 参数 */ });
A.hasMany(B, { /* 参数 */ });
A.belongsToMany(B, { through: 'C', /* 参数 */ });
  • 关联表之间定义顺序对结果是有影响的。在上述所有示例中A称为模型,而B称为目标模型。一个可对应一个/多个目标,但一个目标只能对一个
    • _foreignKey _外键及可在 hasOne/hasMany 中,也可在 belongsTo 中定义。并且也支持对象形式可指定如:type、allowNull、defaultValue 等参数
  • 一对一:hasOnebelongsTo关联一起使用;
  • 一对多:hasManybelongsTo关联一起使用;
    • A.hasMany(B, {sourceKey, foreignKey})
      • A中的记录一个对多个B中的,外键在B
      • 创建非主键字段的关联:_sourceKey _用于定义Bforeignkey 外键,所对应A中的实际字段名
    • B.belongsTo(A, {targetKey, foreignKey})
      • B中有记录值属于A,外键在B
      • 创建非主键字段的关联:_targetKey _用于定义B中 _foreignKey _外键,所对应A中的实际字段名
    • 使用非主键字段的关联 hasMany/belongsTo 都需要写上 sourceKey/targetKey/_foreignKey _并为一个值。要不然 sequelize 还是会按照自己的方式拿而外的外键去查询
  • 多对多(常用与中间表):两个belongsToMany(, {through})调用一起使用;
    • { through: 'xx' }为关联的中间表(必填项)
    • 注意:实际关联表并不需要在include:{}内出现,它只是起到中间关联的作用
  • options:{ sourceKey, targetKey, foreignKey, as }
    • as 别名:当一个模型需要定义两个不同的关联时使用。as 可直接代替 foreignKey ,也可同时使用。
      • 定义后在 include 时只能使用别名 as
      • hasOne/hasMany/belongsTo 中都可定义 as,但在 hasMany 中设置 as 返回的是 []
      • 一般定义在 belongsTo 中
  • 延迟加载/预先加载
    • 延迟:指等用到了,再获取关联数据
    • 预先(include):开始查询事,直接一次获取相关关联数据
  • :关联查询出的数据是个单独的对象;
  • :默认是 outer join/ left join 方式
  • 实例关联后添加的特殊方法
    • 一对一的
      • instance.createModelB({ 数据对象 })
      • getModelB/setModelB( id/instance )
    • 一对多、多对多
      • instance.createModelB({ 数据对象 })
      • hasModelB/addModelB/removeModelB( id/instance )
      • instance.getModelBs/countModelBs/hasModelBs/addModelBs/removeModelBs/setModelBs([ id/instance ])都是复数形式
      • addModelBs/removeModelBs
  • 快速使用样例
// 基本配置
new Sequelize({
  //...
  define: {
    charset: 'utf8mb4',
    collate: 'utf8mb4_general_ci',
    createdAt: 'createTime',
    updatedAt: 'updateTime',
    deleteAt: 'deleteTime',
    underscored: true, // _命名规则
    freezeTableName: true // 强制表名称等于模型名称
  }
});
// 数据列配置项
{ type, allowNull: true, defautlValue, unique, primaryKey, autoIncrement, comment, field, validate }
// 定义表 - 1:sequlize.define 内部实际调用的 Model.init

sequlize.define('modelName', { column_field }, { paranoid });
// 定义表 - 2
const { Model } = require('sequlize');
const sequelize = new Sequelize('sqlite::memory');
class User extends Model {

  // 可以创建一些自定义 模块数据 函数

  getFullName() {
    return [this.firstname, this.lastname].join(' ');
  }
}

User.init({ column_field }, {
  // 这是其他模型参数
  sequelize,
  tableName: 'xxx_xxx'

})

以2024 corctf的erm为例题

源代码如下:
app.js

const express = require("express");
const hbs = require("hbs");
const app = express();
const db = require("./db.js");
const PORT = process.env.PORT || 5000;

app.set("view engine", "hbs");
// catches async errors and forwards them to error handler
// https://stackoverflow.com/a/51391081
const wrap = fn => (req, res, next) => {
    return Promise
        .resolve(fn(req, res, next))
        .catch(next);
};



app.get("/api/members", wrap(async (req, res) => {

    res.json({ members: (await db.Member.findAll({ include: db.Category, where: { kicked: false } })).map(m => m.toJSON()) });

}));



app.get("/api/writeup/:slug", wrap(async (req, res) => {

    const writeup = await db.Writeup.findOne({ where: { slug: req.params.slug }, include: db.Member });

    if (!writeup) return res.status(404).json({ error: "writeup not found" });

    res.json({ writeup: writeup.toJSON() });

}));



app.get("/api/writeups", wrap(async (req, res) => {

    res.json({ writeups: (await db.Writeup.findAll(req.query)).map(w => w.toJSON()).sort((a,b) => b.date - a.date) });

}));



app.get("/writeup/:slug", wrap(async (req, res) => {

    res.render("writeup");

}));



app.get("/writeups", wrap(async (req, res) => res.render("writeups")));



app.get("/members", wrap(async (req, res) => res.render("members")));



app.get("/", (req, res) => res.render("index"));



app.use((err, req, res, next) => {

    console.log(err);

    res.status(500).send('An error occurred');

});



app.listen(PORT, () => console.log(`web/erm listening on port ${PORT}`));

db.js

const { Sequelize, DataTypes, Op } = require('sequelize');
const slugify = require('slugify');
const { rword } = require('rword');
const sequelize = new Sequelize({
    dialect: 'sqlite',
    storage: 'erm.db',
    logging: false
});  

const Category = sequelize.define('Category', {
    name: {
        type: DataTypes.STRING,
        primaryKey: true,
        allowNull: false,
    }
});


const Member = sequelize.define('Member', {
    username: {
        type: DataTypes.STRING,
        primaryKey: true,
        allowNull: false,
    },

    secret: {
        type: DataTypes.STRING,
    },

    kicked: {
        type: DataTypes.BOOLEAN,
        defaultValue: false,
    }
});



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.belongsToMany(Member, { through: 'MemberCategory' });
Member.belongsToMany(Category, { through: 'MemberCategory' });
Member.hasMany(Writeup);
Writeup.belongsTo(Member);



sequelize.sync().then(async () => {

    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: "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);
        }
    }


    // the forbidden member

    // banned for leaking our solve scripts

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

        // most CTFs feel like they're just named with random words anyway

        const ctfName = `${rword.generate(1, { capitalize: 'first', length: '4-6' })}CTF ${date.getFullYear()}`;

        // same thing with challenge names
        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 />";

        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 };

观察查询路由的代码发现,只有/api/writeups的参数是可控的,并且又由上面/api/members路由可以看到关联表查询使用的是include

知识点:
include是sequelize实现连表查询的一个语法,Sequelize 会根据关联模型的名称自动选择此字段的名称。

详细模型关联的解释

来自于官方文档以及个人总结

预先加载

预先加载是一次查询多个模型的数据的行为,是具有一个或多个 join 的查询

const User = sequelize.define('user', { name: DataTypes.STRING }, { timestamps: false });
const Task = sequelize.define('task', { name: DataTypes.STRING }, { timestamps: false });
const Tool = sequelize.define('tool', {
  name: DataTypes.STRING,
  size: DataTypes.STRING
}, { timestamps: false });
User.hasMany(Task);
Task.belongsTo(User);
User.hasMany(Tool, { as: 'Instruments' });

预先加载时,我们可以强制查询仅返回具有关联模型的记录,从而有效地将查询从默认的 OUTER JOIN 转为 INNER JOIN. 这是通过 required: true 参数完成的,如下所示:

User.findAll({  include: {    model: Task,    required: true  }});

注意:
include 自动使用的是left outer join

当在 include 内使用 where 参数时,Sequelize 会自动将 required 参数设置为 true. 这意味着,将执行 INNER JOIN 而不是 OUTER JOIN,仅返回具有至少一个匹配子代的父代模型.

举例:

const city = await City.findByPk(1, {
  include: Country,
});

console.log(city.toJSON());

结果:

SELECT
  `City`.`id`,
  `City`.`cityName`,
  `City`.`CountryId`,
  `Country`.`id` AS `Country.id`,
  `Country`.`countryName` AS `Country.countryName`
FROM
  `Cities` AS `City`
  LEFT OUTER JOIN `Countries` AS `Country` 
    ON `City`.`CountryId` = `Country`.`id`
WHERE
  `City`.`id` = 1;

假设你要join两个没有重复列的表,这是最常见的情况:

  • inner join A 和 B 获得的是A和B的交集(intersect),即韦恩图(venn diagram) 相交的部分.

  • outer join A和B获得的是A和B的并集(union), 即韦恩图(venn diagram)的所有部分.

在使用left join时,on and和on where条件的区别如下:

1、on条件是在生成临时表时使用的条件,它不管on中的条件是否为真,都会返回左边表中的记录。
2、where条件是在临时表生成好后,再对临时表进行过滤的条件。这时已经没有left join的含义(必须返回左边表的记录)了,条件不为真的就全部过滤掉,on后的条件用来生成左右表关联的临时表,where后的条件对临时表中的记录进行过滤。

获取单个关联元素

关联的用户加载所有任务

const tasks = await Task.findAll({ include: User });
console.log(JSON.stringify(tasks, null, 2));

打印结果:

[{
  "name": "A Task",
  "id": 1,
  "userId": 1,
  "user": {
    "name": "张三",
    "id": 1
  }
}]

总结:

关联的模型被添加到名为 user 的新字段中. Sequelize 会根据关联模型的名称自动选择此字段的名称,在适用的情况下(即关联为 hasManybelongsToMany)使用该字段的复数形式

获取所有关联的元素

const users = await User.findAll({ include: Task });\
console.log(JSON.stringify(users, null, 2));

打印结果:

[{
  "name": "张三",
  "id": 1,
  "tasks": [{
    "name": "A Task",
    "id": 1,
    "userId": 1
  }]
}]

关联是一对多的,因此访问器(结果实例中的tasks属性)是复数的

获取别名关联

如果关联是别名的(使用as参数),则在包含模型时必须指定此别名,但可以为对象提供两个选项:modelas

const users = await User.findAll({
  include: { model: Tool, as: 'Instruments' }
});
console.log(JSON.stringify(users, null, 2));

打印结果:

[{
  "name": "张三",
  "id": 1,
  "Instruments": [{
    "name": "hammer",
    "id": 1,
    "userId": 1
  }]
}]

也可以指定的关联别名相匹配的字符串

User.findAll({ include: 'Instruments' }); // 也可以正常使用
User.findAll({ include: { association: 'Instruments' } }); // 也可以正常使用

弄懂了include我没再查看db.js代码,发现flag存在goroo这个Member模型的实例中(模型请看前面的基础知识说明)

那么我们的目标很清晰,就是通过Writeup的模型关联查询来查询到Member模型实例中的flag

构造payload如下:

/api/writeups?include[association]=Member&include[on][username]=goroo

对payload的说明:

include[association]=Member可以正常工作,是因为Sequelize内部使用了模型名称来解析关联。即使没有显式地为关联定义别名,Sequelize仍然能够识别模型之间的关系,并基于这些关系用on来联合多表查询,因为用where的时候会自动转为inner join,而我们需要的是outer join

成功查询到flag:

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