2026FIC-agent在服务器取证侧的运用
写在前面,答案应该有三个错的,供参考
2026FIC-agent在服务器取证侧的运用
附录脚本
脚本1:E01/LVM/md RAID0/Btrfs 读取器
保存为 forensics_btrfs.py,在包含两个 E01 的目录下运行。
依赖示例:
pip install pyewf-python dissect.btrfs
import argparse
import os
import re
import sys
import pyewf
from dissect.btrfs import Btrfs
EXTENT_SIZE = 8192 * 512
MD_DATA_OFFSET = 67584 * 512
MD_CHUNK_SIZE = 1024 * 512
class EWF:
def __init__(self, path):
self.handle = pyewf.handle()
self.handle.open(pyewf.glob(path))
def read(self, offset, size):
self.handle.seek(offset)
return self.handle.read(size)
def close(self):
self.handle.close()
class LV:
def __init__(self, images, pvs, segments):
self.images = images
self.pvs = pvs
self.segments = segments
self.size = max((s["lv_start"] + s["count"]) * EXTENT_SIZE for s in segments)
def read_at(self, offset, size):
if offset >= self.size:
return b""
size = min(size, self.size - offset)
out = bytearray()
pos = offset
remaining = size
while remaining:
for seg in self.segments:
lo = seg["lv_start"] * EXTENT_SIZE
hi = (seg["lv_start"] + seg["count"]) * EXTENT_SIZE
if lo <= pos < hi:
in_seg = pos - lo
n = min(remaining, hi - pos)
pv_extent = seg["pv_start"] + in_seg // EXTENT_SIZE
rel = in_seg % EXTENT_SIZE
n = min(n, EXTENT_SIZE - rel)
image_name, part_start, pe_start = self.pvs[seg["pv"]]
disk_offset = (part_start + pe_start) * 512 + pv_extent * EXTENT_SIZE + rel
out += self.images[image_name].read(disk_offset, n)
pos += n
remaining -= n
break
else:
n = min(remaining, 4096)
out += b"\0" * n
pos += n
remaining -= n
return bytes(out)
class MDFile:
def __init__(self, components):
self.components = components
self.pos = 0
self.size = min(c.size - MD_DATA_OFFSET for c in components) * len(components)
def seek(self, offset, whence=0):
if whence == 0:
self.pos = offset
elif whence == 1:
self.pos += offset
elif whence == 2:
self.pos = self.size + offset
else:
raise ValueError(f"bad whence: {whence}")
return self.pos
def tell(self):
return self.pos
def read(self, size=-1):
if size is None or size < 0:
size = self.size - self.pos
data = self.read_at(self.pos, size)
self.pos += len(data)
return data
def read_at(self, offset, size):
if offset >= self.size:
return b""
size = min(size, self.size - offset)
out = bytearray()
pos = offset
remaining = size
ndev = len(self.components)
while remaining:
stripe = pos // MD_CHUNK_SIZE
dev = stripe % ndev
chunk_index = stripe // ndev
in_chunk = pos % MD_CHUNK_SIZE
n = min(remaining, MD_CHUNK_SIZE - in_chunk)
comp_offset = MD_DATA_OFFSET + chunk_index * MD_CHUNK_SIZE + in_chunk
out += self.components[dev].read_at(comp_offset, n)
pos += n
remaining -= n
return bytes(out)
class Evidence:
def __init__(self):
self.images = {
"sda": EWF("检材3-2.E01"),
"sdb": EWF("检材3-1.E01"),
}
self.pvs = {
"volum_pv0": ("sda", 8204288, 2048),
"volum_pv1": ("sdb", 2048, 2048),
"data_pv0": ("sda", 66797568, 2048),
"data_pv1": ("sdb", 58593280, 2048),
}
root_lv = LV(
self.images,
self.pvs,
[
{"lv_start": 0, "count": 7152, "pv": "volum_pv0", "pv_start": 0},
{"lv_start": 7152, "count": 7152, "pv": "volum_pv1", "pv_start": 0},
],
)
data_lv = LV(
self.images,
self.pvs,
[
{"lv_start": 0, "count": 8207, "pv": "data_pv1", "pv_start": 0},
{"lv_start": 8207, "count": 7205, "pv": "data_pv0", "pv_start": 0},
],
)
self.btrfs = Btrfs(MDFile([data_lv, root_lv]))
self.subvol = self.btrfs.find_subvolume("@rootfs")
def close(self):
for image in self.images.values():
image.close()
def node(self, path):
return self.subvol.get(path)
def read(self, path, max_bytes=None):
fh = self.node(path).open()
return fh.read() if max_bytes is None else fh.read(max_bytes)
def text(self, path, max_bytes=None):
return self.read(path, max_bytes).decode("utf-8", "replace")
def listdir(self, path):
for name, child in self.node(path).iterdir():
if name in (".", ".."):
continue
yield name, child
def walk(self, path, max_depth=99):
root = self.node(path)
stack = [(path.rstrip("/") or "/", root, 0)]
while stack:
cur_path, node, depth = stack.pop()
yield cur_path, node
if depth >= max_depth or not node.is_dir():
continue
try:
children = list(node.iterdir())
except Exception:
continue
for name, child in reversed(children):
if name in (".", ".."):
continue
child_path = (cur_path.rstrip("/") + "/" + name) if cur_path != "/" else "/" + name
stack.append((child_path, child, depth + 1))
def cmd_cat(ev, args):
sys.stdout.buffer.write(ev.read(args.path, args.bytes))
def cmd_ls(ev, args):
for name, child in ev.listdir(args.path):
typ = "d" if child.is_dir() else "f" if child.is_file() else "l" if child.is_symlink() else "?"
print(f"{typ} {child.size:>12} {child.mtime.isoformat()} {name}")
def cmd_find(ev, args):
regex = re.compile(args.pattern, re.I)
for path, node in ev.walk(args.path, args.depth):
if regex.search(path):
typ = "d" if node.is_dir() else "f" if node.is_file() else "l" if node.is_symlink() else "?"
print(f"{typ} {node.size:>12} {node.mtime.isoformat()} {path}")
def cmd_grep(ev, args):
regex = re.compile(args.pattern, re.I | re.S)
for path, node in ev.walk(args.path, args.depth):
if not node.is_file() or node.size > args.max_size:
continue
try:
data = node.open().read()
text = data.decode("utf-8", "replace")
except Exception:
continue
if regex.search(text):
print(f"--- {path}")
for line in text.splitlines():
if re.search(args.pattern, line, re.I):
print(line[:1000])
def cmd_export(ev, args):
node = ev.node(args.source)
os.makedirs(os.path.dirname(os.path.abspath(args.dest)), exist_ok=True)
with node.open() as src, open(args.dest, "wb") as dst:
remaining = node.size
while remaining:
chunk = src.read(min(args.chunk, remaining))
if not chunk:
break
dst.write(chunk)
remaining -= len(chunk)
print(args.dest)
def main():
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("cat")
p.add_argument("path")
p.add_argument("--bytes", type=int)
p.set_defaults(func=cmd_cat)
p = sub.add_parser("ls")
p.add_argument("path")
p.set_defaults(func=cmd_ls)
p = sub.add_parser("find")
p.add_argument("path")
p.add_argument("pattern")
p.add_argument("--depth", type=int, default=99)
p.set_defaults(func=cmd_find)
p = sub.add_parser("grep")
p.add_argument("path")
p.add_argument("pattern")
p.add_argument("--depth", type=int, default=99)
p.add_argument("--max-size", type=int, default=2_000_000)
p.set_defaults(func=cmd_grep)
p = sub.add_parser("export")
p.add_argument("source")
p.add_argument("dest")
p.add_argument("--chunk", type=int, default=8 * 1024 * 1024)
p.set_defaults(func=cmd_export)
ev = Evidence()
try:
args = parser.parse_args()
args.func(ev, args)
finally:
ev.close()
if __name__ == "__main__":
main()
常用方式:
python .\forensics_btrfs.py cat /etc/fstab
python .\forensics_btrfs.py ls /var/www/html/maccms10
python .\forensics_btrfs.py grep /var/www/html/maccms10 "site_icp|site_url"
python .\forensics_btrfs.py export /media/zfs C:\Users\Alexander\AppData\Local\Temp\media_zfs.img
脚本2:ZFS 对象文件导出
zdb 可以列目录和数据块。下面脚本用于从 ZFS 对象号导出普通文件。
#!/usr/bin/env bash
set -euo pipefail
POOL_DIR="/mnt/c/Users/Alexander/AppData/Local/Temp"
POOL="db"
DATASET="db/tidb"
extract_obj() {
obj="$1"
out="$2"
size="$3"
: > "$out"
/usr/sbin/zdb -e -p "$POOL_DIR" -dddddd "$DATASET" "$obj" |
awk '$2=="L0" && $3 ~ /^0:/ {
split($3,b,":");
split($4,a,"L/");
gsub("P","",a[2]);
print b[1]":"b[2]":"a[1]"/"a[2]":dr"
}' |
while read -r spec; do
/usr/sbin/zdb -e -p "$POOL_DIR" -R "$POOL" "$spec" 2>/dev/null >> "$out"
done
truncate -s "$size" "$out"
}
mkdir -p /tmp/mysql_mac2
extract_obj 87627 /tmp/mysql_mac2/mac_user.MYD 5907272
extract_obj 87626 /tmp/mysql_mac2/mac_user.MYI 1705984
extract_obj 87625 /tmp/mysql_mac2/mac_user_422.sdi 31223
extract_obj 98319 /tmp/mysql_mac2/binlog.000009 6651621
ls -lh /tmp/mysql_mac2
常用 zdb 查看目录:
zdb -e -p /mnt/c/Users/Alexander/AppData/Local/Temp -dddd db/tidb 34
zdb -e -p /mnt/c/Users/Alexander/AppData/Local/Temp -dddd db/tidb 59030
zdb -e -p /mnt/c/Users/Alexander/AppData/Local/Temp -dddd db/tidb 87726
脚本3:还原 binlog 并查询用户答案
需要 MySQL 8.0 工具:
apt-get install -y mysql-server-8.0
保存为 restore_mac_user.sh 后在 WSL root 下运行。
#!/usr/bin/env bash
set -euo pipefail
DATADIR=/tmp/mysql_forensic_run
SOCK=/tmp/mysql_forensic.sock
BINLOG=/tmp/mysql_mac2/binlog.000009
rm -rf "$DATADIR"
mkdir -p "$DATADIR"
/usr/sbin/mysqld --initialize-insecure --datadir="$DATADIR" --user=root --log-error=/tmp/mysql_forensic_init.err
/usr/sbin/mysqld \
--datadir="$DATADIR" \
--socket="$SOCK" \
--skip-networking \
--pid-file=/tmp/mysql_forensic.pid \
--log-error=/tmp/mysql_forensic.err \
--user=root \
--daemonize
for _ in $(seq 1 30); do
mysqladmin --protocol=SOCKET --socket="$SOCK" ping >/dev/null 2>&1 && break
sleep 1
done
mysql --protocol=SOCKET --socket="$SOCK" -uroot <<'SQL'
DROP DATABASE IF EXISTS mac2;
CREATE DATABASE mac2 DEFAULT CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci;
USE mac2;
CREATE TABLE `mac_user` (
`user_id` int unsigned NOT NULL AUTO_INCREMENT,
`group_id` varchar(255) NOT NULL DEFAULT '0',
`user_name` varchar(30) NOT NULL DEFAULT '',
`user_pwd` varchar(32) NOT NULL DEFAULT '',
`user_nick_name` varchar(30) NOT NULL DEFAULT '',
`user_qq` varchar(16) NOT NULL DEFAULT '',
`user_email` varchar(30) NOT NULL DEFAULT '',
`user_phone` varchar(16) NOT NULL DEFAULT '',
`user_status` tinyint unsigned NOT NULL DEFAULT '0',
`user_portrait` varchar(100) NOT NULL DEFAULT '',
`user_portrait_thumb` varchar(100) NOT NULL DEFAULT '',
`user_openid_qq` varchar(40) NOT NULL DEFAULT '',
`user_openid_weixin` varchar(40) NOT NULL DEFAULT '',
`user_question` varchar(255) NOT NULL DEFAULT '',
`user_answer` varchar(255) NOT NULL DEFAULT '',
`user_points` int unsigned NOT NULL DEFAULT '0',
`user_points_froze` int unsigned NOT NULL DEFAULT '0',
`user_reg_time` int unsigned NOT NULL DEFAULT '0',
`user_reg_ip` int unsigned NOT NULL DEFAULT '0',
`user_login_time` int unsigned NOT NULL DEFAULT '0',
`user_login_ip` int unsigned NOT NULL DEFAULT '0',
`user_last_login_time` int unsigned NOT NULL DEFAULT '0',
`user_last_login_ip` int unsigned NOT NULL DEFAULT '0',
`user_login_num` smallint unsigned NOT NULL DEFAULT '0',
`user_extend` smallint unsigned NOT NULL DEFAULT '0',
`user_random` varchar(32) NOT NULL DEFAULT '',
`user_end_time` int unsigned NOT NULL DEFAULT '0',
`user_pid` int unsigned NOT NULL DEFAULT '0',
`user_pid_2` int unsigned NOT NULL DEFAULT '0',
`user_pid_3` int unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`user_id`),
KEY `type_id` (`group_id`),
KEY `user_name` (`user_name`),
KEY `user_reg_time` (`user_reg_time`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci;
SQL
mysqlbinlog --stop-position=6650505 "$BINLOG" | mysql --protocol=SOCKET --socket="$SOCK" -uroot
mysql --protocol=SOCKET --socket="$SOCK" -uroot <<'SQL'
USE mac2;
SELECT COUNT(*) AS user_count FROM mac_user;
SELECT DATE_FORMAT(FROM_UNIXTIME(user_reg_time),'%Y/%c/%e') AS reg_date, COUNT(*) AS cnt
FROM mac_user
GROUP BY reg_date
ORDER BY cnt DESC, reg_date ASC
LIMIT 10;
SELECT user_id,user_name,INET_NTOA(user_last_login_ip) AS last_login_ip,
user_last_login_time,FROM_UNIXTIME(user_last_login_time) AS last_login_time_text
FROM mac_user
WHERE user_name='Ma Hui Mei' OR user_nick_name='Ma Hui Mei';
SQL
mysqladmin --protocol=SOCKET --socket="$SOCK" -uroot shutdown
脚本4:SM3 计算
$tmp = Join-Path $env:TEMP 'nginx_default_forensic.conf'
python .\forensics_btrfs.py export /etc/nginx/sites-available/default $tmp
wsl -u root bash -lc 'python3 - <<PY
import hashlib
p="/mnt/c/Users/Alexander/AppData/Local/Temp/nginx_default_forensic.conf"
print(hashlib.new("sm3", open(p,"rb").read()).hexdigest().upper())
PY'
Remove-Item -LiteralPath $tmp -Force
取证思路
检材给出两个 E01 镜像。磁盘不是普通单盘文件系统,而是多层组合:
- E01 内部为 GPT 分区;
- 多个分区组成 LVM PV;
- LVM 中的两个 LV 再组成
mdadmRAID0; - RAID0 上是 Btrfs,根子卷为
@rootfs; /media/zfs是一个 ZFS 池文件,里面放着 LXC 容器根文件系统;- 网站数据库位于 LXC 容器内的 MySQL/TiDB 相关数据中。
因此先写了一个 Python 读取器,直接从 E01 -> LVM -> md RAID0 -> Btrfs 读文件,避免破坏原始证据。
关键证据
服务器部分
1. 操作系统版本
读取:
python .\forensics_btrfs.py cat /usr/lib/os-release
关键字段:
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
DEBIAN_VERSION_FULL=13.0
答案:13.0
2. 根分区 UUID
读取:
python .\forensics_btrfs.py cat /etc/fstab
关键内容:
UUID=3231e52f-5e15-44c4-b224-e29cb4201c0e / btrfs defaults,subvol=@rootfs 0 0
答案:3231e52f-5e15-44c4-b224-e29cb4201c0e
3. 最新 Docker 镜像创建时间
Docker 配置显示数据目录为 /data:
python .\forensics_btrfs.py cat /etc/docker/daemon.json
然后查看镜像元数据:
python .\forensics_btrfs.py find /data/image/overlay2/imagedb/content/sha256 ".*\.json"
python .\forensics_btrfs.py cat /data/image/overlay2/imagedb/content/sha256/<image-json>
最新镜像 JSON 的 created 字段为:
2026-04-16T07:15:50.535713491Z
4. 根分区快照路径
Btrfs 子卷中发现:
@rootfs
@rootfs/root/history
在挂载后的根路径中对应:
/root/history
5. 后台管理入口文件名
网站目录为 /var/www/html/maccms10。入口文件中 user.php 定义了后台入口:
python .\forensics_btrfs.py cat /var/www/html/maccms10/user.php
答案:user.php
6-7. ICP 与主域名
读取站点配置:
python .\forensics_btrfs.py cat /var/www/html/maccms10/application/extra/maccms.php
关键字段:
'site_url' => 'www.2026fic.forensix',
'site_icp' => 'icp1919810',
答案:
icp1919810
2026fic.forensix
8. 分类3中视频的拼音
从 MacCMS 类型缓存/安装数据中定位 type_id = 3:
python .\forensics_btrfs.py grep /var/www/html/maccms10 "type_id.*3|fenlei3"
关键字段:
type_id: 3
type_name: 分类3
type_mid: 1
type_en: fenlei3
答案:fenlei3
9. 站点设置页面前端模板源文件
运行缓存模板里包含源文件注释,定位到设置页模板:
python .\forensics_btrfs.py grep /var/www/html/maccms10/runtime/temp "site\[template_dir\]|view_new/system/config"
源文件为:
application/admin/view_new/system/config.html
10. 伪静态规则配置文件 SM3
实际 Nginx 站点配置为:
/etc/nginx/sites-available/default
导出后计算 SM3:
$tmp = Join-Path $env:TEMP 'nginx_default_forensic.conf'
python .\forensics_btrfs.py export /etc/nginx/sites-available/default $tmp
wsl -u root bash -lc 'python3 - <<PY
import hashlib
p="/mnt/c/Users/Alexander/AppData/Local/Temp/nginx_default_forensic.conf"
print(hashlib.new("sm3", open(p,"rb").read()).hexdigest().upper())
PY'
结果:
E73407468E6F52AF54C7B14632EEEB9BE25B05106D06C4C3085FC843C223793F
11. 数据库 IP
网站数据库配置:
python .\forensics_btrfs.py cat /var/www/html/maccms10/application/database.php
关键字段:
'hostname' => 'mytidb',
'database' => 'mac2',
'username' => 'aa',
'password' => '123456',
'hostport' => '3306',
再读 hosts:
python .\forensics_btrfs.py cat /etc/hosts
关键内容:
10.0.3.100 mytidb
答案:10.0.3.100
12. 数据库使用的容器技术
读取 LXC 配置:
python .\forensics_btrfs.py cat /var/lib/lxc/mytidb/config
关键字段:
lxc.rootfs.path = dir:/db/tidb
lxc.net.0.type = veth
答案:LXC
13. 4000 端口备份数据库版本
先导出 ZFS 池文件:
python .\forensics_btrfs.py export /media/zfs C:\Users\Alexander\AppData\Local\Temp\media_zfs.img
用 zdb 查看池和数据集:
zdb -e -p /mnt/c/Users/Alexander/AppData/Local/Temp db
zdb -e -p /mnt/c/Users/Alexander/AppData/Local/Temp -dddd db/tidb 34
在 LXC 根文件系统的 /root/.tiup/data/*/tiup_process_meta 中发现 TiUP playground 参数:
"/root/.tiup/components/playground/v1.16.5/tiup-playground",
"v7.5.0",
"--host",
"0.0.0.0"
TiDB 默认 SQL 端口为 4000,备份数据库版本为:
v7.5.0
14-15. 用户注册日期统计与马慧美最后登录 IP
LXC 容器内 MySQL 数据目录为:
/var/lib/mysql/mac2
关键文件:
mac_user.MYD
mac_user.MYI
mac_user_422.sdi
binlog.000009
直接挂 MyISAM 时索引状态不一致,因此采用 mysqlbinlog 还原 mac_user 行事件到临时 MySQL,再查询:
SELECT DATE_FORMAT(FROM_UNIXTIME(user_reg_time),'%Y/%c/%e') AS d, COUNT(*) AS c
FROM mac_user
GROUP BY d
ORDER BY c DESC, d ASC
LIMIT 10;
SELECT user_id,user_name,INET_NTOA(user_last_login_ip),user_last_login_time,FROM_UNIXTIME(user_last_login_time)
FROM mac_user
WHERE user_name='Ma Hui Mei' OR user_nick_name='Ma Hui Mei';
结果:
2026/4/15 28933
2026/4/16 13232
4236 Ma Hui Mei 51.43.21.163 1776282701 2026-04-16 03:51:41
答案:
2026/4/15
51.43.21.163
16. 未被使用的文件系统
证据中使用了:
- Btrfs:根文件系统;
- LVM:磁盘卷管理;
- ZFS:
/media/zfs池文件; - vfat:EFI 分区;
- udf/iso9660:光驱配置。
未见 NTFS 使用痕迹,因此选:
A. ntfs
C. xfs
17. 安装的数据库服务
主机上有 PostgreSQL 17:
python .\forensics_btrfs.py cat /var/lib/dpkg/status | Select-String -Pattern 'Package: postgresql' -Context 0,3
LXC 容器中有 MySQL 数据目录和 MySQL 8.0.45 SDI 元数据:
{"mysqld_version_id":80045}
TiUP playground 中有 TiDB:
v7.5.0
所以答案为:
A、C、D
最终答案
| 题号 | 答案 |
|---|---|
| 1 | 13.0 |
| 2 | 3231e52f-5e15-44c4-b224-e29cb4201c0e |
| 3 | 2026-04-16T07:15:50.535713491Z |
| 4 | /root/history |
| 5 | user.php |
| 6 | icp1919810 |
| 7 | www.2026fic.forensix |
| 8 | fenlei3 |
| 9 | application/admin/view_new/system/config.html |
| 10 | E73407468E6F52AF54C7B14632EEEB9BE25B05106D06C4C3085FC843C223793F |
| 11 | 10.0.3.100 |
| 12 | LXC |
| 13 | v7.5.0 |
| 14 | 2026/4/15 |
| 15 | 51.43.21.163 |
| 16 | A. ntfs C. xfs |
| 17 | A、C、D,即 mysql、tidb、postgresql |
本文来自博客园,作者:{Alexander17},转载请注明原文链接:{https://home.cnblogs.com/u/alexander17}

浙公网安备 33010602011771号