2025年Solar应急响应月赛9月

生命在于学习 · 2025-09-29 · 4 人浏览
2025年Solar应急响应月赛9月

昨天和前天参加了2025年Solar应急响应9月月赛,整体来说也算是挺好玩的。

特洛伊挖矿木马事件排查

任务一

  • 任务名称: 提交挖矿文件的绝对路径
  • 任务分数: 100.00
  • 任务类型: 静态Flag
  • 要求: 提交挖矿文件的绝对路径,最终以flag{/xxx/xxx}格式提交
  • 排查过程:
    ps aux看到
    flag{/tmp/kworkerd}

任务二

  • 任务名称: 提交挖矿文件的外联IP与端口
  • 任务分数: 100.00
  • 任务类型: 静态Flag
  • 要求: 提交挖矿文件的外联的IP与端口,最终以flag{ip:port}格式提交
  • 排查过程:
    netstat -ano
    flag{104.21.6.99:10235}

任务三

  • 任务名称: 守护进程脚本的绝对路径
  • 任务分数: 100.00
  • 任务类型: 静态Flag
  • 要求: 停止挖矿进程并尝试删除挖矿程序,根据异常判断,提交守护进程脚本的绝对路径,最终以flag{/xxx/xxx/xxx/xxx}提交
  • 排查过程:
    尝试rm删除,但发现还在,也没有报错:
    Kill掉进程,进程会被重新拉起,排查计划任务:
    flag{/usr/bin/.0guardian}

任务四

  • 任务名称: 根据出现的异常及守护进程脚本,继续排查,以人为本,使用环境内浏览器访问:http://chat.internal-dev.net:8081 获取可疑网址
  • 要求: 最终以flag{http://www.example.com}格式提交
  • 结果:
    flag{http://www.superlog-pro.com}

任务五

  • 任务名称: 分析病毒文件,提交其感染的所有程序
  • 要求: 最终以flag{md5(/usr/bin/whoai,/usr/bin/ls,/usr/bin/top)}进行提交,顺序需以病毒文件中为准
  • 排查过程:
    在靶机访问http://www.superlog-pro.com,下载,用文本打开:
    /bin/ls,/bin/ps,/bin/cat,/bin/rm,/bin/ss,/usr/bin/stat,/usr/bin/top,/usr/bin/wget,/usr/bin/curl,/usr/bin/vi,/usr/bin/sudo
    flag{dac48e98a53b81b0218e2156e364f7ba}

任务六

  • 任务名称: 修复系统并恢复文件完整性
  • 要求: 已知所有程序被感染,当前系统属于断网状态,所以作者贴心的在/deb_final目录下存放了对应程序的deb包,请尝试恢复所有程序,恢复完毕后在/var/flag/1文件获取flag
  • 结果:
    flag{e510c5fca680b1b4bd5c9d8d6b3f4bdc}

任务七

  • 任务名称: 最终清理
  • 要求: 删除挖矿程序、删除计划任务及守护进程及清除相关进程,等待片刻在/var/flag/2获取flag
  • 排查过程:
    在任务六修复后,就可以正常删除了,如果不修复是无法删除的,kill掉进程,删除守护进程/usr/bin/.0guardian,删除计划任务,删除/tmp下的木马:
    flag{081ce3688c6cd6e2946125081381087c}

Misc

流量分析题

在流量里找到多次 POST /shell.phpPOST /shell1.php 的请求,参数形如 pass=eval(base64_decode(strrev(urldecode('...'))))。把 urldecode → 反转 strrev → base64 解出 WebShell 的 PHP 核心。

两个壳的配置分别是:

  • /shell.php$pass='password'$key='5f4dcc3b5aa765d6'
  • /shell1.php$pass='1qaz2wsx3edc'$key='0eff44c362b13fa2'

壳的返回体被 md5($pass.$key) 的前 16/后 16 个 hex 包裹,中间是 base64( XOR(gzip(明文), key) )

/shell1.php 为例,先算 md5('1qaz2wsx3edc0eff44c362b13fa2') = 2abd2b6d922769ccbb57d9b8561ce2a5,据此从响应里截出中间那段 base64。

解码:base64 → 与 ASCII key(0eff44c362b13fa2)按 ($i+1)&15 取位逐字节 XOR → gunzip,得到明文,其中一段就是
flag{ccebdb78-4b5c-4252-b20a-0039913c5c94}

解题脚本

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
一键从 pcapng 中还原 CTF flag(适用于含 HTTP WebShell 通信的场景)
依赖: pyshark (并且系统需安装 tshark)
pip install pyshark
Linux/Win 请确保 tshark 可用(可在 Wireshark 官网上获取)
用法:
    python extract_flag_from_pcap.py /path/to/flag.pcapng
"""

import sys
import re
import base64
import binascii
import gzip
from urllib.parse import unquote_plus
from hashlib import md5

try:
    import pyshark
except Exception as e:
    print("缺少 pyshark。请先安装: pip install pyshark,并保证系统安装了 tshark。")
    raise

def try_urldecode_rev_b64(s):
    """尝试还原:urldecode -> strrev -> base64decode"""
    try:
        u = unquote_plus(s)
        rev = u[::-1]
        # 有的壳把单引号/双引号包起来,去掉外层引号
        rev = rev.strip('\'"')
        # base64 decode
        b = base64.b64decode(rev)
        return b
    except Exception:
        return None

def extract_key_pass_from_php(php_bytes):
    """尝试从 php 代码中提取 $key 和 $pass 等常见写法,返回 (pass, key) 或 (None,None)"""
    try:
        s = php_bytes.decode('utf-8', errors='ignore')
    except:
        s = str(php_bytes)
    # 常见: $pass='password'; $key='0123456789abcdef';
    m_pass = re.search(r"\$pass\s*=\s*['\"]([^'\"]{4,64})['\"]", s)
    m_key  = re.search(r"\$key\s*=\s*['\"]([^'\"]{4,128})['\"]", s)
    passv = m_pass.group(1) if m_pass else None
    keyv  = m_key.group(1)  if m_key  else None
    return passv, keyv

def try_dewrap_and_decode(resp_body_bytes, passv=None, keyv=None):
    """
    根据我们分析的包装格式尝试解包:
    形态假设: <md5_left(32hex)><base64_middle><md5_right(32hex)>
    md5_left/md5_right 大概率是 md5(pass+key) 的前/后 16 hex 或相等的两段。
    解包策略:
      - 正则找出 32hex + base64 + 32hex
      - 若已知 pass/key:校验 md5(pass+key)
      - base64 解码 -> XOR with key bytes (多种尝试) -> gunzip -> search flag
    返回解出的明文(若成功)或 None
    """
    try:
        s = resp_body_bytes.decode('utf-8', errors='ignore').strip()
    except:
        s = ''
    # 找 32hex + base64 + 32hex (base64 中可能含 =)
    m = re.search(r"^([0-9a-fA-F]{32})([A-Za-z0-9+/=\n\r]+?)([0-9a-fA-F]{32})$", s, re.S)
    if not m:
        # 也尝试中间没有 32hex 的变体:有时是 md5左右各16hex(同样长度)
        m2 = re.search(r"([0-9a-fA-F]{16})([A-Za-z0-9+/=\n\r]+?)([0-9a-fA-F]{16})", s, re.S)
        if m2:
            left, mid, right = m2.group(1), m2.group(2), m2.group(3)
            # pad to 32 by duplicating? but we'll proceed
        else:
            return None
    else:
        left, mid, right = m.group(1), m.group(2), m.group(3)

    mid = mid.replace('\r', '').replace('\n', '')
    # 如果已知 pass/key,优先校验
    if passv and keyv:
        try:
            mm = md5((passv + keyv).encode()).hexdigest()
            # 检查是否匹配左/右其中一部分
            if not (left.lower().startswith(mm[:len(left)]) or right.lower().endswith(mm[-len(right):]) \
                    or left.lower()==mm or right.lower()==mm):
                # md5 不匹配也不一定失败,可能使用前/后16等。继续尝试,但打印提示
                pass
        except Exception:
            pass

    # base64 decode 中段
    try:
        mid_bytes = base64.b64decode(mid)
    except Exception:
        return None

    # key 可能是 hex string(长度偶数)也可能是 ascii
    candidate_key_bytes = []
    if keyv:
        # 尝试 hex -> bytes
        try:
            if re.fullmatch(r"[0-9a-fA-F]+", keyv) and len(keyv) % 2 == 0:
                candidate_key_bytes.append(bytes.fromhex(keyv))
        except Exception:
            pass
        # 尝试直接 ascii
        candidate_key_bytes.append(keyv.encode('utf-8', errors='ignore'))

    # 如果没有 key,从 mid_bytes 里推断一个可能的短 key (比如 16 字节)
    if not candidate_key_bytes:
        # 试几个常见长度 8,16
        for L in (16, 8, 12, 32):
            if len(mid_bytes) >= L:
                candidate_key_bytes.append(mid_bytes[:L])

    # 对每种 candidate key 做多种 XOR 尝试(包括描述里的 (i+1)&15 索引策略)
    for kb in candidate_key_bytes:
        for mode in ('simple_repeat', 'offset_and_mask'):
            try:
                if mode == 'simple_repeat':
                    # 直接循环 xor
                    plain = bytes([b ^ kb[i % len(kb)] for i, b in enumerate(mid_bytes)])
                else:
                    # 按描述尝试:索引为 (i+1) & (len(kb)-1) 或 &15
                    mask = len(kb)-1 if (len(kb) & (len(kb)-1)) == 0 else 15
                    plain = bytes([b ^ kb[(i+1) & mask] for i, b in enumerate(mid_bytes)])
            except Exception:
                continue
            # 尝试 gunzip 解压(很多壳会 gzip)
            try:
                dec = gzip.decompress(plain)
                try:
                    dec_text = dec.decode('utf-8', errors='ignore')
                except:
                    dec_text = str(dec)
                # 查找 flag 模式
                fm = re.search(r"flag\{[0-9a-fA-F\-]+\}", dec_text, re.I)
                if fm:
                    return dec_text
                # 如果没有 flag,但是可读文本,也返回(供人工检查)
                if len(dec_text) > 10 and any(c.isalpha() for c in dec_text):
                    # 仍然返回,调用者会打印出来
                    return dec_text
            except Exception:
                # 不是 gzip,尝试直接看 plain 是否有 flag
                try:
                    txt = plain.decode('utf-8', errors='ignore')
                except:
                    txt = None
                if txt:
                    fm = re.search(r"flag\{[0-9a-fA-F\-]+\}", txt, re.I)
                    if fm:
                        return txt
    return None

def main(pcap_path):
    print("[*] 使用 pyshark 解析 pcapng(需要 tshark),可能稍慢...")
    cap = pyshark.FileCapture(pcap_path, display_filter='http')
    # 按 tcp.stream 分组:收集 POST payloads 和 HTTP responses(按 stream id)
    streams = {}  # stream_id -> {'posts': [rawstr], 'resps': [rawbytes], 'phps': [bytes]}
    pkt_count = 0
    for pkt in cap:
        pkt_count += 1
        try:
            # stream index (tcp.stream) 用来绑定请求-响应
            tcp_stream = None
            if 'TCP' in pkt:
                if hasattr(pkt.tcp, 'stream'):
                    tcp_stream = int(pkt.tcp.stream)
            # 仅处理 http 层
            if 'HTTP' not in pkt:
                continue

            # POST 请求体
            if hasattr(pkt.http, 'request_method') and pkt.http.request_method == 'POST':
                body = None
                # pyshark 常见字段: http.file_data 或 http.request_body
                if hasattr(pkt.http, 'file_data'):
                    body = bytes(pkt.http.file_data.binary_value)
                elif hasattr(pkt.http, 'request_body'):
                    # text 字段
                    try:
                        body = pkt.http.request_body.binary_value
                    except:
                        try:
                            body = pkt.http.request_body.get_default_value().encode()
                        except:
                            body = str(pkt.http.request_body).encode()
                else:
                    # 可能部分分片,忽略
                    pass
                if body:
                    rec = streams.setdefault(tcp_stream, {'posts': [], 'resps': [], 'phps': []})
                    rec['posts'].append(body)
                    # 试着在 body 中寻找被 urlencoded 后并反转 base64 的 php 段(根据你以前的描述)
                    try:
                        s = body.decode('utf-8', errors='ignore')
                        # 找形如 urldecode(...strrev(...base64...))
                        candidate = None
                        # 直接取 body 全部尝试 urldecode->rev->b64
                        cand = try_urldecode_rev_b64(s)
                        if cand:
                            rec['phps'].append(cand)
                    except Exception:
                        pass

            # RESPONSE body
            if hasattr(pkt.http, 'response_code'):
                body = None
                if hasattr(pkt.http, 'file_data'):
                    body = bytes(pkt.http.file_data.binary_value)
                elif hasattr(pkt.http, 'response_for_uri'):
                    # 备用
                    pass
                if body:
                    rec = streams.setdefault(tcp_stream, {'posts': [], 'resps': [], 'phps': []})
                    rec['resps'].append(body)
        except Exception as e:
            # 忽略单包解析异常,但记录
            # print("包解析错误:", e)
            pass

    cap.close()
    print(f"[*] 解析完成,共读取包约: {pkt_count}。发现 TCP streams: {len(streams)}")

    # 尝试对每个 stream 做还原
    found_flags = []
    for sid, rec in streams.items():
        print(f"--- stream {sid} posts:{len(rec['posts'])} resps:{len(rec['resps'])} phps_candidates:{len(rec['phps'])}")
        # 从 php candidates 中尝试提取 pass/key
        candidates = []
        for php in rec['phps']:
            passv, keyv = extract_key_pass_from_php(php)
            if passv or keyv:
                candidates.append((passv, keyv, php))
        # 若没直接解到 php,可尝试解析每个 post body as urlencoded key=value 扫描其中的值再试解
        if not candidates and rec['posts']:
            for body in rec['posts']:
                try:
                    s = body.decode('utf-8', errors='ignore')
                except:
                    s = ''
                # 尝试取第一个 urlencoded 字段的值并尝试还原
                for kv in re.split(r'&', s):
                    if '=' in kv:
                        k, v = kv.split('=', 1)
                        b = try_urldecode_rev_b64(v)
                        if b:
                            passv, keyv = extract_key_pass_from_php(b)
                            if passv or keyv:
                                candidates.append((passv, keyv, b))
        # 最后,如果没有候选但有 posts/resps,也尝试通用 key 尝试(不会从 php 获取 key)
        if not candidates:
            candidates.append((None, None, None))

        # 对每个 response 以及每组 candidate 尝试还原
        for passv, keyv, phpbytes in candidates:
            print(f"  尝试候选 pass={passv} key={keyv} (php len {len(phpbytes) if phpbytes else 0})")
            for resp in rec['resps']:
                got = try_dewrap_and_decode(resp, passv, keyv)
                if got:
                    print(f"[+] 在 stream {sid} 成功解出明文 / flag 片段:\n{got}\n")
                    fm = re.search(r"flag\{[0-9a-fA-F\-]+\}", got, re.I)
                    if fm:
                        found_flags.append(fm.group(0))
                    else:
                        # 若明文中没有 flag,但内容有价值,打印并继续
                        pass

    if found_flags:
        print("[*] 找到以下 flag:")
        for f in set(found_flags):
            print(f"    {f}")
    else:
        print("[!] 没有自动定位到 flag。")

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("用法: python extract_flag_from_pcap.py /path/to/flag.pcapng")
        sys.exit(1)
    pcap = sys.argv[1]
    main(pcap)

Reverse

解题过程

直接IDA分析:

结构:

  1. 先写入 "ZX_LOCK"(7 字节)。
  2. 写入 1 字节:原始长度是否为奇数((len & 1))。若为 1,则在内存里补 1 个 0x00 使长度变偶数。
  3. int 1Ah 取时钟滴答(CX:DX),把 DX 存到 word_1022A,随后也写入到文件(2 字节 little-endian)。
  4. 然后把缓冲区按 16 位小端逐字处理,并写入。

核心加密循环(loc_100A3):

mov ax, [bx+si]      ; 取 16 位字
add di, 0FADEh       ; di = di + 0xFADE(每个字都会累加)
...
; 一堆与/非 运算,化简就是: ax = ax XOR di
mov [bx+si], ax

这段看似复杂,其实等价于 "ax ^= di"。验证该布尔表达式恒等于 XOR。因此解密流程就是反向过程(仍然是 XOR):

  1. 从文件取 seed = *(uint16_le)(紧跟在那个 1 字节奇偶标记之后)。
  2. 依次遍历密文每个 16 位字:seed = (seed + 0xFADE) & 0xFFFF; word ^= seed
  3. 如果奇偶标记是 1,最后丢弃 1 个字节(因为原文被补了一个 0 使之变偶)。

得到 flag:
flag{D0s_L0ck3r_WitH_n4Nd_ExpRs!|solarsec_202509}

解题脚本

from pathlib import Path

data = Path("FLAG.ENC").read_bytes()
assert data.startswith(b"ZX_LOCK")
odd = data[7]                 # 0/1:原文是否奇数长度
seed = int.from_bytes(data[8:10], "little")
enc  = data[10:]
assert len(enc) % 2 == 0

out = bytearray()
di = seed
for i in range(0, len(enc), 2):
    di = (di + 0xFADE) & 0xFFFF
    w  = int.from_bytes(enc[i:i+2], "little")
    w ^= di
    out += w.to_bytes(2, "little")

if odd & 1:
    out = out[:-1]

print(out.decode("utf-8", errors="ignore"))
Theme Jasmine by Kent Liao 京ICP备2023023335号-2京公网安备11010802044340号