RisePro Triage
Investigating the link between RisePro and PrivateLoader
Overview
According to FlashPoint
“RisePro” is a newly identified stealer written in C++ that appears to possess similar functionality to the stealer malware “Vidar.” RisePro targets potentially sensitive information on infected machines and attempts to exfiltrate it in the form of logs. “RisePro” is a newly identified stealer written in C++ that appears to possess similar functionality to the stealer malware “Vidar.” RisePro targets potentially sensitive information on infected machines and attempts to exfiltrate it in the form of logs.
RisePRO triggered a false positive for the Malpedia PrivateLoader yara rule and triggered the UnpacMe PrivateLoader config extractor. This led to correct string extraction from RisePRO even thought it is a separate malware. Are these related?!
References
- “RisePro” Stealer and Pay-Per-Install Malware “PrivateLoader”
- PrivateLoader Triage - Config Extractor for PrivateLoader
- privateloader_str_decrypt.py
- Malpedia yara
- PrivateLoader: the loader of the prevalent ruzki PPI service
- Example string encryption using xorstr
Sample
-
2cd2f077ca597ad0ef234a357ea71558d5e039da9df9958d0b8bd0efa92e74c9
UnpacMe
Analysis
Both PrivateLoader and RisePRO use the same plaintext user agent string Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36
and request header "Content-Type: application/x-www-form-urlencoded"
also the string encryption algorithm is the same (xmm registers and xor intrinsic). Funny enough this causes a 100% overlap between the PrivateLoader Yara rule and RisePro.
Sample Identification
PrivateLoader yara rule matches on both PrivateLoader and RisePRO!
rule win_privateloader_w0 {
meta:
author = "andretavare5"
org = "BitSight"
date = "2022-06-06"
md5 = "8f70a0f45532261cb4df2800b141551d"
reference = "https://tavares.re/blog/2022/06/06/hunting-privateloader-pay-per-install-service"
license = "CC BY-NC-SA 4.0"
malpedia_reference = "https://malpedia.caad.fkie.fraunhofer.de/details/win.privateloader"
malpedia_version = "20220824"
malpedia_license = "CC BY-NC-SA 4.0"
malpedia_sharing = "TLP:WHITE"
strings:
$code = { 66 0F EF (4?|8?) } // pxor xmm(1/0) - str chunk decryption
$str = "Content-Type: application/x-www-form-urlencoded" wide ascii
$ua1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36" wide ascii
$ua2 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36" wide ascii
condition:
uint16(0) == 0x5A4D and // MZ
$str and
any of ($ua*) and
#code > 100
}
RisePro Yara Rule
We need new Yara rules for both PrivateLoader and RisePRO to differentiate the two.
The following rule was created by @c3rb3ru5d3d53c. She was using binlex and the rule is has decent fidelity for RisePro.
rule risepro {
meta:
author = "c3rb3ru5d3d53c"
description = "Detects RisePro"
hash = "2cd2f077ca597ad0ef234a357ea71558d5e039da9df9958d0b8bd0efa92e74c9"
created = "2023-06-18"
os = "windows"
tlp = "white"
rev = 1
strings:
$trait_0 = {
8b ff 55 8b ec 83 ec 28 8d 4d ?? 56 57 6a 00 e8
d6 f5 ff ff 8d 45 ?? 50 ff 75 ?? e8 b7 f9 ff ff
59 59 8d 4d ?? 8b f0 8b fa e8 04 f6 ff ff 8b d7
8b c6 5f 5e c9 c3}
$trait_2 = {
8b c7 83 ff 40 99 89 46 ?? 6a 3f 58 0f 4d f8 89
56 ?? 8b 55 ?? 33 c0 33 c9 0f ab f8 83 ff 20 0f
43 c8 33 c1 83 ff 40 0f 43 c8 09 44 1a ?? 09 4c
1a ?? 66 83 4e ?? ?? 5f 8b c6 5e 5b c9 c3}
$trait_4 = {
8b 45 ?? 0f b7 c0 8d 04 48 0f b6 4c 1f ?? 89 45
?? 8b 45 ?? 0f b7 c0 83 c0 fc 66 c1 e1 08 89 45
?? 0f b6 44 1f ?? 66 0b c8 0f b6 44 1f ?? 66 03
45 ?? 0f b7 c9 0f b7 d0 66 85 c9 74 69}
$trait_5 = {
8b 43 ?? 56 0f b7 73 ?? 2b d6 0f b7 48 ?? 8b c2
33 d2 83 e9 04 f7 f1 0f b7 43 ?? 03 d6 3b d0 6a
04 0f 47 d6 59 03 d1 5e 2b 7d ?? 03 d7 3b d1 5f
0f 42 d1 66 8b c2 5b c9 c3}
$trait_6 = {
8a 4d ?? 8a 45 ?? 8a 55 ?? 8b 7d ?? c0 e9 04 80
e1 03 c0 e0 02 02 c8 8a 45 ?? 88 4d ?? 8a ca c0
e9 02 80 e1 0f c0 e0 04 c0 e2 06 02 c8 02 55 ??
4b 88 4d ?? 88 55 ?? 85 db 7e 24}
$trait_7 = {
89 75 ?? ff 75 ?? e8 9b 0d 00 00 59 89 75 ?? ff
75 ?? ff 75 ?? e8 c1 00 00 00 59 59 8b f0 89 75
?? c7 45 ?? ?? ?? ?? ?? e8 15 00 00 00 8b c6 8b
4d ?? 64 89 0d 00 00 00 00 59 5f 5e 5b c9 c3}
$trait_8 = {
b6 45 f8 88 4d ?? c1 e9 08 03 c8 0f b6 45 ?? 88
4d ?? c1 e9 08 03 c8 0f b6 45 ?? 88 4d ?? c1 e9
08 03 c8 88 4d ?? c1 e9 08 00 4d ?? 83 c6 c0 8b
c6 83 d7 ff 83 c3 40 85 ff 77 88}
$trait_9 = {
56 b2 2e 8b f1 e8 13 00 00 00 85 c0 74 03 40 eb
02 8b c6 b2 2f 8b c8 5e e9 00 00 00 00 53 8a da
eb 0d 3a c3 74 13 51 ff 15 ?? ?? ?? ?? 8b c8 8a
01 84 c0 75 ed 33 c0 5b c3}
$trait_10 = {
56 8b f1 8b 4e ?? e8 3a 01 00 00 8b 4e ?? 8a d0
85 c9 74 06 5e e9 24 00 00 00 b8 00 10 00 00 66
85 46 ?? 74 0c 8b 46 ?? 8b 00 8b 48 ?? 8b 09 eb
e3 6a 62 59 84 d2 0f b6 c2 5e 0f 44 c1 c3}
$trait_11 = {
56 8b f1 85 d2 74 34 53 8a 5a ?? f6 c3 04 75 2a
83 7e ?? ?? 74 08 8b 02 f6 40 ?? ?? 74 1c 8b 4a
?? 80 cb 04 88 5a ?? 85 c9 78 0f 8b 42 ?? 6b d1
28 03 50 ?? 80 6a ?? ?? 74 ce 5b 5e c3}
$trait_12 = {
56 8b f1 0f b7 46 ?? a9 60 24 00 00 74 2e a9 00
20 00 00 74 0f 8b 16 e8 3a 00 00 00 8b ce 5e e9
b0 ff ff ff a9 00 04 00 00 74 13 8b 4e ?? 85 c9
74 0c ff 76 ?? ff d1 83 66 ?? ?? 59 5e c3}
$trait_13 = {
56 8b 71 ?? 57 6a 05 58 c7 06 40 42 0f 00 8b 51
?? eb 04 89 04 96 4a 3b d0 7d f8 33 ff 47 eb 09
6a 0b 58 2b c2 89 04 96 4a 3b d7 7d f3 80 79 ??
?? 74 06 8b 41 ?? 89 3c 86 5f 5e c3}
$trait_14 = {
55 8b ec 8b 41 ?? 56 85 c0 74 14 ff 75 ?? ff 75
?? ff 75 ?? 52 ff 71 ?? ff d0 83 c4 14 eb 1d 8b
45 ?? 33 f6 3b 75 ?? 75 10 ff 75 ?? 50 52 ff 71
?? ff 51 ?? 83 c4 10 eb 03 83 c8 ff 5e 5d c3}
$trait_15 = {
55 8b ec 83 ec 58 53 56 57 8b 7d ?? 33 db 89 4d
?? 33 f6 0f 57 c0 89 55 ?? 8b 0f 89 4d ?? 8a 41
?? 88 45 ?? 8b 01 89 45 ?? 33 c0 21 45 ?? 66 89
45 ?? 8a 02 66 0f 13 45 ?? 3c 80 73 07}
$trait_16 = {
55 8b ec 83 ec 24 56 8d 75 ?? eb 1e 85 d2 74 1e
8b 41 ?? 3b 42 ?? 73 0a 89 4e ?? 8b f1 8b 49 ??
eb 08 89 56 ?? 8b f2 8b 52 ?? 85 c9 75 de 85 c9
0f 44 ca 89 4e ?? 8b 45 ?? 5e c9 c3}
$trait_17 = {
8d 45 ?? 50 8d 45 ?? 50 8d 45 ?? 50 e8 b7 0a ff
ff 8d 45 ?? 50 8d 45 ?? 50 e8 8a 14 ff ff 83 c4
14 be 09 00 00 00 ?? ?? 8d 45 ?? 50 50 e8 76 14
ff ff 83 c4 08 83 ee 01 75 ee}
$trait_18 = {
55 8b ec 83 e4 f8 51 56 8b f1 83 7e ?? ?? 75 23
80 7e ?? ?? 72 1d a1 ?? ?? ?? ?? 85 c0 74 02 ff
d0 8b ce e8 f8 df ff ff a1 ?? ?? ?? ?? 85 c0 74
02 ff d0 8b ce e8 18 00 00 00 5e 8b e5 5d c3}
$trait_19 = {
55 8b ec 53 8b 5d ?? 56 57 8b 7d ?? 8d 47 ?? 50
57 53 e8 b9 fd fe ff 8d 77 ?? 56 8d 47 ?? 50 8d
43 ?? 50 e8 a8 fd fe ff 8d 47 ?? 50 8d 43 ?? 56
50 e8 9a fd fe ff 83 c4 24 5f 5e 5b 5d c3}
$trait_20 = {
55 8b ec 51 56 8b 71 ?? 57 8b fa eb 22 3b 75 ??
74 1a 85 ff 74 05 39 7e ?? 75 11 80 7e ?? ?? 75
0b 8b ce e8 12 00 00 00 85 c0 75 09 8b 76 ?? 85
f6 75 da 33 c0 5f 5e 59 5d c3}
$trait_21 = {
55 8b ec 51 56 57 8b fa 8b f1 eb 28 8b 4e ?? e8
92 01 00 00 85 c0 75 19 3b 7e ?? 73 14 ff 75 ??
8b d7 8b ce e8 14 00 00 00 59 85 c0 74 03 89 46
?? 8b 76 ?? 85 f6 75 d4 5f 5e 59 5d c3}
$trait_22 = {
55 8b ec 51 56 57 6a 01 8d 45 ?? 8b f1 50 8b fa
57 ff 76 ?? ff 56 ?? 83 c4 10 83 f8 01 75 0d 8b
45 ?? 0f b6 4d ?? 89 08 33 c0 eb 0d 57 ff 76 ??
ff 56 ?? f7 d8 59 59 1b c0 5f 5e c9 c3}
$trait_23 = {
55 8b ec 51 51 53 56 8b 75 ?? 57 8b 46 ?? 8b 4e
?? 83 c0 fb 3b c1 0f 46 c8 8b 06 89 4d ?? 33 ff
8b 40 ?? 89 45 ?? 8b 86 ?? ?? ?? ?? 8b 16 83 c0
2a c1 f8 03 8b 5a ?? 3b d8 0f 82 04 01 00 00}
$trait_24 = {
53 8b dc 83 ec 08 83 e4 f0 83 c4 04 55 8b 6b ??
89 6c 24 ?? 8b ec 83 ec 28 a1 ?? ?? ?? ?? 33 c5
89 45 ?? 8b 4b ?? 8b 53 ?? 56 33 f6 89 55 ?? 57
8b 7b ?? 81 f9 e0 00 00 00 0f 82 15 01 00 00}
$trait_25 = {
8d 45 ?? 50 8d 45 ?? 50 8d 45 ?? 50 e8 1d 0a ff
ff 8d 45 ?? 50 8d 45 ?? 50 e8 f0 13 ff ff 83 c4
14 be 31 00 00 00 8d 45 ?? 50 50 e8 de 13 ff ff
83 c4 08 83 ee 01 75 ee}
$trait_26 = {
13 c0 03 d1 8b 4d ?? 83 d0 00 23 5d ?? 0b 5d ??
c1 e3 08 c1 e9 12 0b d9 8b 4d ?? 03 d8 8b 45 ??
51 03 59 ?? 89 38 89 70 ?? 89 50 ?? 89 58 ?? e8
82 f8 ff ff 83 c4 08 5f 5e 5b 8b e5 5d c3}
$trait_27 = {
0f b6 47 ?? 0f b6 0f 83 c7 02 c1 e0 08 03 c8 8b
c2 83 e2 3f 25 c0 03 00 00 83 c2 40 81 e1 ff 03
00 00 03 d0 c1 e2 0a 03 d1 8b 4d ?? 8d 46 ?? 89
45 ?? 81 fa 80 00 00 00 73 06}
$trait_28 = {
0f b6 0f 0f b6 47 ?? 83 c7 02 c1 e1 08 03 c8 8b
c2 83 e2 3f 25 c0 03 00 00 83 c2 40 81 e1 ff 03
00 00 03 d0 c1 e2 0a 03 d1 8b 4d ?? 8d 46 ?? 89
45 ?? 81 fa 80 00 00 00 73 06}
condition:
uint16(0) == 0x5a4d and
uint32(uint32(0x3c)) == 0x00004550 and
7 of them
}
C2
The C2 domain is stored in plain text in RisePRO unlike PrivateLoader (where it is encrypted).
194.169.175[.]128
String Decryption
There are encrypted stack strings that are composed of the string data, and an accompanying XOR key. These are loaded onto the stack, then directly XOR decrypted.
X-Junior IDA Script
X-Junior has a script that we can try in IDA to decrypt these strings: GitHub Repo.
Andre Tavares Python Script
andretavare5 has a python script using capstone to decrypt the strings: Script Gist.
We have created our own hybrid of the two, which uses capstone for disassembly, but implements the logic from the IDA script...
XorStr Library
The string decryption looks a lot like this open source library xorstr. The following is an example of the library in use.
.text:00411E14 C7 44 24 08 25 7B 87 92 mov [esp+60h+var_58], 92877B25h
.text:00411E1C 0F 57 C0 xorps xmm0, xmm0
.text:00411E1F C7 44 24 0C B6 10 A7 1F mov [esp+60h+var_54], 1FA710B6h
.text:00411E27 8B 44 24 08 mov eax, [esp+60h+var_58]
.text:00411E2B 8B 4C 24 0C mov ecx, [esp+60h+var_54]
.text:00411E2F 89 44 24 10 mov dword ptr [esp+60h+var_50], eax
.text:00411E33 89 4C 24 14 mov dword ptr [esp+60h+var_50+4], ecx
.text:00411E37 C7 44 24 08 D1 77 20 5B mov [esp+60h+var_58], 5B2077D1h
.text:00411E3F C7 44 24 0C C5 36 32 7E mov [esp+60h+var_54], 7E3236C5h
.text:00411E47 8B 44 24 08 mov eax, [esp+60h+var_58]
.text:00411E4B 8B 4C 24 0C mov ecx, [esp+60h+var_54]
.text:00411E4F 89 44 24 18 mov dword ptr [esp+60h+var_50+8], eax
.text:00411E53 89 4C 24 1C mov dword ptr [esp+60h+var_50+0Ch], ecx
.text:00411E57 C7 44 24 08 6D 1E EB FE mov [esp+60h+var_58], 0FEEB1E6Dh
.text:00411E5F C7 44 24 0C D9 3C 87 48 mov [esp+60h+var_54], 48873CD9h
.text:00411E67 8B 44 24 08 mov eax, [esp+60h+var_58]
.text:00411E6B 8B 4C 24 0C mov ecx, [esp+60h+var_54]
.text:00411E6F C7 44 24 08 BE 05 4C 3F mov [esp+60h+var_58], 3F4C05BEh
.text:00411E77 89 44 24 40 mov dword ptr [esp+60h+var_20], eax
.text:00411E7B C7 44 24 0C E4 36 32 7E mov [esp+60h+var_54], 7E3236E4h
.text:00411E83 8B 44 24 08 mov eax, [esp+60h+var_58]
.text:00411E87 89 4C 24 44 mov dword ptr [esp+60h+var_20+4], ecx
.text:00411E8B 8B 4C 24 0C mov ecx, [esp+60h+var_54]
.text:00411E8F 89 44 24 48 mov dword ptr [esp+60h+var_20+8], eax
.text:00411E93 8D 44 24 10 lea eax, [esp+60h+var_50]
.text:00411E97 89 4C 24 4C mov dword ptr [esp+60h+var_20+0Ch], ecx
.text:00411E9B 8D 50 01 lea edx, [eax+1]
.text:00411E9E 0F 28 4C 24 40 movaps xmm1, [esp+60h+var_20]
.text:00411EA3 66 0F EF 4C 24 10 pxor xmm1, [esp+60h+var_50]
.text:00411EA9 0F 29 4C 24 10 movaps [esp+60h+var_50], xmm1
.text:00411EAE 0F 29 44 24 20 movaps [esp+60h+var_40], xmm0
.text:00411EB3 C7 44 24 30 00 00 00 00 mov [esp+60h+var_30], 0
.text:00411EBB C7 44 24 34 00 00 00 00 mov [esp+60h+var_2C], 0
String Extraction
The string encryption is almost identical to privateloader, we had to adjust for registers that store immediates for multiple strings in a row. Also, there are situations where the stack string DWORDs are not combined in order. Currently we don't handle this, but the solution is to use the .dis
memory displacement to determine the order of the the DWORDs.
Decryption Algorithm
- select the first section in the PE file, assume this is the code
- scan code for final
pxor
instruction and truncate at this instruction to remove extra code from scanning (handle packers with large first sections) - linear disassemble the full code block - not efficient
- traverse assembly until
pxor
instruction is located - scan backwards until all immediate data is located for the
xmm
registers - decrypt xmm data, this is the string chunk
- keep running tally of string chunks and combine if no instructions separate them
def unhex(hex_string):
import binascii
if type(hex_string) == str:
return binascii.unhexlify(hex_string.encode('utf-8'))
else:
return binascii.unhexlify(hex_string)
def tohex(data):
import binascii
if type(data) == str:
return binascii.hexlify(data.encode('utf-8'))
else:
return binascii.hexlify(data)
import pefile
import struct
from capstone import *
from capstone.x86 import *
import re
import time
def is_ascii(s):
return all(c < 128 or c == 0 for c in s)
def xor(data, key):
out = []
for i in range(len(data)):
out.append(data[i] ^ key[i % len(key)])
return bytes(out)
def get_reg_data(instructions, reg_name):
search_count = 0
search_limit = 2000
for inst in instructions:
if search_count > search_limit:
break
search_count += 1
if inst.mnemonic == 'mov' and inst.operands[0].type == X86_OP_REG and inst.operands[1].type == X86_OP_IMM:
if inst.reg_name(inst.operands[0].reg) == reg_name:
imm_value = inst.operands[1].value.imm
data_chunk = struct.pack('<I',imm_value)
return data_chunk
return None
def get_data(instructions):
data_chunks = []
count = 0
steps = 0
steps_flag = 0
flag_reg = 0
search_count = 0
search_limit = 400
for inst_ptr in range(0,len(instructions)):
inst = instructions[inst_ptr]
steps +=1
if search_count > search_limit:
break
search_count += 1
if inst.mnemonic == 'call':
break
# if inst.mnemonic == 'mov' and inst.operands[0].type == X86_OP_REG and inst.operands[1].type == X86_OP_IMM:
# flag_reg = 1
if inst.mnemonic == 'mov' and inst.operands[0].type == X86_OP_MEM and inst.operands[1].type == X86_OP_REG:
reg_name = inst.reg_name(inst.operands[1].reg)
#print(f"Scanning for {reg_name} data....")
result = get_reg_data(instructions[inst_ptr:], reg_name)
if result is None:
#print("scanning failed")
break
#print(f"Found reg {reg_name} data {result}")
data_chunks.append(result)
count += 1
steps = 0
steps_flag = 1
if inst.mnemonic == 'mov' and ( (inst.operands[0].type == X86_OP_MEM and inst.operands[0].value.mem.disp != 0) or inst.operands[0].type == X86_OP_REG ) and inst.operands[1].type == X86_OP_IMM:
imm_value = inst.operands[1].value.imm
#print(hex(imm_value))
if imm_value & 0xff000000 == 0:
break
data_chunk = struct.pack('<I',imm_value)
data_chunks.append(data_chunk)
count += 1
steps = 0
steps_flag = 1
if steps == 16 and steps_flag:
break
#if steps == 6 and steps_flag: # if you got some garbage string use this instead of the above
#break
enc_data = data_chunks[0:count//2][::-1]
key = data_chunks[count//2:count][::-1]
if flag_reg :
enc_data = sum(zip(enc_data[1::2], enc_data[::2]), ())
key = sum(zip(key[1::2], key[::2]), ())
return b''.join(enc_data),b''.join(key)
def get_strings_from_inst(instructions):
# search, build and decrypt strings
strings = []
addr = None
string = ''
for i, inst in enumerate(instructions):
if inst.mnemonic == 'pxor':
# if inst.address == 0x0045C8A0:
#print(hex(inst.address))
#try: # possible string decryption found
reversed_instruction_list = instructions[:i][::-1]
encrypted_str, key = get_data(reversed_instruction_list)
# print(f"str_len: {len(encrypted_str)}, key_len: {len(key)}")
# print(encrypted_str.hex())
# print(key.hex())
if len(encrypted_str) == 0 or len(key) == 0:
#print(f"Error at {hex(inst.address)} key or data is missing")
continue
if len(encrypted_str) != len(key):
#print(f"Error at {hex(inst.address)} key and data not equal length")
continue
out = bytearray(encrypted_str[j] ^ key[j] for j in range(len(key)))
#print(out)
out = out.replace(b'\x00',b'')
if len(out) == 0:
continue
#print(out.decode('utf-8'))
if is_ascii(out):
strings.append((inst.address,out.decode('utf-8')))
return strings
SAMPLE_PATH = '/tmp/xorstr/work/rise.bin'
filename = SAMPLE_PATH
pe = pefile.PE(filename)
# Assume the first section is code
txt = pe.sections[0]
# TODO: we don't seem to be disassembling the full section?!!
image_base = pe.OPTIONAL_HEADER.ImageBase
section_rva = txt.VirtualAddress
section_offset = txt.PointerToRawData
section_data = txt.get_data()
pxor_egg = rb'\x66\x0F\xEF'
scan_end = section_data.rfind(pxor_egg)
section_data = section_data[:scan_end]
# disassemble .txt section
pe = pefile.PE(filename)
md = Cs(CS_ARCH_X86, CS_MODE_32)
md.detail = True
md.skipdata = True
addr = 0
## Time starts
t = time.time()
# instructions = []
# for inst in md.disasm(section_data, image_base + section_rva):
# instructions.append(inst)
# strings = get_strings_from_inst(instructions)
strings = []
for m in re.finditer(pxor_egg, section_data, re.DOTALL):
scan_end = m.start()
instructions = []
for inst in md.disasm(section_data[scan_end-0x400:scan_end], image_base + section_rva + scan_end - 0x400):
instructions.append(inst)
strings += get_strings_from_inst(instructions)
# Benchmark 50.47584390640259 metastealer
print(f"Benchmark {time.time() - t}")
# print(len(strings))
string_dict = {}
last_string = ''
for s in strings:
if last_string != s[1]:
string_dict[s[0]] = s[1]
last_string = s[1]
for o in string_dict.keys():
print(f"{hex(o)} {string_dict[o]}")
print("done")