Challenge Overview

The source code was provided. The challenge is based on the NestJS JavaScript framework (https://nestjs.com/).

The file app.controller.ts contains the different routes of the application :

  • auth/register
  • auth/login
  • infos
  • exec

The function that seems interesting is executeCodeSafely() because it calls the safeEval() function which allows evaluating code in a sandbox. However, a CVE allows bypassing the sandbox and executing code directly on the host : https://security.snyk.io/vuln/SNYK-JS-SAFEEVAL-3373064

@UseGuards(JwtAuthGuard)
@Post('exec')
executeCodeSafely(@Request() req, @Body('code') code: string) {
    if (req.user.pseudo === 'admin')
        try {
            const result = safeEval(code);
            if (!result) throw new CustomError('safeEval Failed');
            return { result };
        } catch (error) {
            return {
                from: error.from ? error.from(AppController) : 'Unknown error source',
                msg: error.message,
            };
        }
    return {
        result: "You're not admin !",
    };
}

Our goal is to be able to use this function. For this, there are 2 conditions :

  • You must be authenticated and have a valid JWT token - @UseGuards(JwtAuthGuard).
  • Your username must be admin.

The first step seems simple enough, as there is a route for creating an account. The second is more complex because when the application is launched, an admin account is created and a check prevents the creation of an account with the same username : unique: true.

@Entity({ name: 'users' })
export class UserEntity extends BaseEntity {
  @PrimaryGeneratedColumn('uuid')
  public id: string;

  @Column({ unique: true, length: 32 })
  public pseudo: string;

  @Column({ length: 6 })
  public password: string;
}

When reading the code, we notice that there is an SQL injection in the get() function of the UsersService class. The user.id parameter is concatenated to the SQL query.

This function is called via the /infos route which will decode the JWT token and retrieve the value of the user's id field.

We would need to be able to control this value in order to perform the SQL injection.

async get(user: UserEntity) {
    try {
        // Custom query to rename pseudo into username
        const users = await this.repository.query(
            `SELECT users.pseudo as username, users.id FROM users WHERE users.id = '${user.id}'`,
        );
        return users[0];
    } catch (error) {
        throw new ForbiddenException('Unknow Error');
    }
}

There is only one place where we can modify this value : during the account creation ! Let’s take a closer look at the register() function.

@Post('auth/register')
async register(@Body() payload: CreateUserDTO) {
    const user = await this.authService.create(payload);
    return this.authService.getToken(user);
}

NestJS will retrieve the body without specifying any keys.

As we can see in the documentation, this is equivalent to Node.js executing a req.body : https://docs.nestjs.com/controllers

Body

We can perform a mass assignment attack by adding the id parameter to our user creation request in order to modify its value.

Mass Assignment

We can verify that the UUID has been successfully modified by decoding the jwt token (https://jwt.io/).

JWT Decoded

Now we can perform the SQL injection ! One last detail is that the UUID is of type uuid, which limits our payload to a maximum of 35 characters.

@PrimaryGeneratedColumn('uuid')

It is very interesting to retrieve the hash of the administrator because it is the same secret used to sign the JWT tokens.

JwtModule.register({
    secret: process.env.SECRET || 'secret',
    signOptions: { expiresIn: '2h' },
}),
this.authService
    .create({
        pseudo: 'admin',
        password: process.env.SECRET || '',
    })
    .catch((e) => {
        console.log(e.message);
});

Here is the password encryption function. First, it will be encrypted in MD5, then the first 6 characters will be kept in the database. If we can extract the hash of the admin and then perform a hash collision, we will be able to log in to their account.

function getReduceMd5(input) {
    return crypto.createHash('md5').update(input).digest('hex').slice(0, 6);
}

After testing multiple payloads, I was only able to extract the admin's UUID and pseudo. But it’s not a bad thing, it allowed me to think of an even more clever way.

I noticed the use of the save() function when creating an account. By reading the documentation (https://typeorm.io/repository-api), understand that save allows the creation of an entity. If it already exists, it is updated !

save - Saves a given entity or array of entities. If the entity already exist in the database, it is updated. If the entity does not exist in the database, it is inserted. It saves all given entities in a single transaction (in the case of entity, manager is not transactional). Also supports partial updating since all undefined properties are skipped. Returns the saved entity/entities.

async create(payload: CreateUserDTO) {
    if (payload.password != '' || payload.pseudo != '')
        return this.repository.save(payload);
    else throw new UnprocessableEntityException('Empty field');
}

The primary key is the uuid, so if the uuid is the same as one existing in the database, then the existing information will be overwritten by the one sent.

We will start by retrieving the admin's uuid with SQL injection :

Leak ID

  • e6785ec2-52ae-4ee8-9be6-c3cae363bd73

Admin ID

Then, we will create a new account with its uuid and a different username, which should update it in the database.

New Account

Finally, we can create a new account with the username admin, which no longer exists in the database.

Admin

We now have access to use the /exec route and we can exploit the CVE to escape the sandbox and read the flag !

RCE

Python will convert this to ASCII for me !

>>> data=[80,87,78,77,69,123,103,48,68,95,106,79,66,33,95,83,52,70,101,45,101,118,52,49,95,119,52,83,95,78,48,116,95,87,101,82,89,95,50,65,102,51,125,10]

>>> for i in data:
...   print(chr(i), end='')
...

PWNME{g0D_jOB!_S4Fe-ev41_w4S_N0t_WeRY_2Af3}
  • PWNME{g0D_jOB!_S4Fe-ev41_w4S_N0t_WeRY_2Af3}