400-6855-828

厦门 Makop devos 勒索病毒 MySQL 数据库恢复实操

时间:2026-07-09

厦门 Makop devos 勒索病毒 MySQL 数据库恢复实操

一、事件背景

1.1 攻击概述

2025 年 7 月,厦门某贸易企业的一台 Windows Server 服务器遭遇勒索病毒攻击。攻击者通过暴露在公网的 RDP(远程桌面协议)端口暴力破解入侵,随后投放 Makop/devos 变种勒索病毒,将服务器上几乎所有文件加密,后缀被修改为 .[victim_id].devos

该企业日常业务严重依赖一套基于 MySQL 的 ERP 系统,数据库中存有数年的订单、客户、财务等核心数据。由于备份策略存在疏漏——最后一次完整备份在三个月前,且备份文件同机存放同样被加密——企业面临严重的数据损失风险。

1.2 Makop/devos 家族特征

Makop 勒索病毒家族首次出现于 2019 年前后,devos 为其较新的变种分支。该家族具有以下技术特征:

特征描述
加密算法AES-256 + RSA-2048 混合加密
加密模式流式分块加密,通常仅加密文件头部和尾部的固定大小区块
文件后缀.[随机ID].devos 或其他变种后缀
勒索信在每个目录下释放 readme-warning.txt
传播方式RDP 暴力破解、弱口令、漏洞利用
持久化注册表 Run 项、计划任务
" 核心恢复原理:Makop/devos 对大型数据库文件(如 .ibd、.MYD)采用的是部分加密策略,即只加密文件头部和尾部的若干数据块(通常每块 0x100000 字节即 1MB),文件中间绝大部分数据保持原样。这为数据恢复留下了关键窗口。

二、初步排查与评估

2.1 现场勘查

到达现场后,首先断开服务器网络连接,防止病毒进一步扩散。然后进行全面的信息收集:

服务器环境:

  • • 操作系统:Windows Server 2012 R2
  • • 数据库:MySQL 5.7.36
  • • 存储引擎:InnoDB(主) + MyISAM(部分辅助表)
  • • 数据库总大小:约 86 GB
  • • 数据库文件路径:D:MySQLdata

加密情况:

  • • 数据库文件夹下所有 .ibd.MYD.MYI.frm 文件均被加密
  • ibdata1 系统表空间文件被加密
  • ib_logfile0ib_logfile1 重做日志被加密
  • • 备份目录 D:backup 下的 .sql 和压缩包同样被加密

2.2 勒索信内容

!!!ALL YOUR FILES ARE ENCRYPTED!!!

Don't worry, you can return all your files!
If you want to restore them, write to the mail: makop@cock.li
Your personal ID: [redacted]

Attention!
* Do not rename encrypted files.
* Do not try to decrypt your data using third party software,
  it may cause permanent data loss.
* Decryption of your files with the help of third parties may
  cause increased price (they add their fee to our).

2.3 恢复可行性判断

使用十六进制编辑器(WinHex)打开一个被加密的 .ibd 文件进行分析:

  1. 1. 文件头部(偏移 0x00000000 ~ 0x00100000):已被加密,原本的 InnoDB 页头结构(FIL_PAGE_SPACE_OR_CHECKSUMFIL_PAGE_OFFSET 等)全部变为乱码。
  2. 2. 文件中部(偏移 0x00100000 往后):数据保持原始状态!可以识别出完整的 InnoDB 数据页结构,页校验和、LSN 等信息完整可读。
  3. 3. 文件尾部(最后约 0x00100000 字节):同样被加密,这部分通常包含最近写入的数据页。

结论:约 97% 以上的数据页未被加密,具备极高的恢复价值。核心策略是绕过被加密的文件头部,从偏移 0x100000 处开始扫描并提取每一条可读的数据行。

三、数据恢复实操过程

3.1 第一步:完整镜像与安全备份

在进行任何恢复操作之前,首先对被加密的磁盘进行完整镜像:

# 使用 dd 或 forensic 工具制作磁盘镜像(在 PE 环境下操作)
# 以下为示意,实际操作在 WinPE 下使用 WinHex 或 FTK Imager 完成

# 将源磁盘(PhysicalDrive0)镜像到外部 2TB 硬盘
dd if=\.PhysicalDrive0 of=E:forensicserver_image.dd bs=64K conv=noerror,sync

# 单独备份数据库文件夹
robocopy D:MySQLdata E:forensicmysql_data /E /COPYALL /R:3 /W:5
" ⚠️ 关键原则:永远不在原始数据上直接操作。所有恢复工作均在镜像副本或备份副本上进行。

3.2 第二步:提取被加密的数据库文件

从备份副本中,将 MySQL 数据目录按数据库分类整理:

# 数据库目录结构(以 ERP 数据库为例)
E:recovery
├── original
│   └── erp_db
│       ├── db.opt
│       ├── orders.frm          # 表结构定义文件
│       ├── orders.ibd          # InnoDB 表空间(已加密头部)
│       ├── products.frm
│       ├── products.ibd
│       ├── customers.frm
│       ├── customers.ibd
│       └── ... (共 47 张表)
└── working
    └── erp_db                  # 工作副本

首先,尝试从 .frm 文件中恢复表结构定义。如果 .frm 文件同样被加密无法直接读取,可以通过以下替代方案获取表结构:

  • • 检查是否有 .sql 备份文件(即使被加密,尝试部分读取未加密区域)
  • • 从应用层代码中提取 CREATE TABLE 语句
  • • 从开发团队或版本控制系统中找回历史 DDL 脚本
  • • 从 InnoDB 数据字典页中手工解析(最复杂但可行)

本案例中,.frm 文件虽然也被加密,但由于文件较小(每个仅约 8KB),头部加密后无法直接解析。幸运的是,开发团队在 Git 仓库中保留了最近一次 DDL 导出脚本。

3.3 第三步:解析 InnoDB 数据页

InnoDB 每个数据页固定 16KB(0x4000 字节),页结构如下:

偏移大小字段说明
0x00004 字节FIL_PAGE_SPACE_OR_CHECKSUM页校验和
0x00044 字节FIL_PAGE_OFFSET页号
0x00084 字节FIL_PAGE_PREV上一页指针
0x000C4 字节FIL_PAGE_NEXT下一页指针
0x00108 字节FIL_PAGE_LSN最后修改的日志序列号
0x00182 字节FIL_PAGE_TYPE页类型

关键页类型标识:

  • 0x45BF:数据页(FIL_PAGE_INDEX)——这是我们恢复的目标

编写 Python 脚本从加密文件中提取所有可读的数据页:

#!/usr/bin/env python3
"""
InnoDB 加密文件数据页提取工具
针对 Makop/devos 部分加密(首尾各 1MB)的场景
"""
import os
import struct
import sys

PAGE_SIZE = 0x4000       # 16KB
HEADER_ENCRYPTED = 0x100000  # 头部加密区域 1MB
TAIL_ENCRYPTED = 0x100000   # 尾部加密区域 1MB

def is_valid_page(page_data: bytes) -> bool:
    """快速校验一个 16KB 块是否可能是有效的 InnoDB 数据页"""
    if len(page_data) < PAGE_SIZE:
        return False
    
    # 读取页类型
    page_type = struct.unpack_from('>H', page_data, 0x18)[0]
    
    # 数据页类型 0x45BF(新建格式)或 0x0005(旧格式)
    if page_type not in (0x45BF, 0x0005):
        return False
    
    # 校验 FIL_PAGE_OFFSET 是否在合理范围
    page_offset = struct.unpack_from('>I', page_data, 0x04)[0]
    if page_offset > 0xFFFFFFFF:
        return False
    
    # 基本通过
    return True

def extract_pages(filepath: str, output_dir: str):
    """从加密 .ibd 文件中提取所有有效数据页"""
    filename = os.path.basename(filepath)
    file_size = os.path.getsize(filepath)
    
    safe_start = HEADER_ENCRYPTED
    safe_end = file_size - TAIL_ENCRYPTED
    
    # 对齐到页边界
    safe_start = (safe_start // PAGE_SIZE) * PAGE_SIZE
    
    print(f"[*] 处理文件: {filename}")
    print(f"    文件大小: {file_size:,} 字节")
    print(f"    安全区域: 0x{safe_start:X} ~ 0x{safe_end:X}")
    
    page_count = 0
    data_pages = 0
    
    with open(filepath, 'rb') as f:
        offset = safe_start
        while offset < safe_end:
            f.seek(offset)
            page_data = f.read(PAGE_SIZE)
            
            if len(page_data) < PAGE_SIZE:
                break
            
            if is_valid_page(page_data):
                # 保存有效数据页
                out_name = f"{filename}_page_{page_count:06d}_offset_{offset:010X}.bin"
                out_path = os.path.join(output_dir, out_name)
                with open(out_path, 'wb') as out:
                    out.write(page_data)
                data_pages += 1
            
            page_count += 1
            offset += PAGE_SIZE
    
    print(f"    扫描页数: {page_count:,}, 有效数据页: {data_pages:,}")
    return data_pages

if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(description='InnoDB 加密页提取')
    parser.add_argument('input', help='加密的 .ibd 文件路径')
    parser.add_argument('-o', '--output', default='./extracted_pages',
                        help='输出目录')
    args = parser.parse_args()
    
    os.makedirs(args.output, exist_ok=True)
    extract_pages(args.input, args.output)

3.4 第四步:从数据页中提取行记录

提取到有效数据页后,需要进一步解析每个页中的行记录。编写第二段 Python 脚本完成行级数据提取:

#!/usr/bin/env python3
"""
从 InnoDB 数据页中提取用户行记录
"""
import struct
import os
import json

PAGE_SIZE = 0x4000

# InnoDB 页内偏移常量
PAGE_HEADER_SIZE = 0x38       # 38 字节页头
PAGE_DATA_OFFSET = 0x38       # 数据区起始
INFIMUM_OFFSET = 0x0D         # infimum 记录偏移
SUPREMUM_OFFSET = 0x12        # supremum 记录偏移
PAGE_N_HEAP = 0x14            # 堆中记录数(2字节)
PAGE_HEAP_TOP = 0x16          # 堆顶偏移(2字节)
PAGE_N_RECS = 0x18            # 用户记录数(2字节)

def extract_records_from_page(page_data: bytes, table_columns: list) -> list:
    """
    从单个 InnoDB 数据页中提取用户行记录
    
    Args:
        page_data: 16KB 页数据
        table_columns: 列定义列表 [{'name': 'id', 'type': 'INT'}, ...]
    Returns:
        行数据列表
    """
    records = []
    
    # 读取页目录槽
    page_dir_slot_count = struct.unpack_from('>H', page_data, 0x06)[0]
    
    # 从 infimum 开始遍历记录链表
    # 简化实现:解析紧凑行格式(COMPACT row format)
    # 每行记录格式:[变长字段长度列表] [NULL位图] [记录头5字节] [列数据...]
    
    n_recs = struct.unpack_from('>H', page_data, PAGE_N_RECS)[0]
    
    # 获取 infimum 记录指针
    # infimum 伪记录位于页体偏移 0x0D 处(PAGE_NEW_INFIMUM)
    cursor = INFIMUM_OFFSET + 13  # 跳过 infimum 伪记录的固定部分
    
    for _ in range(n_recs):
        # 获取下一条记录偏移
        next_offset = struct.unpack_from('>h', page_data, cursor - 2)[0]
        if next_offset == 0 or next_offset >= PAGE_SIZE:
            break
        
        cursor = next_offset
        
        # 解析记录头(5字节)
        if cursor + 5 > PAGE_SIZE:
            break
        
        # 该记录是否为删除标记
        info_flags = page_data[cursor] & 0x30
        if info_flags == 0x20:  # 删除标记
            continue
        
        # 尝试按紧凑格式提取数据
        row = parse_compact_row(page_data, cursor, table_columns)
        if row:
            records.append(row)
    
    return records

def parse_compact_row(page_data: bytes, origin: int, columns: list) -> dict:
    """解析 COMPACT 行格式(简化实现)"""
    row = {}
    
    # 记录头大小(5字节)
    rec_header_end = origin + 5
    
    # 跳过变长字段长度列表和 NULL 位图
    # 这是简化版本,实际解析需根据列定义精确计算
    
    # 提取各列数据(简化:按常见 INT/VARCHAR 类型处理)
    offset = rec_header_end
    for col in columns:
        col_type = col.get('type', 'VARCHAR').upper()
        col_name = col.get('name', f'col_{offset}')
        
        try:
            if 'INT' in col_type:
                if offset + 4 <= PAGE_SIZE:
                    val = struct.unpack_from('

3.5 第五步:数据导入与校验

将提取出的数据组装成 SQL INSERT 语句,导入到新搭建的 MySQL 环境中:

-- ============================================
-- 第一步:在干净环境中创建数据库和表结构
-- ============================================
CREATE DATABASE recover_erp
    DEFAULT CHARACTER SET utf8mb4
    DEFAULT COLLATE utf8mb4_general_ci;

USE recover_erp;

-- 示例:订单表(从 DDL 脚本重建)
CREATE TABLE orders (
    id INT UNSIGNED NOT NULL AUTO_INCREMENT,
    order_no VARCHAR(32) NOT NULL,
    customer_id INT UNSIGNED NOT NULL,
    order_date DATE NOT NULL,
    total_amount DECIMAL(12,2) DEFAULT 0.00,
    status TINYINT DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_customer (customer_id),
    INDEX idx_order_date (order_date),
    INDEX idx_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 产品表
CREATE TABLE products (
    id INT UNSIGNED NOT NULL AUTO_INCREMENT,
    product_code VARCHAR(32) NOT NULL,
    product_name VARCHAR(200) NOT NULL,
    category_id INT UNSIGNED DEFAULT 0,
    unit_price DECIMAL(10,2) DEFAULT 0.00,
    stock_qty INT DEFAULT 0,
    PRIMARY KEY (id),
    INDEX idx_product_code (product_code)
);

-- 客户表
CREATE TABLE customers (
    id INT UNSIGNED NOT NULL AUTO_INCREMENT,
    customer_name VARCHAR(100) NOT NULL,
    contact_person VARCHAR(50),
    phone VARCHAR(20),
    address VARCHAR(255),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
);

-- ... 其余 44 张表结构类似,此处省略
-- ============================================
-- 第二步:导入从数据页提取的 INSERT 语句
-- ============================================
SOURCE D:/recovery/extract/orders_extract.sql;
SOURCE D:/recovery/extract/products_extract.sql;
SOURCE D:/recovery/extract/customers_extract.sql;
-- ... 其余表依次导入
-- ============================================
-- 第三步:数据完整性校验
-- ============================================

-- 1. 各表行数统计
SELECT TABLE_NAME, TABLE_ROWS
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'recover_erp'
ORDER BY TABLE_NAME;

-- 2. 关键业务数据抽样校验
SELECT 
    COUNT(*) AS total_orders,
    MIN(order_date) AS earliest_order,
    MAX(order_date) AS latest_order,
    SUM(total_amount) AS total_revenue
FROM recover_erp.orders;

-- 3. 关联完整性检查(订单-订单明细)
SELECT COUNT(*) AS orphan_items
FROM recover_erp.order_items oi
LEFT JOIN recover_erp.orders o ON oi.order_id = o.id
WHERE o.id IS NULL;

-- 4. 与已知基准数据对比
-- 假设企业能提供最近季度的汇总数据
-- 如:Q2 总订单数应为 3,245 条,恢复结果 3,218 条,偏差 < 1%

-- 5. 与应用系统联合测试
-- 连接 ERP 应用后:
--   ✓ 用户登录正常
--   ✓ 订单列表查询正常、分页正常
--   ✓ 新增订单功能正常
--   ✓ 财务报表数据与历史纸质报表吻合
--   ✓ 客户信息完整可查

四、恢复过程中的技术难点与解决

4.1 表结构恢复

.frm 文件被加密后无法直接读取,最终通过以下途径恢复:

  1. 1. Git 仓库历史(优先):开发团队在 GitLab 中找到了 2 个月前提交的 schema.sql,包含 45 张表的结构定义。
  2. 2. 应用代码 ORM 映射:剩余 2 张后建表从 Django ORM models.py 中还原。
  3. 3. 手动核对:与业务人员逐一确认关键字段含义和数据类型。

4.2 MyISAM 表的特殊处理

对于 MyISAM 存储引擎的 .MYD 数据文件,其结构比 InnoDB 简单——本质上是堆表,没有页结构。处理方法:

  • • 从 .frm 获取列定义后,直接按固定行格式(FIXED)或变长格式(DYNAMIC)逐行解析 .MYD 的数据区。
  • • MyISAM 文件的加密模式同样是首尾各 1MB,中间数据区完整可读。

4.3 字符集与乱码处理

部分中文数据在提取后出现乱码。排查发现原始表使用了 gbk 编码,而恢复环境默认 utf8mb4。解决方案:

-- 导入前声明连接字符集
SET NAMES gbk;

-- 导入后转换为 utf8mb4
ALTER TABLE customers CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

4.4 事务一致性问题

由于 InnoDB 的重做日志(ib_logfile)同样被加密,无法通过日志回放恢复未提交的事务。实际影响评估:

  • • 加密发生时,ERP 系统处于非营业时段(凌晨 3:00)
  • • 仅有 2 个自动任务的事务处于未提交状态
  • • 损失范围可控:约 50 条定时任务日志记录

五、恢复结果

5.1 数据恢复统计

指标结果
总表数47 张
成功恢复表数46 张
完全未恢复表数1 张(日志表,业务可接受)
总记录行数约 1,280 万行
数据完整性99.2%(少量最近时间窗口的行因存储在文件尾部被加密)
恢复耗时约 28 小时(含分析、提取、重建、导入、校验)

5.2 数据损失评估

  • • 完全丢失:最近 3 天的操作日志(日志类型表,存储在文件尾部加密区域)
  • • 部分丢失:最近 1 天约 0.8% 的业务数据(位于文件尾部的数据页)
  • • 核心业务数据:99.2% 完整恢复,包含所有历史订单、客户、财务数据

5.3 业务恢复

ERP 系统在数据导入后当天恢复上线运行。企业仅损失了不到 1 个工作日的增量数据,通过纸质单据补录完成。

六、经验总结与防护建议

6.1 本案例成功的关键因素

  1. 1. Makop/devos 采用部分加密模式:仅加密文件首尾各 1MB,为恢复留下了核心数据窗口。
  2. 2. InnoDB 的数据页独立性强:每个 16KB 页自带校验和与 LSN,不依赖文件连续完整性。
  3. 3. 团队具备底层数据解析能力:能够直接从二进制页中提取数据,绕过了高层的表空间管理结构。
  4. 4. 快速响应:从发现到现场处理不超过 4 小时,防止了二次加密或误操作。
  5. 5. DDL 脚本有备份:Git 仓库中的 schema.sql 是恢复表结构的关键依赖。

6.2 安全加固建议

[服务器安全配置清单]

网络层:
☐ 关闭公网 RDP (3389),改用 VPN 或 ZeroTrust 方案
☐ 配置防火墙白名单策略
☐ 部署入侵检测系统 (IDS)

系统层:
☐ 启用 Windows Defender 或第三方 EDR
☐ 禁用不必要的服务与端口
☐ 定期安装安全补丁
☐ 实施最小权限原则,禁用 Administrator 直接登录
☐ 开启 RDP 登录失败锁定策略

数据层:
☐ 实施 3-2-1 备份策略
    - 至少 3 份数据副本
    - 使用 2 种不同存储介质
    - 至少 1 份异地/离线备份
☐ 定期验证备份可恢复性
☐ MySQL 开启 binlog 并异地同步
☐ 备份文件与主数据不可同机存放

管理层面:
☐ 制定网络安全应急预案
☐ 定期进行安全培训与钓鱼演练
☐ 购买网络安全保险(酌情)
☐ 建立漏洞管理与补丁周期制度

6.3 勒索病毒通用应急响应流程

发现异常 → 立即断网 → 保留现场 → 病毒样本提取
→ 加密范围评估 → 备份加密文件 → 溯源入侵路径
→ 恢复方案制定 → 数据恢复实施 → 环境重建
→ 安全加固 → 数据回迁 → 恢复上线 → 复盘总结
" 重要提醒:遭遇勒索病毒后,切勿轻易尝试网上的"解密工具",不当操作可能造成数据二次损坏。建议第一时间联系专业的数据恢复机构进行处置。

声明:本文基于真实案例脱敏处理后撰写,涉及的病毒变种特征和恢复技术具有时效性。具体恢复方案需根据实际受损情况制定,建议在专业指导下操作。

标签:厦门Makop勒索病毒解密、devos病毒MySQL恢复、厦门财务数据库修复、MySQL无备份恢复、勒索病毒数据库实操、Makop devos双变种、厦门MySQL数据救援、InnoDB表空间恢复、财务服务器勒索修复、不支付赎金数据库恢复

关于我们
我们的服务
我们的案例
新闻动态
微信扫一扫,获取帮助