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

作者:solar应急响应团队 发布:2026-11-04 18:14 收录:2026-11-04 21:32 2 次阅读 约 4429 字
摘要:点击蓝字 关注我们 前言 随着 Solar 月赛临近尾声,第三届 Solar 年赛也将在年底如期而至。本届年赛赛制全面升级,分为线上初赛与线下决赛两个阶段;月赛总榜前三名将直通线下决赛,差旅全包。比赛详情将在后续文章中陆续发布,敬请期待。…
推荐理由:本文涵盖「应急响应」、「月赛排名」、「网络安全大赛」等多个主题,重点关注 应急响应。




点击蓝字 关注我们




前言

随着 Solar 月赛临近尾声,第三届 Solar 年赛也将在年底如期而至。本届年赛赛制全面升级,分为线上初赛与线下决赛两个阶段;月赛总榜前三名将直通线下决赛,差旅全包。比赛详情将在后续文章中陆续发布,敬请期待。

也请各位选手珍惜最后一场月赛——当前分差极小,往往 1、2 分之差,便足以决定最终名次。

1.10月月赛排名

2026年10月Solar应急响应公益月赛已圆满结束。以下为最终WP提交情况

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

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

2.平台介绍

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

3.赛事回顾

本次应急响应挑战赛围绕威胁情报分析、网络流量取证与隐蔽通信分析、恶意程序逆向与密码学漏洞利用、Linux 主机入侵取证与挖矿木马溯源四类场景,构建了四条完整分析链:邮件头溯源与 AiLock 勒索家族识别、自定义 MTXP 协议解析与多重隐蔽信道解密、Windows PE 加密程序逆向与 AES 密钥派生缺陷利用,以及 Linux Webshell 入侵、计划任务持久化与挖矿木马溯源。

场景覆盖勒索软件暗网基础设施关联、交易网络中的隐蔽数据外传、加密程序中的伪造 Flag 干扰与可预测密钥漏洞、Web 文件上传漏洞利用、Linux 主机持久化及恶意挖矿活动,形成从威胁识别、攻击行为分析、隐蔽信息提取到攻击链还原的综合应急响应实战体系。

核心考察点分四个层次:

一是网络流量取证。 通过 PCAP 流量分析与自定义 MTXP 协议解析,还原异常管理认证、维护模式切换及数据外传攻击链;结合订单号隐写、Base64/Gzip 解码、心跳时间隐蔽信道和 DNS 查询编码提取分段密钥,利用 MD5 挑战应答恢复认证口令,最终通过 AES-CBC 解密外传数据获取 Flag。

二是威胁情报检索与勒索家族溯源。 通过 Google 搜索可疑 .onion 暗网域名,结合 ThreatFox、Ransomlook、GitHub 等公开威胁情报平台进行交叉验证,关联勒索组织的暗网泄露站点及勒索信特征,最终识别出 AiLock 勒索软件家族。

三是逆向分析与密码学漏洞利用。 通过 IDA 逆向分析 PE 加密程序,识别并排除 RC4、SM4、ChaCha20 等伪造 Flag 和算法干扰项,还原 AES-256-CBC 加密流程与文件结构,发现加密密钥由原始文件名经 SHA-256 哈希生成的设计缺陷,利用可预测文件名派生密钥、提取文件尾部 IV,最终通过 AES-CBC 解密及 PKCS#7 去填充恢复 Flag。

四是 Linux 主机入侵取证与挖矿木马溯源。 通过 Nginx 访问日志与文件系统时间线分析,定位文件上传漏洞及 Webshell 落地路径;结合 Cron 计划任务排查发现恶意持久化脚本,通过脚本内容分析与 Netstat 网络连接快照交叉验证,锁定挖矿程序及其矿池 IP 和端口,最终提取恶意样本并计算 MD5 哈希,完成从入侵入口、权限维持到挖矿行为的完整攻击链还原。

这组题目的区分度不在于找到单一漏洞或 Flag,而在于选手能否将日志溯源、流量解析、逆向工程和威胁情报串联起来,完成从异常发现、攻击链还原到证据提取与检测规则落地的完整闭环。

趋势榜单

解题榜单

4.10月月赛WP

Black swan event

解题步骤

TCP 应用层消息的前 20 字节:

偏移
长度
字段
0x00
4
MTXP magic
0x04
1
version,值 0x21
0x05
1
消息类型 type
0x06
2
flags,小端
0x08
4
sequence,小端
0x0c
4
body 长度,小端
0x10
4
CRC32(body),小端
0x14
变长
body

实际解析的 51,071 条 MTXP 消息 CRC32 均匹配。关键类型包括:

  • 0x01 / 0x02 / 0x03 / 0x04:登录 / 挑战 / 应答 / 成功;
  • 0x10:下单请求,0x11:下单响应;
  • 0x30:心跳;
  • 0x40 / 0x41:管理命令及响应;
  • 0x60:可疑的分块外传数据。

Wireshark 可先使用 tcp.port == 9001、tcp.port == 8899、tcp.port == 40001 进行筛选,随后使用 Follow TCP Stream;DNS 线索则使用 dns.qry.name contains "``kp.trader-hub.cn``"。

确认管理端的异常入侵

抓包中 2025-10-24 18:32:00 UTC,203.0.113.66 连接 10.20.31.10:8899:

  1. 0x01 登录,用户名 svc_bak2019,认证方式为 challenge-response;
  2. 0x02 服务端下发 8 字节随机挑战 9f37a1c2d4e5b607;
  3. 0x03 客户端发回 16 字节 MD5 ac9e4e6e7c090b38c5b8074fc0743d4f;
  4. 0x04 服务端返回 OK svc_bak2019,登录成功。

18:32:30,该地址通过管理命令查询了 session;18:33:00,下发 0x40 的 07 01 01,服务端回复:

maint=1 ack (sessions preserved)

下一步的隐藏加载器明确提示:管理端认证采用 md5(nonce + pwd),其中 pwd 为 6 位数字。使用上述数据枚举 000000 至 999999:

import hashlib
nonce = bytes.fromhex("9f37a1c2d4e5b607")
target = bytes.fromhex("ac9e4e6e7c090b38c5b8074fc0743d4f")
for i in range(1_000_000):
    pwd = f"{i:06d}".encode()
    if hashlib.md5(nonce + pwd).digest() == target:
        print(pwd.decode())
        break
# 958213

核心隐写:MTXP 订单号尾部藏着第二阶段脚本

异常交易流量来自 10.20.31.204 → 10.20.31.10:9001,0x10 订单请求。常规订单 ID 长 21 字节、形如:

ORD20251024[A-Z0-9]{10}

但在 18:45:00 到约 19:12:53 UTC,出现了一系列包含小写字母、+、/、= 的异常 ID,例如:

ORD20251024U217O5sH4s
ORD20251024D5Q0DXwIAA
ORD20251024D00AOBaAAA
ORD202510240OP91InAAC
ORD20251024GE5LTQs/5V

尾部 4 字符分别是 sH4s、wIAA、aAAA、nAAC、s/5V……首字符按 s → w → a → n 循环,是 swan 标记;剩下的 3 个字符才是真正的 Base64 数据。

注意:流量存在 两次重复订单,必须按完整订单 ID 去重并按时间排序。原本匹配 250 条,去重后获得 248 个有效片段,swan 正好重复 62 次。

# suffixes 为按时间排序、去重后的订单 ID 最后 4 字符
assert ''.join(x[0] for x in suffixes) == 'swan' * 62
encoded = ''.join(x[1:] for x in suffixes)
stage2 = gzip.decompress(base64.b64decode(encoded))

得到 557 字节 gzip,解压后是 960 字节 Python 源码,核心内容:

#!/usr/bin/env py3
# swan_drop v0.3 :: stage2 loader (self-wipe enabled)
# target : meridian gw 10.20.31.10:8899
# acct   : svc_bak2019 / pwd = 6 digits, md5(nonce+pwd) on 8899

K1 = b"c0v3rt!!"      # chunk A: order-tag trail
K2 = None             # chunk B: heartbeat cadence on the trader line
                      # 48 gaps -> 0.5s=0 1.0s=1 -> pack MSB-first -> 6 ascii
K3 = None             # chunk C: kp zone lookups
                      # label = <idx nibble><hexbyte>, 8 queries -> 8 ascii

def forge_key(pwd, k1, k2, k3):
    import hashlib
    return hashlib.md5(pwd + k1 + k2 + k3).digest()[:16]

# exfil blob: first 16 bytes = IV, rest = AES-128-CBC payload
# port 40001 mirror, chunks are b64 lines

正常 0x30 心跳大约每 30 秒一次。但 19:14:00–19:14:36 UTC,来自 10.20.31.204 的心跳突然高频出现。

先筛选该时间窗口的 0x30,然后排除时间点 19:14:00.000 和 19:14:30.000 的正常心跳(注意 19:14:00.000 还有一条同刻发出的异常心跳,需要保留第二条)。剩下 49 条特殊心跳,形成 48 个相邻时间间隔。

根据 stage2 提示:

  • 约 0.5s 表示比特 0;
  • 约 1.0s 表示比特 1;
  • 按时间顺序每 8 bit 一组,最高位在前(MSB-first)。

重建出的 48 bit 为:

01010100 00110001 01101101 00110001 01101110 01100111
   T        1        m        1        n        g

筛选从 10.20.31.204 发出的 DNS 查询。在 19:20–19:32 UTC 发现如下 8 个域名:

索引
QNAME
数据字节
0
044.kp.trader-hub.cn
44 → D
1
16e.kp.trader-hub.cn
6e → n
2
235.kp.trader-hub.cn
35 → 5
3
34b.kp.trader-hub.cn
4b → K
4
433.kp.trader-hub.cn
33 → 3
5
579.kp.trader-hub.cn
79 → y
6
621.kp.trader-hub.cn
21 → !
7
721.kp.trader-hub.cn
21 → !

外传数据拼接、AES-128-CBC 解密

2025-10-24 19:42:00–19:42:24 UTC,10.20.31.204:52114 → 203.0.113.66:40001 发送了 19 条类型为 0x60 的 MTXP 帧。

各帧的 body 格式:

offset 0: uint32_le  chunk_index
         uint32_le  chunk_length
         bytes      chunk_base64_text

chunk_index 从 0 到 18。按索引拼接其 Base64 文本,解码得到 880 字节。前 16 字节是 IV;后 864 字节为密文:

按照第二阶段脚本提供的公式

import hashlib
pwd = b"958213"
K1  = b"c0v3rt!!"
K2  = b"T1m1ng"
K3  = b"Dn5K3y!!"
key = hashlib.md5(pwd + K1 + K2 + K3).digest()[:16]
print(key.hex())
# 39bc58b76329534cd688b9580f7dc3a3

使用 AES-128-CBC 解密,并移除 PKCS#7 padding(最后 14 字节),恢复 850 字节明文。明文开头包含:

MERIDIAN-CUST-MASTER-EXPORT v1  pulled by svc_bak2019 ...
account,name,risk_group,balance_cny,phone
...
>>> FLAG: flag{3f6a1c9e-7b42-4d85-9c0e-2a8f5d1b6e73} <<<
copy ok. wipe scheduled t+30m. - swan
def decode_heartbeat(messages):
    start = datetime(2025, 10, 24, 19, 14, 0, tzinfo=timezone.utc).timestamp()
    burst = sorted(
        (ts, body) for ts, src, sport, dst, dport, typ, body in messages
        if typ == 0x30 and src == '10.20.31.204' and start <= ts < start + 36.5
    )
    # Normal heartbeat at :00 and :30 is mixed into the covert pulse train.
    # At :00 there are two messages; remove the earlier normal one.
    burst.pop(0)
    # At :30 there is one normal heartbeat; remove it as well.
    thirty = next(i for i, (ts, body) in enumerate(burst) if abs(ts - (start + 30)) < 0.00001)
    burst.pop(thirty)
    assert len(burst) == 49, len(burst)
    gaps = [burst[i + 1][0] - burst[i][0] for i in range(len(burst) - 1)]
    assert all(0.4 < gap < 1.1 for gap in gaps)
    bits = ''.join('1' if gap > 0.75 else '0' for gap in gaps)
    k2 = bytes(int(bits[i:i + 8], 2) for i in range(0, len(bits), 8))
    print('[2] Heartbeat gaps:', len(gaps), 'bits:', bits)
    print('    K2:', k2.decode())
    return k2


def decode_dns_key(dns_queries):
    key_parts = {}
    for ts, src, dst, name in dns_queries:
        if src != '10.20.31.204':
            continue
        match = re.fullmatch(r'([0-7])([0-9a-f]{2})\.kp\.trader-hub\.cn', name)
        if match:
            idx, val = match.groups()
            key_parts[int(idx)] = int(val, 16)
    k3 = bytes(key_parts[i] for i in range(8))
    print('[3] DNS key labels:', [f'{i}{key_parts[i]:02x}' for i in range(8)])
    print('    K3:', k3.decode())
    return k3


def recover_admin_password(messages):
    nonce = next(body for ts, src, sport, dst, dport, typ, body in messages
                 if typ == 0x02 and src == '10.20.31.10'
                 and dst == '203.0.113.66' and sport == 8899)
    digest = next(body for ts, src, sport, dst, dport, typ, body in messages
                  if typ == 0x03 and src == '203.0.113.66'
                  and dst == '10.20.31.10' and dport == 8899)
    for num in range(1_000_000):
        pwd = f'{num:06d}'.encode()
        if hashlib.md5(nonce + pwd).digest() == digest:
            print('[4] Admin password:', pwd.decode(), 'nonce:', nonce.hex(), 'digest:', digest.hex())
            return pwd
    raise RuntimeError('No 6-digit password matched the challenge/response')


def recover_exfil(messages, password, k1, k2, k3):
    chunks = {}
    for ts, src, sport, dst, dport, typ, body in messages:
        if typ == 0x60 and src == '10.20.31.204' and dst == '203.0.113.66' and dport == 40001:
            index, size = struct.unpack_from('<II', body)
            segment = body[8:8 + size]
            assert len(segment) == size
            chunks[index] = segment
    encoded = b''.join(chunks[i] for i in range(len(chunks)))
    encrypted = base64.b64decode(encoded, validate=True)
    iv, ciphertext = encrypted[:16], encrypted[16:]
    key = hashlib.md5(password + k1 + k2 + k3).digest()[:16]
    dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
    padded = dec.update(ciphertext) + dec.finalize()
    n = padded[-1]
    if not (1 <= n <= 16 and padded.endswith(bytes([n]) * n)):
        raise ValueError('Invalid PKCS#7 padding; check recovered key pieces')
    plaintext = padded[:-n]
    match = re.search(rb'flag\{[^}\n]+\}', plaintext)
    if not match:
        raise RuntimeError('No flag found after decryption')
    print('[5] Exfil chunks:', len(chunks), 'encrypted bytes:', len(encrypted))
    print('    AES-128 key:', key.hex(), 'IV:', iv.hex())
    print('    Decrypted:', len(plaintext), 'bytes')
    return match.group().decode(), plaintext


def main(path: str):
    messages = []
    dns_queries = []
    for ts, src, sport, dst, dport, proto, payload in parse_packets(read_attachment(path)):
        if proto == 'UDP' and dport == 53:
            dns_queries.append((ts, src, dst, parse_dns_name(payload)))
        elif proto == 'TCP':
            record = parse_mtxp(payload)
            if record:
                typ, body = record
                messages.append((ts, src, sport, dst, dport, typ, body))
    print('Parsed MTXP records:', len(messages), 'DNS requests:', len(dns_queries))
    k1, stage2 = decode_stage2(messages)
    k2 = decode_heartbeat(messages)
    k3 = decode_dns_key(dns_queries)
    pwd = recover_admin_password(messages)
    flag, plaintext = recover_exfil(messages, pwd, k1, k2, k3)
    print('\nFLAG:', flag)


if __name__ == '__main__':
    if len(sys.argv) != 2:
        raise SystemExit('Usage: python blackswan_solve.py /path/to/blackswan_incident.zip')
    main(sys.argv[1])

flag{3f6a1c9e-7b42-4d85-9c0e-2a8f5d1b6e73}

勒索组织确认

解题步骤

谷歌搜索

flag{Ailock}

easyRansomware

解题步骤

Step 1: 初步分析

现场只有加密器和一个加密结果:

encryptor.exe      双击运行,无参数、无窗口
flag.txt.enc       64 字节

Step 2: 逆向加密器

用 IDA  encryptor.exe,字符串窗口会先看到大量干扰项:

ChaCha20-Poly1305
expand 32-byte k
RC4 / Salsa20 / Blowfish / SM4
flag{rc4_sbox_is_the_key_material}
flag{chacha20_expand_32_byte_k}
flag{sm4_fk_ck_sbox_round_key}
flag{pbkdf2_sha1_iteration_100000}
flag{blowfish_p_array_pi_digits}

这些字符串旁边还有 RC4 的 256 字节状态、SM4 的 FK、Blowfish 的 P 数组初始值,以及一个假的 AES-128 密钥。对应代码会用 memcmp 把输入和这些 flag{...} 比较,但比较位于 route != 0 的分支。route 正常运行时是 0,双击加密不会走到这里。这些都是诱饵,不能直接提交。

加密器不会删除原文件。它只处理同目录下的 flag.txt,输出路径是在原路径后面追加 .enc,因此加密文件名可以直接还原出原始文件名:

flag.txt.enc  ->  flag.txt

通过分析可以发现文件结构是:

[48 字节 AES 密文] [16 字节 IV]

64 字节刚好是 3 个 AES 分组加一个分组长度的 IV。明文 flag{...} 不足 48 字节,多出来的部分是 PKCS#7 填充。

程序自带 AES S-box、密钥扩展和 CBC:
原始文件名做 UTF-8 编码,得到 "flag.txt"。
key = SHA-256("flag.txt"),32 字节,作为 AES-256 密钥。
随机生成 16 字节 IV,AES-256-CBC 加密,PKCS#7 填充。
密文在前,IV 追加在文件最后 16 字节。
同一套 AES 解密函数也在程序里,逻辑就是标准 CBC 解密:取出末尾 IV,逐块解密后再去掉填充。

Step 3: 识别漏洞

AES-256 本身没有被削弱,问题在密钥来源。密钥不是随机生成的,而是:

SHA-256(UTF-8(原始文件名))

文件名能从 flag.txt.enc 去掉后缀得到,IV 又明文写在文件尾部,所以不需要暴力破解。

Step 4: 切出 IV 并派生密钥

iv  = flag.txt.enc[-16:]
ct  = flag.txt.enc[:-16]
key = SHA-256(b"flag.txt")

Step 5: 解密

对 ct 做 AES-256-CBC 解密,IV 使用上一步取出的 16 字节,最后检查并去掉 PKCS#7 填充。明文长度是 38 字节:

flag{60571932386275385969178511968721}

解题脚本

#!/usr/bin/env python3
from pathlib import Path
import hashlib
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

blob = Path("flag.txt.enc").read_bytes()
ct, iv = blob[:-16], blob[-16:]
key = hashlib.sha256(b"flag.txt").digest()

decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
padded = decryptor.update(ct) + decryptor.finalize()
unpadder = padding.PKCS7(128).unpadder()
flag = unpadder.update(padded) + unpadder.finalize()

print(flag.decode())
flag{60571932386275385969178511968721}

服务器异常进程排查

通过网盘分享的文件:solar202610-web01.zip

链接: https://pan.baidu.com/s/1z0oCWKsYEOBTknBo0EASFg 提取码: vten

--来自百度网盘超级会员v10的分享

压缩包密码:908229181c27a38701e6f9cf00fb7fdd

解题步骤

步骤一:定位入侵入口(Webshell 路径)

题目描述:通过 Web 访问日志与文件系统痕迹,定位攻击者利用文件上传漏洞写入的 Webshell 文件的绝对路径。

提交格式:flag{/绝对路径}

解题思路:解压证据包后,先查看 Nginx 访问日志 /var/log/nginx/access.log,按请求方式、状态码、响应大小筛选可疑请求,再从时间线回溯上传点。

发现一条对 /upload.php 的 POST 请求返回 200,紧接着出现对 /upload/images/shell.php 的请求,而该路径在正常业务中并不存在。

进入 /var/www/html/upload/images/ 确认 shell.php 真实落地,文件创建时间与日志时间吻合,内容为 PHP 一句话木马。

所以第一题答案为 flag{/var/www/html/upload/images/shell.php}

步骤二:定位持久化(计划任务脚本路径)

题目描述:找出攻击者为实现持久化而写入的计划任务中被调用的恶意脚本文件绝对路径。

提交格式:flag{/绝对路径}

解题思路:检查 /var/spool/cron/root 与 /etc/cron.d/,结合文件修改时间筛出异常任务,并与正常的运维计划任务做区分。在 /var/spool/cron/root 中发现一条每 5 分钟执行一次的任务:*/5 * * * * /bin/bash /tmp/.cache/.update.sh >/dev/null 2>&1,脚本位于 /tmp/.cache/ 隐藏目录,明显为恶意持久化。

所以第二题答案为 flag{/tmp/.cache/.update.sh}

步骤三:定位恶意外联地址(矿池 IP:端口)

题目描述:分析恶意脚本内容与网络连接快照,找出挖矿程序回连的矿池地址及端口。

提交格式:flag{IP:端口}

解题思路:读取 /tmp/.cache/.update.sh,脚本中会下载挖矿程序并指定矿池地址;再结合 netstat 快照确认外联。

脚本中的下载地址与启动命令指向矿池 103.214.68.11:3333;netstat 快照中可看到该进程与该地址处于 ESTABLISHED 状态。所以第三题答案为 flag{103.214.68.11:3333}

步骤四:提取恶意样本并计算 MD5

题目描述:提取挖矿程序本体文件,计算其 MD5 值(32 位大写)作为提交答案。

提交格式:flag{32位大写MD5}

解题思路:由任务三脚本中的下载路径定位挖矿程序本体(如 /tmp/.cache/kworkerd),将其提取到本地。Windows 下使用 certutil -hashfile <文件> MD5,Linux 下使用 md5sum <文件>;注意结果需转为大写。所以第四题答案为 flag{1699473C2674ABCF84D07B7AFA32DFA8}

5.题目投票

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




转载声明:本文转载自原发布平台 (作者:solar应急响应团队), 原文标题《2026-10月Solar应急响应公益月赛排名及官方题解》, 查看原文。 版权归原作者及原发布平台所有,本站仅作收录与展示,未对正文内容作实质性修改; 若涉及侵权请联系本站处理。