Nestapp
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
We can perform a mass assignment
attack by adding the id parameter
to our user creation request in order to modify its value.
We can verify that the UUID has been successfully modified by decoding the jwt token (https://jwt.io/).
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
:
e6785ec2-52ae-4ee8-9be6-c3cae363bd73
Then, we will create a new account
with its uuid and a different username
, which should update it in the database.
Finally, we can create a new account with the username admin
, which no longer exists in the database.
We now have access to use the /exec
route and we can exploit the CVE
to escape the sandbox and read the flag !
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}