11:51:54 阅读量:1

2026-3月Solar应急响应公益月赛排名及官方题解

#安全运营 #威胁情报 #安全技术

1. 3月月赛排名

2026年3月Solar应急响应公益月赛已圆满结束。以下为最终WP提交情况(部分选手因未在规定时间内提交WP,不计入最终排名)

以下为3月月赛最终排名结果

月赛榜总分统计(积分相同排名并列)

2. 平台介绍

天狩·网络安全竞赛平台是由 思而听网络科技有限公司 推出的一款Saas化部署的网络安全竞赛平台。平台可满足CTF、AWD、渗透赛等各种赛制的举办需要,可以满足万人同时竞赛的需要,支持最高全国级的网络安全大赛承办。具备竞赛中心、竞赛管理、练习场、试卷管理、赛题管理、人员管理、报名管理、数据中心、日志管理、防作弊机制、3D大屏等功能模块,能够全面、精准地考核选拔网络安全人才。

3. 赛事回顾

在3月举行的Solar应急响应公益月赛中,共有300余位选手参与。本月赛题围绕溯源分析、流量分析、逆向工程三个方向展开出题。

本次应急响应挑战赛溯源分析题真实模拟了“浏览器扩展程序劫持”这一隐蔽攻击链路。全赛题环环相扣,从网络层的“初露端倪”到最终提取“终极远控”,不仅考验选手的取证功底,更是一场逻辑推理的博弈。

本套题目的设计逻辑遵循了标准的“剥洋葱式”取证思维。首先,要求选手具备进程关联分析能力,能够从网络连接中精准定位到合法程序(Chrome)下的异常行为;随后进入浏览器深度取证阶段,考察选手对 Chrome 扩展 ID 识别、本地文件系统时间戳分析(MAC 时间)以及浏览器 History 数据库溯源的熟练度。

在中后期的实战中,重点转向了静态代码审计与动态行为监控。选手需要深入恶意插件的 JavaScript 源码,剖析其静默获取主机外网 IP 的 API 调用逻辑,并识别出隐藏在代码中的钓鱼重定向 URL。最后,通过对二阶段载荷(运维助手.exe)的动态运行分析,利用流量监控工具成功提取出攻击者幕后的 C2 控制端 IP 与端口。

趋势榜单

解题榜单

4. 3月月赛WP

4.1 Tunnel Traffic(流量分析题)

memdump.lime ──→ LiME解析 ──→ 定位wg_device结构体 ──→ 提取密钥
                                                        │ static_private
                                                        │ PSK
                                                        │ Transport Keys (sending_key / receiving_key)
                                                        │ (er_priv 已被清零 → Wireshark 无法使用)
                                                        ↓
capture.pcapng ──→ 从 QUIC/TLS/ICMP/DNS 噪声中
                   识别 WireGuard 流量 ──→ 密钥关联
                                                        ↓
                                           使用 transport keys 直接解密
                                                        ↓
                                              ChaCha20-Poly1305 解密
                                                        ↓
                                                IP分片重组 + TCP重组
                                                        ↓
                                              HTTP POST → tar.gz → flag

WireGuard 在完成 Noise_IKpsk2 握手后会清零临时私钥 (ephemeral private key),这是前向保密性的安全设计。Wireshark 的 WireGuard 解密功能需要 keylog 文件中的 LOCAL_EPHEMERAL_PRIVATE_KEY,而该密钥在内存中已被清零,因此无法使用 Wireshark 的半自动解密功能,因此需要必须找到内核中 noise_keypair 结构体里存储的 transport keys (T_send / T_recv),直接进行 ChaCha20-Poly1305 解密。

打开 file.pcapng,观察到 5 种流量(共约 153 个包):

WireGuard Type 1 (Handshake Initiation):  01 00 00 00  → 总长 148 字节
WireGuard Type 2 (Handshake Response):    02 00 00 00  → 总长 92 字节
WireGuard Type 4 (Transport Data):        04 00 00 00  → 总长 ≥ 32 字节
# WireGuard 识别逻辑
for pkt in udp_443_packets:
    payload = bytes(pkt[UDP].payload)
    msg_type = struct.unpack('<I', payload[:4])[0]
    if msg_type == 1 and len(payload) == 148:
        print("WireGuard Handshake Initiation")
    elif msg_type == 2 and len(payload) == 92:
        print("WireGuard Handshake Response")
    elif msg_type == 4 and len(payload) >= 32:
        print("WireGuard Transport Data")
    else:
        print("Likely QUIC traffic")

发现两个 WireGuard 会话:

  • Flow A: UDP/51820(192.168.1.100 ↔ 10.10.0.1,标准端口,看起来是企业 VPN)

  • Flow B: UDP/443(203.0.113.50 ↔ 198.51.100.10,非标端口)

LiME 格式结构

struct lime_header {
    uint32_t magic;     // 0x4C694D45 ("EMiL" little-endian)
    uint32_t version;   // 1
    uint64_t start;     // 物理起始地址
    uint64_t end;       // 物理结束地址
    uint64_t reserved;  // 0
};
// 紧跟 (end - start + 1) 字节的内存数据

解析代码:

import struct

LIME_MAGIC = 0x4C694D45
HEADER_SIZE = 32

with open("memdump.lime", "rb") as f:
    data = f.read()

offset = 0
segments = []
while offset < len(data) - HEADER_SIZE:
    magic = struct.unpack_from('<I', data, offset)[0]
    if magic != LIME_MAGIC:
        break
    version = struct.unpack_from('<I', data, offset + 4)[0]
    start = struct.unpack_from('<Q', data, offset + 8)[0]
    end = struct.unpack_from('<Q', data, offset + 16)[0]
    seg_size = end - start + 1
    seg_data = data[offset + HEADER_SIZE: offset + HEADER_SIZE + seg_size]
    segments.append({'start': start, 'end': end, 'data': seg_data})
    offset += HEADER_SIZE + seg_size
    print(f"Segment: 0x{start:08x} - 0x{end:08x} ({seg_size} bytes)")

输出 4 个内存段(共 ~16 MB)

定位 WireGuard 设备结构体

在内存中搜索接口名 wg0\x00 和 wg1\x00:

for seg in segments:
    for name in [b'wg0\x00', b'wg1\x00']:
        idx = seg['data'].find(name)
        if idx != -1:
            print(f"Found '{name}' at offset 0x{idx:x}")

解析 wg_device 结构体

WireGuard 内核模块中的关键结构体布局:

struct wg_device {
    struct net_device *dev;                          // ← 指向 net_device,接口名在 dev->name[16]
    struct crypt_queue encrypt_queue, decrypt_queue, handshake_queue;
    struct sock __rcu *sock4, *sock6;
    struct net __rcu *creating_net;
    struct noise_static_identity static_identity;    // ← ★ 目标密钥 1 在这里
    struct workqueue_struct *packet_crypt_wq,
                            *handshake_receive_wq,
                            *handshake_send_wq;
    struct cookie_checker cookie_checker;
    struct pubkey_hashtable *peer_hashtable;
    struct index_hashtable *index_hashtable;
    struct allowedips peer_allowedips;
    struct mutex device_update_lock, socket_update_lock;
    struct list_head device_list, peer_list;         // ← peer 链表起点
    atomic_t handshake_queue_len;
    unsigned int num_peers, device_update_gen;
    u32 fwmark;
    u16 incoming_port;
};

struc crypt_queue encrypt_queue, decrypt_queue, handshake_queue三个多核工作队列(基于prt_ring)分别处理:

  • 加密出站包

  • 解密入站包

  • 握手包(Noise协议)

ptr_ring核心结构

struct net_sensitive_ring {
    unsigned long bits;   // 8 = 8 项/批,16 = 16 项/批
    __u64 magic;          // 验证用
    __u64 pointer[bits];  // 数据指针数组
};

工作原理:

  • 环形缓冲区存储一批数据包 (N 个)

  • pointer[0]..pointer[N-1] 指向每个包的上下文中

  • 消费方和生成方共享同一块内存,避免数据拷贝

在 wg_device 中的作用

struct crypt_queue {
    struct ptr_ring ring;     // ← 环形缓冲区
    void *data[ptr_ring_bits]; // ← 每个位置的数据候选
    // ...
};

三个专用队列的用途:

队列 类型 宽度 用途
encrypt_queue 加密队列 8 项/批 并行加密多个数据包
decrypt_queue 解密队列 8 项/批 并行解密多个数据包
handshake_queue 握手队列 16 项/批 轻量级握手同步

性能优化机制

数据流向:
发送端 [队列生产者]
    ↓ 填入一批数据
ptr_ring ← → 环形缓冲区 |
接收端 [队列消费者] ← 消费一批数据

关键特性:

  • 零拷贝:生产者把数据放入缓冲区,消费者直接消费

  • 并行处理:多核 CPU 同时处理不同队列

  • 批处理:减少函数调用和上下文切换

  • 确定性延迟:固定长度的批处理,便于调度

对应到 WireGuard 的 wg_device 队列:

  • encrypt_queue: 发送时的加密队列

  • decrypt_queue: 发送时的解密队列

  • handshake_queue: 安全协议握手的数据包队列

这些队列都内嵌在 struct wg_device 中,实现了完整的数据处理流水线

struct sock __rcu sock4, sock6 IPv4 / IPv6 UDP socket(RCU 保护)。WireGuard 所有 UDP 流量都走这两个 socket(端口默认 51820)。

struct net __rcu creating_net 该 wg 接口所属的网络命名空间(netns),支持容器/多租户。

struct noise_static_identity static_identity ← ★ 目标密钥 1 本地密钥身份(Noise 协议静态密钥):

struct noise_static_identity {
    u8 static_public[32];     // 本设备公钥
    u8 static_private[32];    // ★ 本设备私钥(内存偏移 0x68)
    struct rw_semaphore lock;
    bool has_identity;        // 是否已设置密钥(内存偏移 0x40,8字节对齐后==1)
};

要提取的设备私钥(32字节)

三个 workqueue_struct 专用工作队列:

  • packet_crypt_wq:加解密线程池

  • handshake_receive_wq / send_wq:握手处理线程池 (提升高负载性能)

struct cookie_checker cookie_checker WireGuard 抗 DoS 机制(Handshake Initiation 的 MAC cookie 验证)。

struct pubkey_hashtable peer_hashtable 以 peer 公钥为 key 的哈希表,快速查找 peer。

struct index_hashtable index_hashtable 以 handshake 临时 index 为 key 的表(sender/receiver index 查找用)。

struct allowedips peer_allowedips Allowed IPs 前缀树(trie)。决定“哪个 IP 段走哪个 peer”。

struct mutex device_update_lock, socket_update_lock 两个保护锁:

  • device_update_lock:配置变更(add peer、改密钥等)

  • socket_update_lock:socket 绑定变更

struct list_head device_list, peer_list

  • device_list:全局所有 wg 接口链表

  • peer_list:本设备的所有 peer(内存 offset 0xC0 开始的 struct wg_peer[])

atomic_t handshake_queue_len 当前握手队列长度(原子计数,避免锁竞争)。

unsigned int num_peers, device_update_gen

  • num_peers:当前 peer 数量(内存 offset 0xB0)

  • device_update_gen:配置更新世代号(用于 netlink 同步)

// 假设 wg_device 基址 = BASE

BASE + 0x00  struct net_device *dev          (8)
BASE + 0x08  encrypt_queue                   (~??)
BASE + 0x20  decrypt_queue
BASE + 0x38  handshake_queue

BASE + 0x50  struct sock *sock4              (8)
BASE + 0x58  struct sock *sock6              (8)
BASE + 0x60  struct net *creating_net        (8)

BASE + 0x68  noise_static_identity
    +0x00  has_identity (u8 / padding → 实际占 8 bytes)
    +0x08  static_public[32]
    +0x28  static_private[32]      ★ 目标1

--------------------------------------------------
BASE + 0xA8  workqueue pointers (3 × 8 bytes)

BASE + 0xC0  cookie_checker (~结构体)

BASE + 0xF0  pubkey_hashtable *
BASE + 0xF8  index_hashtable *

BASE + 0x100 allowedips

BASE + 0x140 mutex (device_update_lock)
BASE + 0x168 mutex (socket_update_lock)

--------------------------------------------------

BASE + 0x190 list_head device_list
BASE + 0x1A0 list_head peer_list   ★ peer入口

--------------------------------------------------

BASE + 0x1B0 handshake_queue_len
BASE + 0x1B8 num_peers (u32)
BASE + 0x1BC device_update_gen
BASE + 0x1C0 fwmark
BASE + 0x1C4 incoming_port
import struct

LIME_MAGIC = 0x4C694D45
HEADER_SIZE = 32

with open("memdump.lime", "rb") as f:
    data = f.read()

offset = 0
segments = []
while offset < len(data) - HEADER_SIZE:
    magic = struct.unpack_from('<I', data, offset)[0]
    if magic != LIME_MAGIC:
        break
    version = struct.unpack_from('<I', data, offset + 4)[0]
    start = struct.unpack_from('<Q', data, offset + 8)[0]
    end = struct.unpack_from('<Q', data, offset + 16)[0]
    seg_size = end - start + 1
    seg_data = data[offset + HEADER_SIZE: offset + HEADER_SIZE + seg_size]
    segments.append({'start': start, 'end': end, 'data': seg_data})
    offset += HEADER_SIZE + seg_size
    print(f"Segment: 0x{start:08x} - 0x{end:08x} ({seg_size} bytes)")

for seg in segments:
    for name in [b'wg0\x00', b'wg1\x00', b'wg2\x00', b'wg3\x00', b'wg4\x00']:
        idx = seg['data'].find(name)
        if idx == -1:
            continue
        has_offset = idx + 0x40
        if has_offset + 8 > len(seg['data']):
            continue
            
        has_identity = struct.unpack_from('<Q', seg['data'], has_offset)[0]
        if has_identity != 1:
            continue

        priv_offset = idx + 0x68
        private_key = seg['data'][priv_offset : priv_offset + 32]
        print(f"   设备私钥 (目标密钥1): {private_key.hex()}")
        
        pub_offset = idx + 0x48
        public_key = seg['data'][pub_offset : pub_offset + 32]
        print(f"   设备公钥: {public_key.hex()}")
        

        num_offset = idx + 0xB0
        num_peers = struct.unpack_from('<I', seg['data'], num_offset)[0]
        print(f"   Peer 数量: {num_peers}")
        

        peer_magic = struct.pack('<I', 0xDEADCAFE)
        peer_idx = seg['data'].find(peer_magic, idx + 0xC0)
        
        peer_count = 0
        while peer_idx != -1 and peer_count < num_peers + 5:
            peer_count += 1
            handshake_offset = peer_idx + 52
            psk_offset = handshake_offset + 76 # remote_static[32] + ephem[32] + timestamp[12] = 76
            
            if psk_offset + 32 <= len(seg['data']):
                psk = seg['data'][psk_offset : psk_offset + 32]
                if psk != b'\x00' * 32:                 # 过滤全零的假阳性
                    print(f"     → Peer {peer_count} PSK (目标密钥2): {psk.hex()}")
            peer_idx = seg['data'].find(peer_magic, peer_idx + 4)

            
            
"""
Segment: 0x00000000 - 0x000fffff (1048576 bytes)
Segment: 0x00100000 - 0x003fffff (3145728 bytes)
Segment: 0x00400000 - 0x007fffff (4194304 bytes)
Segment: 0x00800000 - 0x00ffffff (8388608 bytes)
   设备私钥 (目标密钥1): 086fb449f70b2f55cf7d3ddfb30de297e88b57d4bc31e6a68f5fb1c89ed8854e
   设备公钥: 59ee45d80579c4eab0c5aae7a4b2c6bd74aa0982e6b5d0acc72beb79b155843c
   Peer 数量: 1
     → Peer 2 PSK (目标密钥2): fc46acac3c5df5d7a4758c7bdb44016e0342b73d93fe9470c001ada5791dd744
   设备私钥 (目标密钥1): a0f049b06a2e39bb4213fb359bffc32a41de71a250e10a56a6b26aea0d873b7c
   设备公钥: eefbe63794a279273fca0e1d5ac93f5a7bd62af955ba6ffb74cf31510db55b2f
   Peer 数量: 1
     → Peer 1 PSK (目标密钥2): fc46acac3c5df5d7a4758c7bdb44016e0342b73d93fe9470c001ada5791dd744
"""

验证密钥正确性

提取 static_private 后,通过 Curve25519 计算公钥并与 static_public 比对:

from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives import serialization
priv_bytes = bytes.fromhex('a0f049b06a2e39bb4213fb359bffc32a41de71a250e10a56a6b26aea0d873b7c')
pub_bytes = bytes.fromhex('eefbe63794a279273fca0e1d5ac93f5a7bd62af955ba6ffb74cf31510db55b2f')
priv = X25519PrivateKey.from_private_bytes(priv_bytes)
computed_pub = priv.public_key().public_bytes(
    serialization.Encoding.Raw, serialization.PublicFormat.Raw)
print("Second key match:", computed_pub == pub_bytes)
print("Computed pub:", computed_pub.hex())

'''
Second key match: True
Computed pub: eefbe63794a279273fca0e1d5ac93f5a7bd62af955ba6ffb74cf31510db55b2f
'''

提取 Transport Keys

在 peer 结构体之后搜索 noisekp\x00 标记,该标记指向 noise_keypair 结构体:

noise_keypair 结构体(从 noisekp\x00 标记之后):
┌─ offset +0: is_valid (4 bytes, ==1)
├─ offset +4: sending_index (4 bytes)        ← receiver_index 关联
├─ offset +8: receiving_index (4 bytes)       ← receiver_index 关联
├─ offset +12: sending_key[32]               ← ★ Transport Key (发送方向)
├─ offset +44: receiving_key[32]             ← ★ Transport Key (接收方向)
└─ offset +76: er_priv_area[32]             ← 全零!(前向保密性)
import struct

LIME_MAGIC = 0x4C694D45
HEADER_SIZE = 32

with open("memdump.lime", "rb") as f:
    data = f.read()

offset = 0
segments = []

while offset < len(data) - HEADER_SIZE:
    magic = struct.unpack_from('<I', data, offset)[0]
    if magic != LIME_MAGIC:
        break
    
    version = struct.unpack_from('<I', data, offset + 4)[0]
    start = struct.unpack_from('<Q', data, offset + 8)[0]
    end = struct.unpack_from('<Q', data, offset + 16)[0]
    
    seg_size = end - start + 1
    seg_data = data[offset + HEADER_SIZE : offset + HEADER_SIZE + seg_size]
    
    segments.append({'start': start, 'end': end, 'data': seg_data})
    
    print(f"Segment: 0x{start:08x} - 0x{end:08x} ({seg_size} bytes)")
    
    offset += HEADER_SIZE + seg_size

for seg in segments:
    for name in [b'wg0\x00', b'wg1\x00', b'wg2\x00', b'wg3\x00', b'wg4\x00']:
        idx = seg['data'].find(name)
        if idx == -1:
            continue
        
        has_offset = idx + 0x40
        if has_offset + 8 > len(seg['data']):
            continue
            
        has_identity = struct.unpack_from('<Q', seg['data'], has_offset)[0]
        if has_identity != 1:
            continue
        priv_offset = idx + 0x68
        private_key = seg['data'][priv_offset : priv_offset + 32]
        print(f"   设备私钥 (目标密钥1): {private_key.hex()}")
        
        pub_offset = idx + 0x48
        public_key = seg['data'][pub_offset : pub_offset + 32]
        print(f"   设备公钥: {public_key.hex()}")
        
        num_offset = idx + 0xB0
        num_peers = struct.unpack_from('<I', seg['data'], num_offset)[0]
        print(f"   Peer 数量: {num_peers}")
        
        peer_magic = struct.pack('<I', 0xDEADCAFE)
        peer_idx = seg['data'].find(peer_magic, idx + 0xC0)
        
        peer_count = 0
        while peer_idx != -1 and peer_count < num_peers + 5:
            peer_count += 1
            print(f"   → Peer {peer_count} (偏移 0x{peer_idx:x})")
            

            handshake_offset = peer_idx + 52
            psk_offset = handshake_offset + 76  # remote_static[32] + remote_ephemeral[32] + latest_timestamp[12]
            
            if psk_offset + 32 <= len(seg['data']):
                psk = seg['data'][psk_offset : psk_offset + 32]
                if psk != b'\x00' * 32:
                    print(f"       PSK (目标密钥2): {psk.hex()}")
            
            marker = b'noisekp\x00'
            mk_idx = seg['data'].find(marker, peer_idx)
            if mk_idx != -1:
                print(f"       找到 noisekp@0x{mk_idx:x}")
                kp_off = mk_idx + len(marker)
                
                if kp_off + 108 <= len(seg['data']):
                    is_valid = struct.unpack_from('<I', seg['data'], kp_off)[0]
                    
                    if is_valid == 1:
                        print("       ✓ noise_keypair valid == 1")
                        
                        sending_index   = struct.unpack_from('<I', seg['data'], kp_off + 4)[0]
                        receiving_index = struct.unpack_from('<I', seg['data'], kp_off + 8)[0]
                        sending_key     = seg['data'][kp_off + 12 : kp_off + 44]
                        receiving_key   = seg['data'][kp_off + 44 : kp_off + 76]
                        er_priv_area    = seg['data'][kp_off + 76 : kp_off + 108]
                        
                        print(f"       sending_index:   {sending_index}")
                        print(f"       receiving_index: {receiving_index}")
                        print(f"       sending_key:     {sending_key.hex()}")
                        print(f"       receiving_key:   {receiving_key.hex()}")
                        print(f"       er_priv_area:    {er_priv_area.hex()}")
            peer_idx = seg['data'].find(peer_magic, peer_idx + 4)
           
'''
Segment: 0x00000000 - 0x000fffff (1048576 bytes)
Segment: 0x00100000 - 0x003fffff (3145728 bytes)
Segment: 0x00400000 - 0x007fffff (4194304 bytes)
Segment: 0x00800000 - 0x00ffffff (8388608 bytes)
   设备私钥 (目标密钥1): 086fb449f70b2f55cf7d3ddfb30de297e88b57d4bc31e6a68f5fb1c89ed8854e
   设备公钥: 59ee45d80579c4eab0c5aae7a4b2c6bd74aa0982e6b5d0acc72beb79b155843c
   Peer 数量: 1
   → Peer 1 (偏移 0xd65d)
       找到 noisekp@0xd765
       ✓ noise_keypair valid == 1
       sending_index:   39124473
       receiving_index: 2341889286
       sending_key:     f2ca460d7b4c6bb517ec7d575bcc6ffa832799135e44606a4aaf15370a2146c8
       receiving_key:   462b5e9492353911c5a065cce2ddaa60bfe73b53a268adb95e4adeb6048ec065
       er_priv_area:    0000000000000000000000000000000000000000000000000000000000000000
   → Peer 2 (偏移 0x116b1)
       PSK (目标密钥2): fc46acac3c5df5d7a4758c7bdb44016e0342b73d93fe9470c001ada5791dd744
       找到 noisekp@0x117b9
       ✓ noise_keypair valid == 1
       sending_index:   1269992707
       receiving_index: 3380792094
       sending_key:     4a9dab3843a1bb6a287db96151247b332398d2bd7351e69c74cd36837e74eacc
       receiving_key:   f7c2b4be8a3d1ca5938d7aec4e5c40be7e300d7f25190b6fc406048ba53caea6
       er_priv_area:    0000000000000000000000000000000000000000000000000000000000000000
   设备私钥 (目标密钥1): a0f049b06a2e39bb4213fb359bffc32a41de71a250e10a56a6b26aea0d873b7c
   设备公钥: eefbe63794a279273fca0e1d5ac93f5a7bd62af955ba6ffb74cf31510db55b2f
   Peer 数量: 1
   → Peer 1 (偏移 0x116b1)
       PSK (目标密钥2): fc46acac3c5df5d7a4758c7bdb44016e0342b73d93fe9470c001ada5791dd744
       找到 noisekp@0x117b9
       ✓ noise_keypair valid == 1
       sending_index:   1269992707
       receiving_index: 3380792094
       sending_key:     4a9dab3843a1bb6a287db96151247b332398d2bd7351e69c74cd36837e74eacc
       receiving_key:   f7c2b4be8a3d1ca5938d7aec4e5c40be7e300d7f25190b6fc406048ba53caea6
       er_priv_area:    0000000000000000000000000000000000000000000000000000000000000000
'''

最终提取到的密钥

密钥 来源 用途
sr_priv (32 bytes) wg1 设备 offset 0x68 静态私钥(用于关联流量)
PSK (32 bytes) wg1 peer noise_handshake Pre-Shared Key(非零 → 确认为攻击者隧道)
sending_key (32 bytes) wg1 noise_keypair Transport 层发送密钥
receiving_key (32 bytes) wg1 noise_keypair Transport 层接收密钥
er_priv (32 bytes) wg1 noise_keypair 全零 — Wireshark 无法使用

关键判断:

  • wg0: PSK 全零,端口 51820 → 正常企业 VPN(诱饵) 脚本中将全零的部分进行了删除

  • wg1: PSK 非零,端口 443 → 攻击者隧道(目标)

密钥-流量关联

使用提取的 sr_priv 尝试解密两个流的 Handshake Initiation:

# 从 PCAP 中 Handshake Initiation 包提取:
ei_pub = packet[8:40]              # initiator ephemeral public key
encrypted_static = packet[40:88]   # encrypted initiator static public key (32+16 tag)
encrypted_timestamp = packet[88:116]  # encrypted TAI64N timestamp (12+16 tag)

# Noise_IKpsk2 前两步 DH (es + ss) 用于解密 handshake:
C = blake2s(CONSTRUCTION)
H = blake2s(C + IDENTIFIER)
H = blake2s(H + sr_pub)           # MixHash(sr_pub)
C, _ = kdf2(C, ei_pub)            # MixKey(ei_pub)
H = blake2s(H + ei_pub)           # MixHash(ei_pub)
DH_es = Curve25519(sr_priv, ei_pub)
C, κ = kdf2(C, DH_es)
si_pub = ChaCha20Poly1305_Decrypt(κ, 0, encrypted_static, H)
# 如果解密成功 → 该设备是此流的 responder
import hashlib
import hmac
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305

def blake2s_hash(data, sz=32):
    return hashlib.blake2s(data, digest_size=sz).digest()

def hmac_blake2s(key, data):
    return hmac.new(key, data, hashlib.blake2s).digest()

def kdf2(key, inp):
    t0 = hmac_blake2s(key, inp)
    t1 = hmac_blake2s(t0, b'\x01')
    t2 = hmac_blake2s(t0, t1 + b'\x02')
    return t1, t2

CONSTRUCTION = b"Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s"
IDENTIFIER   = b"WireGuard v1 zx2c4 Jason@zx2c4.com--"

ei_pub = bytes.fromhex("ded1ffd0f51d268bc64d0c08ee4fe8dc1feebeb70d34cfaabc81654887870267")
encrypted_static = bytes.fromhex("3b45e60ac45a17e1a48a1209db46915860e41cf8199f9af1e0ed41b0abf2f730e86970f23f59011818270d4e99b2cee8")

sr_priv_bytes = bytes.fromhex("a0f049b06a2e39bb4213fb359bffc32a41de71a250e10a56a6b26aea0d873b7c")
sr_pub_bytes  = bytes.fromhex("eefbe63794a279273fca0e1d5ac93f5a7bd62af955ba6ffb74cf31510db55b2f")

sr_priv = X25519PrivateKey.from_private_bytes(sr_priv_bytes)
ei_pub_obj = X25519PublicKey.from_public_bytes(ei_pub)

C = blake2s_hash(CONSTRUCTION)
H = blake2s_hash(C + IDENTIFIER)
H = blake2s_hash(H + sr_pub_bytes)
C, _ = kdf2(C, ei_pub)
H = blake2s_hash(H + ei_pub)
DH_es = sr_priv.exchange(ei_pub_obj)
C, k = kdf2(C, DH_es)
chacha = ChaCha20Poly1305(k)
nonce = b'\x00' * 12
si_pub = chacha.decrypt(nonce, encrypted_static, H)
print(si_pub.hex())

'''
c0c4a1eda00eae2f0f88b5d8e3e70b7696436cef7ce4d88f1833d0c94cc14639
'''

结果:

  • wg0 的 sr_priv 成功解密 Flow A (port 51820) → wg0 是企业 VPN(诱饵)

  • wg1 的 sr_priv 成功解密 Flow B (port 443) → wg1 是攻击者隧道(目标)

通过解密得到的 si_pub 匹配 peer 列表中的公钥,进一步确认 PSK。

sr_priv (本地私钥)
       |
       |----> DH (sr_priv x ei_pub)
       |
       v
     KDF ---------------> 中间 key k
       |
       | (解密 encrypted_static)
       v
     si_pub -----------> 对端静态公钥 (验证 DH 正确)
       |
       | (匹配 peer 列表)
       v
  PSK -> KDF -> session key
       |
       | (用于解密实际通信)
       v
  正确解密 → PSK 正确

传输数据解密(使用 Transport Keys)

不需要重建 Noise_IKpsk2 完整派生链 因为 transport keys 已经从内存中提取。

关键:根据 receiver_index 判断使用哪个密钥。这里的 transport keys 是从 Responder 的内存中提取的:

# 从 Responder 内存视角:
# - sending_key: R 用来发送的密钥
# - receiving_key: R 用来接收的密钥
# - sending_index: I 接收 R 的包时看到的 receiver_index
# - receiving_index: R 接收 I 的包时看到的 receiver_index

# 在 PCAP 中:
# I → R 的包: receiver_index = tk['receiving_index'] → 用 receiving_key 解密
# R → I 的包: receiver_index = tk['sending_index']   → 用 sending_key 解密

from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305

def decrypt_transport(key, counter, ciphertext):
    nonce = struct.pack('<I', 0) + struct.pack('<Q', counter)
    aead = ChaCha20Poly1305(key)
    return aead.decrypt(nonce, ciphertext, b'')  # AAD = empty

for pkt in transport_packets:
    ridx = pkt['receiver_index']
    if ridx == tk['receiving_index']:
        # 来自 Initiator 的包
        plaintext = decrypt_transport(tk['receiving_key'], pkt['counter'], pkt['data'])
    elif ridx == tk['sending_index']:
        # 来自 Responder 的包
        plaintext = decrypt_transport(tk['sending_key'], pkt['counter'], pkt['data'])

    if len(plaintext) == 0:
        pass  # keepalive 包,跳过
    else:
        inner_packets.append(plaintext)  # 解密后的 IP 包
from scapy.all import rdpcap, UDP
import struct
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305

def parse_pcap(file):
    packets = rdpcap(file)
    flows = []
    flow = {
        "transport": []
    }
    for pkt in packets:
        if UDP in pkt:
            data = bytes(pkt[UDP].payload)

            # WireGuard Transport packet: type = 4
            if len(data) > 16 and data[0] == 4:
                receiver_idx = struct.unpack("<I", data[4:8])[0]
                counter = struct.unpack("<Q", data[8:16])[0]
                enc_data = data[16:]

                flow["transport"].append({
                    "receiver_idx": receiver_idx,
                    "counter": counter,
                    "enc_data": enc_data,
                    "pkt": pkt
                })

    flows.append(flow)
    return flows

def decrypt_transport(flow, tk):

    dec = []
    ka_count = 0
    err = 0

    for tp in flow['transport']:
        ridx = tp['receiver_idx']
        ctr = tp['counter']
        ed = tp['enc_data']

        if ridx == tk['receiving_index']:
            key = tk['receiving_key']
            direction = "I->R"
        elif ridx == tk['sending_index']:
            key = tk['sending_key']
            direction = "R->I"
        else:
            err += 1
            continue

        try:
            nonce = struct.pack('<I', 0) + struct.pack('<Q', ctr)
            pt = ChaCha20Poly1305(key).decrypt(nonce, ed, b'')

            if len(pt) == 0:
                ka_count += 1
            else:
                dec.append({
                    'data': pt,
                    'dir': direction,
                    'time': float(tp['pkt'].time)
                })

        except Exception as e:
            err += 1
    return dec

if __name__ == "__main__":

    file = "capture.pcapng"
    flows = parse_pcap(file)
    tk = {
        "sending_index": 1269992707,
        "receiving_index": 3380792094,
        "sending_key": bytes.fromhex("4a9dab3843a1bb6a287db96151247b332398d2bd7351e69c74cd36837e74eacc"),
        "receiving_key": bytes.fromhex("f7c2b4be8a3d1ca5938d7aec4e5c40be7e300d7f25190b6fc406048ba53caea6")
    }
    result = decrypt_transport(flows[0], tk)
    for i, pkt in enumerate(result):
        print(pkt['data'].decode(errors="ignore"))

解密结果:14 个数据包 + 2 个 keepalive。

IP 分片重组 + TCP 流重组

部分解密后的 IP 包被分片(WireGuard 隧道内 MTU 限制为 576):

# 检查 IP header 中的 MF (More Fragments) 标志和 Fragment Offset
flags_offset = struct.unpack('>H', ip_header[6:8])[0]
mf = (flags_offset >> 13) & 0x1
frag_offset = (flags_offset & 0x1FFF) * 8

if mf or frag_offset > 0:
    # 收集同一 (src, dst, ip_id, proto) 的所有分片
    # 按 frag_offset 排序
    # 拼接 payload

TCP 流重组

按 sequence number 排序 TCP 段并拼接 payload:

segments.sort(key=lambda s: s['seq'])
stream_data = b''.join(s['payload'] for s in segments)
import struct
from collections import defaultdict

hex_data = """
450000280d370000400659970a0000020a000001e7a600500007c122000000005002fffff2bf0000
450000280d380000400659960a0000010a0000020050e7a6000e05e70007c1235012ffffecb90000
450000280d390000400659950a0000020a000001e7a600500007c123000e05e85010ffffecba0000
450004d80d3a0000400654e40a0000020a000001e7a600500007c123000e05e85018ffffe1b80000504f5354202f75706c6f616420485454502f312e310d0a486f73743a2031302e302e302e310d0a557365722d4167656e743a206375726c2f372e38382e310d0a436f6e74656e742d547970653a206d756c7469706172742f666f726d2d646174613b20626f756e646172793d2d2d2d2d457866696c426f756e646172793764326133630d0a436f6e74656e742d4c656e6774683a20323935330d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a2d2d2d2d2d2d457866696c426f756e646172793764326133630d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d2266696c65223b2066696c656e616d653d226261636b75702e7461722e677a220d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6f637465742d73747265616d0d0a0d0a1f8b0800aca6bb6902ffed987b34546b1fc729b7714d2e839469448798fb189723971042e49292cb9e3ddb18e6c2cc18a452228790501222e238115227a122c929924b2ea5440885d2c925ba78c771def59ed53fef3fe7b5d679d77cd6defbb9fd7ecf7e9eb59fef5ecfef2103605068301a6204d3591110c441811c9ed0df0c868f0181f047cae7fb1483c5e185b0442cd18084c3e3887c3b2c1e8737104260845681500e1760f387f2774cf2af93fb8740a3e8310106a40731001a5d8f02057325b17af6ac0026c29541e306e805f2b3e6208b1d8ca2b34080ae67cda44ae2f4ec012684b062417a81fccc5f9b6d684c492101ff1cc82bfaf7a73101264803e8be210454389d13be8afa272ce7bfd33fc98028d0ff6a50b0ade4d2a5fe5de7a9b387dcb167129be34fce60e2ba7996eb955d5fc97fbe54ec55a27cb8a1c5b8135caa960831b862b29e9643f8221d20859c694f5c3c877398b9d670d9a42b44faedb07d942d7343d3d6d3e1f317072a9fb455d6757d84147259598ee90f2e8ebc5119fca63422bbb15a679de797e19e3c1176bd4d5e516e10af32adf975335e34e48db4a8ca65b16e1cfc9822c7ac8962b16d5bb77261528178f4c6076f2792e282a6af0dbe91b6c7dda8b44dcae2b6ea74bf2cc7baf7a575d41a47b484040cfea81a2e95feeda35bc8d78e3a3010256e8d8baaed2f2727980416ee8acbcdc9d06a0c353373339ed827b7696d627a9490a2f0d730907eef6166d9e71a965ada2942e9cf63497da7dfddd8206b0d7a22b5edb973bd4b924236f3b977eb4159ff918e370927a27f453acb1fb3907511ce3add61507302d2edd0b0e8b35b7a3459fb7481aee89f94a89235b801363821a6dff1e0ddac55b2cd0fb62f5a8e5f92bc695a4445b64de43f3a4896f5b58977dc9364e9d1a3a30cdc1ac8f20a392b4e4f40bbd97656429ecebf18a93eafb7dd34bd3fe4fdba98b2e734c2f90a82dbd56f70a7fc5cac77e7bd99e73b940f4676ce2b7d7e2daa0b77dbb6bdca902c0b66c96ca9adb74db7f992bd6bfd810558f1d527438e6fc3b4b831ca95f8348d43e61a8d7a725c198d9891dbfd11763db6d3531a0780deadd2375a8ec499ceff24db34227cddb647a1de05644b3bf453c91a29a041f9a8da53d2ab9b7274cd4766675cac889e9a8d8dd25beb354bf7713e66b780891cf495d2890ef69529a582739d7ea7676243adb3d26f209fc55914c35eb7fc645861
450002540d3b2000400637670a0000020a000001e7a600500007c5d3000e05e85018ffffd9170000048b502a168777c666cd3f39382e397e16afb2bb3dc61bf69ce28e0c6cf75c7248ed0cf38e88d18c1a4e2eb247758352a6f5a2334db0b9e89342d5bea2b574dea4fca0650c62aa9174c8ac75380ca6e6d0bb3026311a9faa9b7bffebac67c8af5b5ed08a46d4c4cd93072e8e1eaff5538f8df6fcf0ed5599e6387d615eb2eb29728aa0f0d54aca151c6aae3a831f88bb227e3dcf2c2c25dfdbfd3ab16df17a4e597e6ee2de469fdfef27c3b2a6365cd5bfc58eefc25611120c6bba08c9e64b1d09e11efeaab7b9eb6891bfd9dcd7235729cd151bed6c72dd37415a703a1ce7440eac731c72bf46de81675a49afadeaf397b9eedae4599d619f627a6bc3e3ed9d3a06f04983daf68ebd937ee78a89173c8f75eef48e7dfc24b6fc743bf2e0a253edd08d8a45d8039d91b73b742e9cedf1852370c38ead29e2b1a6c0a7c9f8c25373a187e09a493e1fb24893a14396fa03d7de3b69ff6ae2b073b7a6c1cf22a732b7982fa5ac5f42da9072366a2f9233d32e4cf74f8ac1c4dd3d899eeee8b6dd8f0ccf7a723eafcf444b8c7167afa93e014828a1743325b4c61ef4a5ccaf77b524bae5a96246a3d977c6af183bcf87be2a7aa1bb765769694cece0a44a76facb2e5f979a8fa565e158977559d8a1d0f4f31652dd206dea67dea80bf558c06bc6b4dd1a19158b06a498dc57e9a8ea8258f9b5264a793bbccf2be46b9f14eb79c7ba493579aadcbc35bfc429f95bf4c52dc0edd98af13b2ef69d3b3cda7e48423f1b8d42286faca724e72e5967c8
450002540d3b20484006371f0a0000020a000001b6a5cd0b997b58284fc27ab379928484f71f7bcdec4e4c3569a9675686851dd84bfa90595b91b709dd16581834d0b0d08ec5985633f6d370d90b25e872d37dd37eb7a72dd699b2ab0b8f3406c6b31c86de2c34fcf8f148fc970bd2f2c22c1f638747bbf75fdf10db1af44474e1c61d2d5136fd884fbb2ed21f859fe80b20963ed4854ff52a583f3965c7bea30128da39b7cb0f5ba9838ed683cea2e729f3c132137a4b76f1375f954d5075645c616eb86107a943de19a66183e9961d1b8dcaa41a9fe73e975d94d7e87ea8953971537e4ed4422e9982ed3ae151dc8977d4007ed4148e433ac7bca89f74f4d8b5eb61a3e1fb86d13e73b77d8fab5a4a2bbc9adbce6df67ecfac0b652966d3fa5fc68ca6667aeae8b8ebe6b636dd87d2112136410a3eeaf6fddb19d684f100b8cea6044df8d858a949f9e58699a0308534ed8582d9332135e02d6adb8b7087e23091549e592f69c3b973893eaf63b728c03ba3e215d55b532fbecee5219e557937bc6819fda4d081f8ddab1a1e5ef39396c417531b75ef8cb3b272ba9aa9aa63754b1235a3ea5ee3a70ec0d6da7d96f72b2c42cccfabf549951cd7a2b05efabec928b8e0979f655a51f1a8a7762e9c7c604081d5ff7b0d5d5335c76e0d2ff2786bf75bb38dc60b77cc0db7ba16345b85a9b8c0aae79f9a2b97b0cba7eb542a51cd86c9a6bada05bf31d3255db6bb7ddaf941e45462848388e57dbfc316673e158ff8e62bcee4f8fb68558966bb138b15ee913b0dc319e52a2526c1a7738fe26638b0a63c6cb8b848579dd040a0eaa535551efef0
450000580d3b0090400658d30a0000020a00000156d984cde2e562912e8cab1b190f8737ab6902bdf96836f5b72d05f03ead227bc5a8cf133f845fe09eb914bd3857d6ce539619d8ede7d4f2f80855cde416e234e9ba586f
450003010d3c0000400656b90a0000020a000001e7a600500007ca83000e05e85018ffff2e830000c7ded78d0e66730e67e5aa7b42ee069b17c7dd2c37c5b08fde9faacfbd7c2f4fe39a5eb5d72fc715509f92856df7a5aade6d9b6e1e2c1e534bf57e85c4150cbaeb8f058dcb12150a361f9cd5de767e346d31b5751d836e7c62ad58fcbbfbf34351baaeb3b894bcb426deb1138b252f1dd5839c47174308934a85d487d16f843dfb8a1e619432a485553888079f71b080ec7269edca48f4e054d2ec9af66347221ee86aab1e55d1d4c3243f17393f967ff5885215c9fdd9e465afa9f56e76c402250ff73d48f3b1c2eacc12f4ce9bc4e4d8f90491a0f6a4de0e2a5dce42abea20314626a4b99ab929b12f03b5c71c71f4a895e2aee8a6873901190c1f2d78dd9983b392f7ca6e47fe72f2cffd1f13e286b1d841be141a4065030c543093ba8af11fe6fbf88fc0df130af67fab41bcb3d34e1949b5e5a04dc6ced66a0f3f155ebe25d6f09f2961ae2ff989b49d95855b7836f8498edf202c24aaf2d469d9d5cedac9aaccd22f5a1044fdf3e3bf3dd616568ed62806e57ff18effa67f8c01e93bfde3301841fcb72a68229cd9ac4008e4222cff58099216a15c1603e04214c4cad2409023fecce9035488c945f07028ace08ce7ff4cff2890c5f4a751d1201ba2f03f320da07350811c167355f44fc011bfd33f11872709f4bf1a444a2210482098e60b3129c12c1a938b34462003b8dc608e311acd2f426c264047fde78417cdb745f37048bd653f2e2b08622e3bf8d3016a2419c060202cc55f1f34f2c7eb130090a46f08fa13f501032c996884370021ace1e11547283c98c68638cbae380cce401f8bd3c763dd707863a211ffdabf62c481d83c1a08f90220c80a5d19188707eaafacd8bf1c3aaf5807436c068dc3a1b198cbdd7af1abf8956c08a01803f415137e398c4de342c62b3dfcbb0ea030684c63fe88586c2e925fe52d7958f05b102040800001020408102040800001ff67fc0bcbf69c0a002800000d0a2d2d2d2d2d2d457866696c426f756e646172793764326133632d2d0d0a
450000280d3d0000400659910a0000010a0000020050e7a6000e05e80007cd5c5010ffffe0810000
450000d80d3e0000400658e00a0000010a0000020050e7a6000e05e80007cd5c5018ffffc65b0000485454502f312e3120323030204f4b0d0a5365727665723a206e67696e782f312e32342e300d0a436f6e74656e742d4c656e6774683a2036340d0a436f6e74656e742d547970653a206170706c69636174696f6e2f6a736f6e0d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a7b22737461747573223a226f6b222c226d657373616765223a2255706c6f6164207265636569766564222c2262797465735f7772697474656e223a333039347d
450000280d3f00004006598f0a0000020a000001e7a600500007cd5c000e06985010ffffdfd10000
450000280d4000004006598e0a0000020a000001e7a600500007cd5c000e06985011ffffdfd00000
450000280d4100004006598d0a0000010a0000020050e7a6000e06980007cd5d5011ffffdfcf0000
450000280d4200004006598c0a0000020a000001e7a600500007cd5d000e06995010ffffdfcf0000
"""


packets = []
for line in hex_data.strip().splitlines():
    line = line.strip()
    if not line:
        continue
    packets.append(bytes.fromhex(line))

ip_fragments = defaultdict(list)

def parse_ip_packet(pkt):
    ip_header = pkt[:20]

    ver_ihl = ip_header[0]
    ihl = (ver_ihl & 0x0F) * 4

    total_len = struct.unpack(">H", ip_header[2:4])[0]
    identification = struct.unpack(">H", ip_header[4:6])[0]

    flags_offset = struct.unpack(">H", ip_header[6:8])[0]
    mf = (flags_offset >> 13) & 0x1
    frag_offset = (flags_offset & 0x1FFF) * 8

    proto = ip_header[9]

    src = ip_header[12:16]
    dst = ip_header[16:20]

    payload = pkt[ihl:total_len]

    key = (src, dst, identification, proto)

    ip_fragments[key].append({
        "offset": frag_offset,
        "mf": mf,
        "data": payload
    })

def reassemble_ip():
    reassembled = []
    for key, frags in ip_fragments.items():
        # 按偏移排序
        frags.sort(key=lambda x: x["offset"])
        data = b""
        for f in frags:
            data += f["data"]
        reassembled.append((key, data))
    return reassembled

tcp_streams = defaultdict(list)

def parse_tcp(ip_payload, key):
    if len(ip_payload) < 20:
        return  # 防止短包
    tcp_header = ip_payload[:20]

    src_port, dst_port = struct.unpack(">HH", tcp_header[:4])
    seq = struct.unpack(">I", tcp_header[4:8])[0]

    data_offset = (tcp_header[12] >> 4) * 4
    payload = ip_payload[data_offset:]

    flow = (key[0], key[1], src_port, dst_port)
    tcp_streams[flow].append({
        "seq": seq,
        "payload": payload
    })

def reassemble_tcp():
    streams = {}
    for flow, segs in tcp_streams.items():
        segs.sort(key=lambda s: s["seq"])
        data = b"".join(s["payload"] for s in segs)
        streams[flow] = data
    return streams

for pkt in packets:
    parse_ip_packet(pkt)

ip_packets = reassemble_ip()

for key, ip_payload in ip_packets:
    parse_tcp(ip_payload, key)

streams = reassemble_tcp()

for _, data in streams.items():
    print(data.hex())

重组结果:

  • Client → Server (10.0.0.2 → 10.0.0.1:80): ~3167 bytes(HTTP POST)

  • Server → Client (10.0.0.1 → 10.0.0.2): ~176 bytes(HTTP Response)

文件提取与 Flag

重组之后的数据包:

POST /upload HTTP/1.1
Host: 10.0.0.1
Content-Type: multipart/form-data; boundary=----ExfilBoundary7d2a3c
Content-Length: 2991

------ExfilBoundary7d2a3c
Content-Disposition: form-data; name="file"; filename="backup.tar.gz"
Content-Type: application/octet-stream

<binary tar.gz data>
------ExfilBoundary7d2a3c--

手动提取tar.gz

读取 credentials.json

{
  "api_endpoint": "https://internal.corp.local/api/v2",
  "token": "flag{ba00e1df-c9f3-4ac7-8cf5-a61b5936ce18}",
  "expires": "2026-12-31T23:59:59Z",
  "service_account": "admin123456@admin.com",
  "permissions": ["read:all", "write:backup", "admin:export"]
}

本题 无法使用 Wireshark 的 WireGuard 解密功能。原因如下:

  1. Wireshark 解密 WireGuard 需要 keylog 文件,格式为:
LOCAL_STATIC_PRIVATE_KEY=<base64>
LOCAL_EPHEMERAL_PRIVATE_KEY=<base64>
PRESHARED_KEY=<base64>
  • 其中 LOCAL_EPHEMERAL_PRIVATE_KEY 是必需项 — Wireshark 需要它来重建 Noise_IKpsk2 握手中的 4 次 DH 交换(es, ss, ee, se),从而派生出 transport keys。

  • 在本题的内存 dump 中,er_priv(ephemeral private key)区域已被清零。这是 WireGuard 的真实行为:内核在完成握手并派生出 transport keys 后,会立即擦除临时密钥以确保前向保密性。

  • 因此必须:

    • 找到 noise_keypair 结构体中已经派生好的 transport keys

    • 直接用这些 transport keys 进行 ChaCha20-Poly1305 解密

    • 而不是重建 Noise_IKpsk2 握手流程

4.2 bash_dump(逆向题)

题目附件压缩包解压后的文件结构.从上到下分别为加密器,bash的dump文件,加密的flag文件,dump工具程序,dump工具源码.

为了避免奇奇怪怪的问题,程序全部采用静态编译,所以会比较大.

题面说得很清晰了,不必解释.文字游戏,看懂就好.

这题重要的是知识点,而且毕竟今年第一个月比赛,没必要出太难,所以没上难度,下个月再上难度.这题如果要考得难有很多方法,比如去符号,那样应该就会少很多解了.

这题比较符合我喜欢的出题规范,这种题的解法一般有两种,一种简单的方法能取巧(ai也算),另一种是规规矩矩逆向完整流程得到结果.

简单的方法,逆向加密器后获得参数格式,正则搜索所有dump文件,得到结果组合成公钥点传入自定义曲线,找到在曲线上的那一个点,然后攻击得到私钥,根据程序中的函数(没有去符号)写出cryptopp解密程序解密即可(解密函数和加密函数就两三个有区别,参数稍微变一下就行).这种方法不作详解.当然直接用ai跑的师傅,你已经离天三尺三了...不必多说了吧?

说说逆向的解法.

题面: 菜鸡小赵第一次做应急响应,这次遇到了一个linux勒索病毒,他拿着(公司提供的dump工具)登上机器,发现黑客正在执行内网横向,(没有发现勒索病毒进程,只发现了黑客的bash进程).所以他就(将bash进程使用公司的dump工具dump下来),然后他找到了被勒索的文件,并根据勒索信特征在网络上找到了相关分析文章,向作者拿到了勒索病毒的hash,并在一个(类似vt的平台)上下载到了样本.但是不知道接下来该怎么做.

四个重点.

第一个意思是dump程序是开源的,不考察逆向,集中考点于知识点.所以源码就是给你看的.

第二个意思是没有给你勒索病毒的参数,要你另外想办法.

第三个意思是给你的dump文件是一个普通bash的内存dump.

第四个意思是这些文件里面有个名字像hash的文件,他就是样本.同时避免暴力搜索程序名字,然而这并避免不了什么.

考虑到第一个,直接打开USD.c.源码看不看无所谓,知道是dump每个区段然后写入文件,带上起止地址即可.

考虑到第二个和第四个,打开这个文件名像个hash(其实就是加密器文件的sha256)的文件,发现的确是加密器.

c++读取文件,没有必要解释.符号都在.

看注释就够了.

参数提示.

ecc_enc函数.比sage还简单...

自定义曲线,输入公钥(16进制格式),ecies默认配置混合加密(对称加密是什么都不用管了,直接调decrypt解密即可).

不给私钥的话,那肯定要攻击了.阶扔进sage分解一下看看.

所以,拿到公钥的话,Pohlig-Hellman直接就能做.那现在去找公钥.

考虑到第二个和第三个重点,能够想到bash history的使用.但是这时也没法执行history命令,所以还得另想办法.

猜想,bash打开的时候,会预先读取并缓存下bash history的信息,存在内存中的一个位置.

所以,找到对应版本的bash并逆向,是首要任务.当然你愿意手动根据dump出来的区段去拼elf也可以.

第一个bin搜索Bash version得到bash版本4.3.48.在linux软件包的版本号中,最后一个小数点后面是补丁号,所以版本号是4.3.官方未发行4.3.48,而是以补丁的形式逐步更新.最新的发行版能下载到4.3.30,所以只要下载4.3.30,然后链式应用所有补丁直到4.3.48即可.

下载bash 4.3.30和所有补丁,patch后编译得到二进制文件.

通过符号搜索history,找到该函数.

能够发现history信息的指针数组的指针.记录函数符号名:add_history.

ida分析该文件.

在同样位置找到被去除符号的the_history,记录地址:0x7015e8.

打开对应地址区间中的dump文件.

记录小端序qword:0x252e408.

打开对应区间dump.

找到对应偏移.随意跟进一个地址,如0x23e1888.

跟进来到这里,可以发现找到了history的信息.由于这个dump其实为堆的dump,所以history信息全部在其中,可以使用正则表达式搜索16进制格式的字符串,假设长度与曲线的阶长度相当,即28,以获得传入的参数.

能够找到传入的参数,但是不止一个.但是个数为偶数,说明是配对的,方法正确.

提取四对参数,判断公钥点是否在曲线上.

经检测,选出仅有的一对,为正确公钥点,开始使用pohlig-hellman攻击计算私钥.

私钥计算脚本(sage):

获得私钥后,即可编写对应的cryptopp代码解密文件.

参考解密脚本:

linux x64编译,cryptopp版本5.6.2.

编译.

执行解密程序.

得到结果.

4.3 谁偷了我的数据?(溯源分析题)

背景与环境说明

某科技公司(目标域名设定为 solarsecurity.cn)的安全运维人员小李,近期在负责搭建内部的安全运营平台。但在最近的例行检查中,态势感知设备发出高危告警:小李的办公电脑存在频繁的异常外联,且伴随被远控的迹象。目前仅捕获到其对外访问的可疑IP地址为:47.105.126.219。

本次排查任务需要以该可疑IP为线索,上机还原完整的攻击链路。

  • 受害主机账号/密码:solar / Solar2026

环境与靶场说明: 本环境参考文章:【Solar 应急预警】国家安全部揭露境外间谍黑产,针对境外间谍插件窃密技术深度解析(附自查清单)

设计本环境主要为了在实战中针对黑灰产远控、浏览器插件外联事件进行排查演练。整体排查思路涵盖:前期使用工具定位外联主程序,中期定位恶意插件及进行代码审计,后期追踪远控木马、进行清理及总结。

靶场访问与资源链接:

靶场在线环境及相关应急响应题目列表展示

题目清单

任务一:初露端倪(寻找发起连接的主程序)

题目描述: 安全设备捕获到了恶意的网络通信,但究竟是哪个本地程序在发起这些请求?请排查并提交实际建立该可疑连接的“宿主”程序绝对路径。

提交格式: flag{C:\xx\xx\xx.exe}

预期答案: flag{C:\Program Files\Google\Chrome\Application\chrome.exe}

任务二:顺藤摸瓜(深入排查隐蔽恶意组件)

题目描述: 经验丰富的应急响应人员深知,刚才找到的宿主程序本身是合法的白名单软件,它只是一个“躯壳”。真正的幕后黑手是潜伏在其中被加载的某个恶意核心组件。请深入排查,揪出这个隐蔽的恶意模块,并提交该组件的唯一标识符(ID)。

提交格式: flag{xxxxxxxxxxxx}

预期答案: flag{bibkdnmjdmickicfinmfmelnhicamlde}

任务三:时间刻度(锁定本地落地时间)

题目描述: 梳理时间线是还原攻击过程的关键一步。请查明这个恶意组件最初被植入到受害者本地主机的准确修改时间。

提交格式: flag{YYYY-M-D-H:MM:SS} (注意带上连字符和冒号)

预期答案: flag{2025-9-26-3:33:35}

任务四:追根溯源(寻找初始感染源)

题目描述: 小李究竟是怎么中招的?请结合受害主机留下的历史痕迹,找出小李最初获取/下载该恶意组件的来源网站地址。

提交格式: flag{``http://xxx.com/``}

预期答案: flag{``http://blog.fake-sec-tools.com/``}

任务五:代码审计(剖析组件核心逻辑)

题目描述: 提取出该恶意组件的源码后,我们需要明确它的窃密行为。请对其进行代码审计,找出代码中用于静默获取受害主机互联网出口IP的外部API地址。

提交格式: flag{``https://xxx.com/xxx``}

预期答案: flag{``https://api.ipify.org?format=json``}

任务六:致命诱饵

题目描述: 通过深入分析发现,该恶意组件不仅窃密,还会向受害者下发欺骗性的伪造弹窗。请找出受害者点击弹窗后,被重定向去下载后续恶意负载(远控木马)的钓鱼网站完整URL。

提交格式: flag{http://x.x.x.x:port/xxx/xxx/xx}

预期答案: flag{``http://47.105.126.219:8080/fake/index.html``}

任务七:终极远控(提取C2基础设施)

题目描述: 受害者在上述钓鱼页面中被诱导下载并执行了进一步的远控木马。请对该木马文件进行分析(或结合流量/日志),提取出攻击者最终用于深度控制受害主机的C2地址和端口。

提交格式: flag{x.x.x.x:port}

预期答案: flag{``143.55.28.36:1234``}

解题步骤

任务一:初露端倪(寻找发起连接的主程序)

安全设备捕获到了恶意的网络通信,但究竟是哪个本地程序在发起这些请求?请排查并提交实际建立该可疑连接的“宿主”程序绝对路径。

分析与排查:

登录受害主机后,会发现系统自动打开了Google Chrome浏览器。在题目的模拟环境中,为了复现用户日常被植入恶意插件后持续外联的场景,系统预设了开机自启的计划任务来触发浏览器。在真实的实战场景中,受害者往往是在日常办公、浏览网页时手动打开浏览器,而潜伏的恶意插件便会在后台进行无感知的网络外联,甚至在用户访问特定业务域名时进行页面劫持,窃取敏感数据。

受害主机登录后自动唤起Chrome浏览器,模拟日常办公场景

在排查进程的网络行为时,推荐使用专业的系统行为分析工具,这能够大幅提升定位的准确率和效率。常用的工具包括 ProcessExplorer、火绒剑、Process Hacker 以及 Sysmon 等。

在本环境中,我们可以使用桌面提供的火绒安全软件。依次打开“火绒安全软件 -> 安全工具 -> 火绒剑”。

通过火绒安全工具箱启动火绒剑高级分析工具

进入火绒剑后,点击左上角的“开启监控”,工具便会开始捕获当前系统中所有进程的底层行为(涵盖文件读写、注册表操作以及网络通信等)。

火绒剑开启系统级监控,准备捕获异常外联行为

根据告警情报,已知目标外联IP为 47.105.126.219。在火绒剑的搜索框中输入该IP进行条件过滤。稍作等待,监控日志中便会出现相关的网络请求,清晰地指出发起该网络动作的进程名为 chrome.exe。

过滤指定IP后,成功定位到发起外联请求的进程为 chrome.exe

双击该条网络监控记录或右键查看进程属性,可以获取到该宿主程序的完整绝对路径。

在动作详细信息中,提取 chrome.exe 的物理完整路径

预期答案: flag{C:\Program Files\Google\Chrome\Application\chrome.exe}

任务二:顺藤摸瓜(深入排查隐蔽恶意组件)

经验丰富的应急响应人员深知,刚才找到的宿主程序本身是合法的白名单软件,它只是一个“躯壳”。真正的幕后黑手是潜伏在其中被加载的某个恶意核心组件。请深入排查,揪出这个隐蔽的恶意模块,并提交该组件的唯一标识符(ID)。

分析与排查:

Chrome 浏览器产生异常外联,在实战中通常可以归纳为以下三种主流情况:

  1. 正常业务交互:用户访问了包含大量第三方资源的网站,产生了正常的网络流量。

  2. 插件后台行为:浏览器安装的扩展插件在后台静默与服务端进行通信(如版本更新、数据同步,或恶意数据回传)。

  3. DLL 白加黑利用:Chrome 进程加载了被替换的恶意 DLL 库,由恶意 DLL 发起网络连接。

结合本题背景,排查重点应放在浏览器插件上。在 Chrome 中,可以使用快捷键 Shift + Esc 呼出浏览器自带的“任务管理器”,这有助于快速甄别是哪个特定的页面或服务在占用网络。

Chrome 任务管理器中,发现一个 Service Worker 在使用网络服务,并暴露了插件ID

从图中可以观察到一个正在运行的 Service Worker,其关联的插件 ID 为 bibkdnmjdmickicfinmfmelnhicamlde。

随后,在浏览器地址栏输入 chrome://extensions/(或通过右侧菜单进入“扩展程序”管理页面)。

进入 Chrome 的扩展程序管理中心进行核查

在列表中找到对应 ID 的插件,发现其伪装名为“内网安全运维加速助手”。点击“详情”按钮进入深层排查。

核对可疑插件的名称及其唯一标识符(ID)

在插件的详情页底部,可以查看到该“未打包的扩展程序”的本地加载来源路径。点击该路径链接,即可直接定位到恶意组件在本地磁盘的存放目录。

通过扩展程序的“来源”属性,定位其实际落地的本地文件夹

进入该目录后,对插件源码文件进行初步检视。打开 background.js 文件,可以明显看到代码中硬编码的 C2_HOST 地址正是我们在告警中发现的可疑 IP 47.105.126.219。

(注:实战中的恶意插件代码往往会经过高度混淆或加密,需要进行额外的反混淆处理才能看到真实的 C2 地址。)

在 background.js 中确认外联 IP,完成证据链闭环

条件都可以一一对应,所以插件/组件的ID为:bibkdnmjdmickicfinmfmelnhicamlde

预期答案: flag{bibkdnmjdmickicfinmfmelnhicamlde}

任务三:时间刻度(锁定本地落地时间)

梳理时间线是还原攻击过程的关键一步。请查明这个恶意组件最初被植入到受害者本地主机的准确修改时间。

分析与排查:

确定了恶意文件存放的目录后,我们需要排查文件的落地时间。通常情况下,文件的“修改时间”可以近似看作其落地的初始时间(前提是该文件在此后未被恶意篡改,或者攻击者没有刻意利用工具修改时间戳)。

查看恶意插件核心文件的属性面板,提取准确的修改时间戳

从文件属性中提取修改时间即可锁定其植入的时间刻度。

预期答案: flag{2025-9-26-3:33:35}

任务四:追根溯源(寻找初始感染源)

小李究竟是怎么中招的?请结合受害主机留下的历史痕迹,找出小李最初获取/下载该恶意组件的来源网站地址。

分析与排查:

明确了恶意文件的落地时间(凌晨 3:33 左右)后,我们需要通过溯源浏览器的历史记录来寻找感染入口。如果手动在浏览器中翻阅海量的历史记录,效率极低且容易遗漏。

浏览器自带的历史记录界面,人工排查耗时且不直观

为了提高排查效率,推荐使用专业的浏览器数据提取工具,例如 HackBrowserData。 在命令行中运行该工具,它会自动解析系统内主流浏览器的本地数据库,并将书签、历史记录、下载记录等提取并格式化输出到 results 目录下的 CSV 文件中。

使用 HackBrowserData 提取浏览器底层数据库中的敏感信息

打开 chrome_default_download.csv(Chrome默认下载记录),浏览器会对用户的所有下载行为进行留存。

实战经验拓展:无论是下载记录还是浏览记录,其本质都是被浏览器存储在本地的 SQLite 数据库文件(即 History 文件)中。基于 Chromium 内核的浏览器默认通常只保留最近 90 天的常规网页访问记录。如果攻击者清理了痕迹,或者 DB 文件被删除,常规的提取工具将失效,此时必须通过磁盘扇区级的深度数据恢复或底层文件系统取证来尝试还原。

在导出的下载记录中,结合时间戳比对,发现 extension.zip 的下载来源

在记录中,发现一条发生于 2025-09-26 03:18:19 的下载行为,下载文件为 extension.zip,其来源 URL 明确指向了一个伪造的安全工具博客。时间点和文件类型与事件高度吻合

预期答案: flag{http://blog.fake-sec-tools.com/}

任务五:代码审计(剖析组件核心逻辑)

提取出该恶意组件的源码后,我们需要明确它的窃密行为。请对其进行代码审计,找出代码中用于静默获取受害主机互联网出口IP的外部API地址。

分析与排查:

首先对提取出的恶意插件目录结构进行梳理:

关键文件审计:

  1. manifest.json: 该文件声明了极高的权限(如读取所有网站数据、Cookie、历史记录、地理位置等),并指定 content.js 仅在匹配 ://.solarsecurity.cn/* 时运行,暴露出明显的针对性攻击意图。

  2. content.js(核心攻击载荷片段): 当用户访问目标网站时,该脚本会立刻停止原页面加载,并通过 iframe 注入伪造的钓鱼页面,同时启动键盘记录器(Keylogger)和本地存储窃取。

通过上述代码审计,可以明确该组件在静默获取受害者公网 IP 时,调用了外部接口 https://api.ipify.org?format=json。

预期答案: flag{https://api.ipify.org?format=json}

任务六:致命诱饵

通过深入分析发现,该恶意组件不仅窃密,还会向受害者下发欺骗性的伪造弹窗。请找出受害者点击弹窗后,被重定向去下载后续恶意负载(远控木马)的钓鱼网站完整URL。

分析与排查:

在 Windows 操作系统中,所谓的“弹窗”常常是以系统右下角的通知形式呈现。

实战经验拓展:需要注意的是,Windows系统的通知栏存在“已读/交互即焚”机制。当受害者点击该伪造的安全警告弹窗后,该通知消息会被系统自动从侧边栏清除,不会留下直观的视觉痕迹。这大大增加了事后取证的难度。

插件利用系统原生 API 触发的伪装通知弹窗,极具欺骗性

由于弹窗点击后发生了下载行为,我们再次回到前面导出的 chrome_default_download.csv 记录进行排查。

在下载历史记录中,发现时间线吻合的远控木马下载事件及源 URL

发现在 2026-01-29 06:48:17,主机下载了一个名为“运维助手.exe”的程序。

在应急响应的实战中,任何未经确认的可执行程序下载事件都应被列入高度可疑的范畴。对应的下载 URL 恰好指向了我们已知的 C2 服务器的特定目录。

结合威胁情报平台及开放端口探测,可以进一步佐证该 URL 为托管恶意负载的钓鱼页面。

预期答案: flag{http://47.105.126.219:8080/fake/index.html}

任务七:终极远控(提取C2基础设施)

受害者在上述钓鱼页面中被诱导下载并执行了进一步的远控木马。请对该木马文件进行分析(或结合流量/日志),提取出攻击者最终用于深度控制受害主机的C2地址和端口。

分析与排查:

根据前一步提取的浏览器下载日志,确定该远控木马程序落地在 C:\Users\Solar\Downloads\ 目录下。

在文件系统中定位被执行的远控木马实体文件

获取到样本后,最快速的分析方法是将其上传至云沙箱(如微步云沙箱、奇安信沙箱等)进行动态行为分析,提取其网络外联特征。

利用云沙箱进行自动化动态分析,尝试提取网络通信特征

实战经验拓展:

  1. 沙箱逃逸应对:在实战场景中,有时将样本上传至沙箱后,由于沙箱环境的局限性(如缺少特定的运行库、模拟环境被识别)可能会出现不触发外联的情况。

  2. 手工动态调试:此时,建议将样本置于隔离的虚拟机中,开启火绒剑、Sysmon 等系统行为监控工具,手动触发木马以观察其真实行为轨迹。

  3. 逆向分析:若样本具备复杂的反沙箱或反虚拟机机制,则需要安全人员使用 IDA Pro、x64dbg 等工具进行脱壳、逆向工程和底层调试,强行提取出硬编码或动态解密的 C2 地址。

在虚拟机中使用火绒剑捕获木马执行后的真实 C2 外联地址

通过监控分析,清晰地捕获到 运维助手 (1).exe 进程建立了一条 TCP 连接,其远程外联的目的 IP 和端口为 143.55.28.36:1234。这就是攻击者用于最终深度控制该主机的 C2 基础设施。

预期答案: flag{143.55.28.36:1234}

5. 题目投票

请选择您心中最具实战价值且最具创新突破的题目(可多选)

6. 抽奖公示