raw-data memdumps

Kpot v1 strings, network decryptor and whatnot

April 13, 2020

Even if Kpot v1.0 is definitely old, it might be still interesting to some; the sample in question was found on Any.Run.

Objectives

  1. Defeat the packer
  2. Decrypt payload strings - Ghidra Jython
  3. Decrypt network traffic - Stand alone python script

The packer

To extract the payload, there are at least two possible paths one can follow. The tedious version, requires to trace every call to NtMapViewOfSection and see where the payload will be written in memory or go for the I'm feeling lucky - Google ;~) approach, and check for RWX / strings patterns in the hollowed process memory sections.

Tracing NtMapViewOfSection

Set breakpoints on

  1. bp NtResumeThread
  2. bp NtMapViewOfSection

To keep track of the different maps created between the main process and the (injected) child, a table like the one below can come in handy - thx herrcore

Section handler Remote address Local address
F4 75 C0 00 00
E4 00 40 00 00 01 6A 00 00
E0 00 16 00 00 01 6C 00 00

Once the last NtMapViewOfSection is hit, NtResumeThread is the only missing call preventing payload execution. From the table, only the 2nd entry is of interest and it can be seen how code located in the parent process @0x016A0000 is mapped into the child @0x00400000 … this looks a good candidate for a PE base address.

Indeed, checking the location in the Memory dump reveals a shiny DOS header

I’m feeling lucky

This approach requires only to set one breakpoint, on NtResumeThread and

1. Attach to the new child process

2. Inspect Memory MapFind pattern ⇒ search for “This program cannot”

3. Follow memory address in DumpFollow in Memory Map

Do you see something familiar?

The memory protection at this address is set to ExecuteReadWrite, another hint about injection.

Payload location is now identified, from here the injected Kpot is ready to be execute in memory, from this stage

  1. it can be dumped to disk
  2. unmapped

And it’s ready for being analyzed

Go, go, go!

Unpacked sample details:

Big picture, at its core the analyzed sample has the following structure

Let’s begin … part of the Jython code developed for Kpot is similar to the one used for the Artra downloader sample, nevertheless, there will be few tweaks here and there to cope with this specimen.

Once loaded in Ghidra, following Exportsentry will lead to two calls, the first one, renamed here in mw_main, jumps straight to the malware core.

Strings decryption instructions are located inside FUN_0040c083 - this is one of the first function to be called after jumping from the entry point - but it’s not called immediately, instead, Kpot will perform some privilege escalation tricks first, restarting itself with the new granted permissions - if necessary.

Privilege escalation functions overview

  1. mw_func_CheckProcessToken() wraps calls checking the process integrity level with OpenProcessTokenGetTokenInformationGetSidSubAuthorityCountGetSidSubAuthority

  2. If mw_func_CheckProcessToken == 4 (GetSidSubAuthority < 0x2000)[SECURITY_MANDATORY_MEDIUM_RID], performs escalation ⇢ CreateProcessAsUserW

  1. if mw_func_CheckProcessToken == 1 (GetSidSubAuthority == 0x1000)[SECURITY_MANDATORY_LOW_RID], performs escalation ⇢ ShellExecuteW (RunAs)

Once checks are completed, the mw_wrap_func_string_decrypt() function is triggered, and strings are decrypted in bulk.

The decryption function

The wrap function is just initializing, in sequence, the decryption of every single string

Taking for instance the first entry as a reference, it can be clearly seen how mw_func_string_decrypt takes three parameters

1
2
3
4
 .text:00405680 PUSH     0x22                                 ; length of the encrypted strings
 .text:00405682 PUSH     s_I8VN5ESIRT1VVWX4EQMEPW9XK60WSDIR       ; encrypted strings
 .text:00405687 PUSH     DAT_004117bc                             ; key
 .text:0040568c CALL     mw_func_string_decrypt   ; int mw_func_string_decrypt(int key, char *

Every string requires a different key to decrypt itself, once cleaned-up, mw_func_string_decrypt, looks like the following

Pseudo code can be replicated in python, like this

1
2
3
4
5
6
7
8
9
def decrypt_string(key, enc_str):
    decrypted_string = bytearray()
    counter = 0
    if len(enc_str) != 0 :
        while (counter < len(enc_str)):
            dec_string = enc_str[counter % len(enc_str)] ^ key[counter % len(key)]
            decrypted_string.append(dec_string)
            counter += 1
    return decrypted_string

To proof the code, some encrypted strings can be taken from the payload as a reference.

1
2
3
4
5
6
key = bytearray([0x7B, 0x23, 0x47, 0x3A, 0x20, 0x26, 0x5C, 0x39, 0x18, 0x2B, 0x3B, 
    0x29, 0x64])
enc_str = bytearray([0x59, 0x53, 0x34, 0x4e, 0x4f, 0x54, 0x39, 0x5a, 0x36 , 0x4f, 
    0x57, 0x45, 0x46])

print(decrypt_string(key, enc_str).decode('utf-8')) # "pstorec.dll"

But enough talk … Have at you!

Building the Ghidra script

1. Code reference to the decrypt function

For this sample, code is located @0x00405623

1
2
3
4
5
6
7
8
def extract_encrypted_str(xrefs):
    for xref in xrefs:
        ref_addr = (xref.getFromAddress())
        get_function_args(ref_addr.toString())

def run():
	xrefs = getReferencesTo(toAddr(0x00405623))
	extract_encrypted_str(xrefs)

2. Get function arguments

Given the xrefs address, get_function_args will scan backwards every code blocks and look for - in reverse order - the following pattern.

  1. PUSH xor key
  2. PUSH encrypted string
  3. (PUSH length of encrypted string)

The last entry in the list is not really necessary, since it is confirmed it will be the length of the encrypted string, thus it can be skipped and computed at script runtime.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# store mapping -> (xor_key, (enc_string, enc_string_start_address) )
buffer = []

def return_bytes(address):
    """
    address:    memory address

    Read undefined bytes at a given address until 
    two consecutive instructions are equal to 0x0, 0x0

    For instance
        0x00000001 = 0x0
        0x00000002 = 0x0

    Returns
    -------
    bytearray
    """
    barray = bytearray()
    start_address = toAddr(address)
    address = start_address
    get_byte = getByte(address)

    while get_byte != 0:
        get_byte = getByte(address)
        if get_byte == 0 and getByte(address.add(1)) != 0:
            break
        barray.append(get_byte)
        address = address.add(1)

    return barray

def get_function_args(addr, data_struct=None):
    """
    addr:           memory address
    data_struct:    tuple data structure to track results

    Given xref address calling the string decrypt
    function, scan for the pattern.
    
    Returns
    -------
    None
    """

    # At first run, get the instruction before the CALL
    # to mw_func_string_decrypt
    ins_before_call = getInstructionBefore(toAddr(addr))

    # Start scanning instructions backwards

    # get instruction at offset address
    ins_before_call_address = ins_before_call.getAddress()

    # get first operand instruction
    instruction = ins_before_call.toString().split()[0]
    
    # ensure 1st argument is PUSH -> xor key
    if instruction == 'PUSH' \
        and ins_before_call.getDefaultOperandRepresentation(0).startswith('0x'):
        # extract XOR key
        xor_key_start_address = ins_before_call.toString().split()[1]
        
        # reads bytes at xor_key_start_address
        key = return_bytes(xor_key_start_address)

        ins_before_call = getInstructionBefore(ins_before_call_address)
        ins_before_call_address = ins_before_call.getAddress()
        instruction = ins_before_call.toString().split()[0]

        # ensure 2nd argument is PUSH -> encrypted string
        if instruction == 'PUSH' \
                and ins_before_call.getDefaultOperandRepresentation(0).startswith('0x'):
            enc_string_start_address = ins_before_call.toString().split()[1]
            enc_string =  getDataAt(toAddr(enc_string_start_address))

            # build structure, maps xor key and encrypted string
            struct = (key, (enc_string, enc_string_start_address) )
            buffer.append(struct)

    elif instruction == 'MOV' \
        and ( ins_before_call.getDefaultOperandRepresentation(0).startswith('[0x') \
            and ins_before_call.getDefaultOperandRepresentation(1) == 'EAX'):
        # if the 1st argument is not PUSH but MOV, recall function 
        get_function_args(ins_before_call_address.toString())

The last elif statement, skips operation of type, mov [0x...... ], EAX and recalls get_function_args, going back of one instruction. The skipped code is just storing into a memory location the decrypted string of a previous call to mw_func_string_decrypt - more on this later.

3. Execute decryption

After the previous step, the buffer variable contains a mapping between the xor keys and the encrypted strings. It’s now enough to iterate through it and apply the decryption, calling the decrypt_string snippet - introduced at the end of the previous section.

1
2
3
4
5
6
for key, (enc_string, enc_string_start_address) in buffer:
    _enc_string = bytearray()
    # horrible but we will live with that ...
    enc_string = enc_string.toString().split()[1].strip("\"")
    for s in enc_string: _enc_string.append(ord(s))
    x = decrypt_string(key, _enc_string, enc_string_start_address).decode('utf-8').strip("\"")

4. Processing results

Finally, decrypted strings are added to Ghidra as comments and labels

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# for key, (enc_string, enc_string_start_address) in buffer:
    ...
    ...
    # strings can be shrinked - just for formatting purposes
    # to fit within labels and comments
    # x = '_'.join(x[:40].strip("\\").split()) 

    # create label
    createLabel(toAddr(enc_string_start_address), x, False)

    codeUnit = listing.getCodeUnitAt(toAddr(enc_string_start_address))
    ds_string = getDataAt(toAddr(enc_string_start_address))

    # set comment
    ds_string.setComment(codeUnit.EOL_COMMENT, x)

Below a flat list of the revealed strings

http://nkpotu.xyz/Kpot2/gate.ph
rmGJUQE5lueQotqhTfG4DDY
\Microsoft\Windows\CurrentVersion
accounts
\Martin Prikryl\WinSCP 2\Sessions
FileZilla
recentservers
sitemanager
Ipswitch\WS_FTP\Sites\ws_ftp
Web Data
logins
Software
Install Directory
webappsstore
Path
credentials
CABINET
Valve\Steam
config
loginusers
wallet.dat
Crypto
\drivers\
VBox
Guest
Mouse
Video
Ethereum
keystore
Electrum
Bytecoin
Armory
Namecoin
monero-project
wallet_path
key3.db
Browsers
\Microsoft\Windows NT\CurrentVersion
\Microsoft\Cryptography
global-salt
moz_formhistory
moz_cookies
masked_credit_cards
fieldname
baseDomain
host_key
autofill
encrypted_value
is_secure
is_httponly
cape.dll
isSecure
isHttpOnly
%s	TRUE	%s	%s	%ld	%s	%s
expiry
%s x%d
IP: %S
MachineGuid: %s
CPU: %S (%d cores)
RAM: %S MB
Screen: %dx%d
PC: %s
User: %s
LT: %S (UTC+%d:%d)
GPU:
pstorec.dll
vaultcli.dll

The wrap function that was decrypting all strings in bulk looks indeed more talkative than before

Two down, one to go

Decrypting network traffic

Unveiled strings scattered around the code helps quite a bit, and building the big picture about payload’s functionalities is getting easier. For this part of the analysis, the 2nd string in order of appearance from the top list is fundamental for for the next steps.

DAT_00415C50 = mw_func_string_decrypt((int)&DAT_004117fc,rmGJUQE5lueQotqhTfG4DDY,0x19); 

The result of mw_func_string_decrypt gets stored into DAT_00415C50, cross-referencing on this memory area (renamed into cnc_enc_key), leads the analysis to another interesting portion of the code; it turns out, this is the hard-coded xor key shared with the C&C server.

This snippet block (taken from a bigger chunk of code) is in charge for reading a stream object (use to store collected information from the system), xor encrypt and send it to the command-and-control server.

Pseudo code can be translated to python as follows

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# read raw data sent to gate.php
with open('raw.blob', 'r'): as f
    exfiltrate = bytearray(f.read().encode('utf-8'))

def decrypt_buffer(cnc_enc_key, buffer):
    cnc_enc_key = bytearray(cnc_enc_key.encode('utf-8'))
    buffer = bytearray(buffer)

    for x, _byte in enumerate(buffer): 
        buffer[x] = _byte ^ cnc_enc_key[x % len(cnc_enc_key)]

    return buffer

decrypt_buffer(exfiltrate, cnc_enc_key).decode('utf-8')

To test the code, data sent (below an excerpt) to the gate.php endpoint, gets translated essentially from this

to

(output formatted for better visualization)

USD914BC32101291311131 # bot_id
SYSINFORMATNON: Windows 7 Professional x32
MachineGuid: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
IP: xxxx.xxxx.xxxx.xxxx
CPU: Intel(R) Core(TM) xx-xxxx CPU @ x.xxGHz (x cores)
RAM: xxxx.x MB
Screen: xxxxxxxxx
PC: xxxxxx
Use: xxxxxxxxx
LT: xxxx-xx-x .....  # local time -> %Y-%m-%d %H:%M:%S' (UTC+X)
GPU: xxxxxxxxxxxxxxxxx
Layouts: x/xx/
_SYSINFORMATION_

these are a subset (by default EarthVPN, NordVPN and Firefox credentials are always collected - if present) of the information Kpot will send back to the C&C if none of the stealer functionalities are enabled.

Compared to other Any.Run samples reports, the specimen here analyzed did not retrieve the initial configuration from the server’s config.php endpoint, but just received “OK” instead.

Investigating further Kpot core components, yields the bot true capabilities. The memory address, here renamed in features_array_0041676c is used as a reference to check if specific functions of the bot should be enabled (value set to 1) or not, as specified by the configuration retrieved from the C&C.

Targeted cryptocurrency

Targeted FTP clients - interesting to see TotalCommander FTP in the list …

Targeted VPN clients

For instance, executing Kpot in another controller env and enabling functions

it transmits the below additional blob

_SYSINFORMATNON_ delimits the end of the previous OS info message (as it was observed in the initial decrypted message), whereas XMPP:, FTP: and VPN: denotes the start of new sections, the end of the same are marked with _XMPP, _FTP and _VPN; last but not least, every section separates exfiltrated data with the corresponding delimiter between underscores (e.g. _FTP_).

To note, that the VPN delimiter is present but no data was collected, this because one of the VPN client was indeed detected on the system but credentials were not extracted.

ATT&CK Techniques

Tactic ID Name
Privilege escalation T1134 Access Token Manipulation
Defense evasion T1107 File Deletion
Credential Access T1552.001 Credentials from Files
Credential Access T1555.003 Credentials from Web Browser
Credential Access T1539 Steal Web Session Cookie
Discovery T1087 Account Discovery
Discovery T1083 File and Directory Discovery
Discovery T1518 Software Discovery
Discovery T1082 System Information Discovery
Discovery T1033 System Owner/User Discovery
Collection T1119 Automated Collection
Collection T1005 Data from Local System
Command and Control T1043 Commonly Used Port
Command and Control T1024 Custom Cryptography Protocol
Command and Control T1132 Data Encoding
Command and Control T1102 Web Service
Exfiltration T1020 Automated Exfiltration
Exfiltration T1002 Data Compressed
Exfiltration T1022 Data Encrypted
Exfiltration T1041 Exfiltration Over Command and Control Channel

Tags: