news 2026/10/5 6:15:24

DexProtector内存态匿名段检测原理与dex重组绕过思路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DexProtector内存态匿名段检测原理与dex重组绕过思路

很多搞Android安全的朋友第一次碰上DexProtector,大概率都是从“这个APP的dex脱壳后怎么一跑就崩”开始的。商业加固工具这几年越做越重,DexProtector算是一个典型代表——它不是简单把dex加密一下,而是把Art虚拟机从启动到运行的整个链路都考虑进来了,光在“dex加载”这一环就埋了好几道检测。而“内存态匿名段检测”就是其中很有意思的一道坎。这篇文章换个角度,不吹“DexProtector无敌”也不空喊“干穿它”,而是把它的加固思路、检测逻辑、以及对抗侧常见的“dex重组”思路放在一起拆开看。全程以原理分析和授权实验为主,相关操作只建议在你有权限、有授权的前提下进行。

先说清楚一个基础认知:DexProtector这类工具保护的核心目标是dex文件。一个APP的dex文件包含了几乎全部业务逻辑,谁拿到它谁就能逆向出代码。所以加固方案的第一步就是把dex加密或者抽取,让静态拿到的apk里根本没有完整的dex。但app最终要运行,加固后的dex必须被还原并加载进内存,这就给了对抗侧一个机会——从内存中把dex dump出来。而DexProtector偏偏就在这一步做了大量文章,“内存态匿名段检测”只是其中一条线。这篇文章我会从内存映射原理讲起,再把检测逻辑和重组思路逐层展开。

1. 先从“威胁模型”说起:DexProtector到底在防谁

1.1 加固工具眼中的攻击者画像

很多刚接触加固的人会以为,加固工具是在防“黑客”。这个说法太粗了。如果站在DexProtector这类商业产品的视角,它的威胁模型其实非常明确:一个拥有设备root权限、能抓包、能下断点、能读写进程内存的攻击者。这个攻击者通常已经拿到了apk本身,甚至可以自由地在设备上做任何操作,唯一缺的就是“完整可读的dex逻辑”。

这个威胁模型决定了DexProtector的防御设计不是“不让进”,而是“让你就算进来了也拿不到好东西”。所以你会看到它的方案里不仅有常规的文件加密,还有防调试、防dump、防hook、防篡改,甚至在运行时对dex内存区域做完整性校验。理解了“它防的是一个权限很高的攻击者”这一点,后面看它为什么盯着内存态匿名段,就顺理成章了。

1.2 DEX从静态文件到内存态的旅程

一个正常的Android app启动时,dex文件从apk中提取出来,由Art虚拟机通过mmap映射到内存中。这个映射通常是文件映射,也就是说内存页背后有实际的文件在支撑,内核在页面缺失时会从文件加载数据。这种映射在/proc/self/maps里长这样:

7f1c400000-7f1c600000 r--p 00000000 fd:0b 12345 /data/app/.../base.apk

看到了吗,后面是有名字的,指向apk或者odex文件。这是加固工具判断“dex来路正不正”的一个天然依据。

但当我们自己写代码把一份dex从内存中解密出来,再用memcpy放到一块新分配的内存里,再让Art去load它,情况就完全不一样了。这块内存没有文件支撑,内核只是给它分配了匿名页,maps里对应的区域会显示成类似这样:

7f1c400000-7f1c600000 rw-p 00000000 00:00 0

注意后面的00000000 00:00 0,这表示一个匿名映射。对DexProtector来说,这行信息本身就非常可疑。“一份合法的dex应该来自文件映射,你这里却是匿名映射,那大概率是某种动态加载或者dump后的数据。”这就是“内存态匿名段检测”最朴素的出发点。

1.3 “匿名段”为什么会成为检测信号

说到这你可能会问:匿名映射本身在Android里很常见啊,搞个JIT、放个堆内存不都是匿名映射吗?为什么它能拿来做检测?

关键在于检测的“目标对象”不是匿名段本身,而是“一份运行中的dex是否落在了匿名段里”。Art在把dex解析成OatDexFile或者DexFile的时候,会记录下dex的内存地址范围。如果DexProtector的native层代码能拿到这些dex区域,再逐一检查它们的映射属性,就会很清楚地发现:哎,这个区域是匿名的,来路不对。

这就像小区保安盯的不是“有没有陌生人”,而是“陌生人有没有住在业主登记的房子里”。匿名映射本身不违法,但一份应该由文件加载的dex跑到了匿名段里,那就说明中间一定发生了什么不该发生的事。所以DexProtector会在这个点上做定时巡检,一旦发现异常,轻则让app闪退,重则触发假数据、自毁逻辑。

2. 内存态匿名段检测是怎么实现的

2.1 扫描时机:启动期巡检与运行期轮询

检测不能只在启动时做一次,因为对抗侧完全可以在app完全起来之后,再hook住Art的加载流程,把重组的dex塞进去。所以DexProtector的做法通常是“启动时重点查+运行期间歇查”。

启动时重点查,是在加固dex被解密、加载的瞬间,检查当前Art运行时中已经注册的dex文件列表里有没有异常区域。运行期间歇查,是后台开一个线程,每隔几百毫秒扫一遍maps文件或通过Art内部接口遍历dex区域,比对映射权限、文件来源等属性。这样的设计很直白,但也很有用——大部分dump工具拿到dex后,总需要一个加载动作,只要加载动作发生在Art的常规路径之外,就容易留下痕迹。

2.2 检测维度的三个关键点

抛开具体实现细节,内存态匿名段检测核心就靠三个维度来判断:

第一个维度是“文件来源”。通过/proc/self/maps或者dl_iterate_phdr拿到一个内存区域的映射来源,看它是/data/app/xxx/base.apk这种合法路径,还是没有文件支撑的匿名区域。这是最关键的判断依据。

第二个维度是“加载方式”。Art加载dex通常有固定路径——DexFile::Open、OatFile::Open、DexFileLoader这类接口。如果检测代码发现当前内存中的dex区域并非通过这些标准接口建立,就说明加载方式是自定义的。DexProtector的hook点会埋在Art的关键方法上,拦截OpenCommon、DexFile::OpenFile等函数,记录每一个dex的来源。

第三个维度是“运行特征”。包括dex区域的访问权限(是r--还是rw-)、数据段的布局是否完整、class_defs的偏移是否合理。如果一份dex是从内存中dump后直接重组的,往往会有头部字段错乱、偏移对不齐等问题。这些特征虽然不是直接判定匿名段,但会在综合评分里被拉出来作为异常加分项。

2.3 一个简化的检测逻辑模型

为了便于理解,我把检测逻辑抽象成一段伪代码,真实实现肯定更复杂,但主体思路就是这个样子:

function checkDexRegion(addr, size): maps = readMaps() region = findRegion(maps, addr, size) if region is null: return ANONYMOUS_SUSPICIOUS if region.pathname == "": score += 40 if region.perms != "r--" and region.perms != "r-x": score += 20 if !isArtLoadedViaStandardPath(addr): score += 40 if score > 70: triggerProtection()

这个模型解释了为什么“伪造maps”和“恢复映射属性”会成为绕过的核心方向。只要让检测函数在做判断时看到的信息足够“正常”,分数就上不去,检测自然就被糊弄过去了。

3. “绕过”思路的底层逻辑:不是骗检测,而是重定义加载链路

3.1 先想清楚绕过的边界

很多人一听到“绕过”两个字就兴奋,觉得是神仙打架。但做安全研究最忌讳的就是不讲边界。未经授权的绕过,本质上是破坏别人服务的行为;只有在你自己拥有的一台设备、自己开发的app、或者明确授权的测试环境里做研究,才是有价值的。所以这一节讲的都是原理层面的思路枚举,不提供现成脚本,也不鼓励对任何你没授权的app动手。

在这个前提下,我可以负责任地说:绕过内存态匿名段检测这件事,难点不在“写代码”,而在“是否理解了加载链路”。你要是能把Art加载dex的全流程走一遍,绕过的思路自然就浮现了。

3.2 思路A:让检测函数看到一个“正常”的映射

最直接的想法是,既然检测函数在看maps,那我就让maps里对应区域显示成文件映射。做法是:先把dex文件写到磁盘上,然后重新以文件映射的方式把这块区域挂载进来。但这有几个坑。

第一个坑是,文件写在哪里。你不能写到app自己的私有目录太显眼的位置,因为DexProtector很可能检测这些目录的可写状态。第二个坑是,Art加载dex以后会缓存很多跟文件路径、修改时间相关的信息,光改maps不够,还得保证文件存在且内容匹配。第三个坑是,即便你伪造了maps,运行时的定时自校验仍然可能通过对比文件hash和内存数据来发现你改过文件。所以这个思路看着简单,实际操作限制很多。

3.3 思路B:在dex落地前动手,把重组的dex写回原加载路径

另一个思路是,在DexProtector把解密后的dex加载进Art之前,先一步把重组的dex内容替换过去。这个思路的巧妙之处在于它不跟检测打架,而是让自己变成了“加载链路上合理的一部分”。

具体原理是,DexProtector必须在运行时把解密后的dex交给Art。而Art的加载入口是固定的,如果你能通过Hook或者Inline Hook的方式在入口处截获数据,把原始dex内容替换成你重组的dex内容,那么后续的加载流程完全走的是合法路径。检测机制看到的是:文件映射、标准加载接口、正常权限位。它自然不会觉得有问题。

这里引出了一个关键概念——dex重组。因为你在入口处替换的不再是“原封不动的dex”,而是从内存dump后修复、重组出来的新dex。这就对dex本身的结构完整度提出了很高的要求。

3.4 思路C:Hook判定函数,让检测“失明”

第三种思路更直接:找到检测代码的关键判定点,直接让判定结果恒为“正常”。比如Hook住读取maps的那些函数,让它们只返回经过过滤的内容;或者Hook住最终的打分函数,让分数永远低于阈值。

这个思路的难度在于“找到关键判定点”本身。DexProtector是商业级的,代码经过了混淆、加壳、反调试处理,你没法直接在IDA里搜一个函数名就找到它。你只能通过动态调试、Trace、内存读写断点等方式一点点缩小范围。而且要小心,很多加固工具会做双重校验——你Hook了A函数,B函数可能也在做同样的事情,或者A函数有完整性校验,你改了它,它就触发自毁逻辑。

我把这几种思路放在一起对比一下:

思路核心动作难度风险
伪造映射修改maps或文件映射属性中等定时自校验容易穿透
原路径替换在Art入口替换dex内容较高需要掌握dex重组
Hook判定函数修改检测逻辑返回值高反调试和完整性校验多

从研究和学习的角度,思路B最有价值,因为它要求你把“dex加载”和“dex结构”这两块知识吃透。下面我就重点拆解dex重组技术。

4. dex重组技术:关键细节与实操流程

4.1 dex文件结构回顾:你重组时到底在动什么

一个标准的dex文件,开头92字节是header,里面记录了一堆关键偏移:file_size、header_size、string_ids_size/off、type_ids_size/off、proto_ids_size/off、field_ids_size/off、method_ids_size/off、class_defs_size/off、data_size/off。这些字段是Art解析dex的目录,任何一个offs写错,整个dex就废了。

重组的过程,本质上就是把dex的各段重新排列,并把header里对应的offset同步更新。常见的内存dump得到的dex往往是“残缺版”的——可能是数据段缺失,可能是class_defs里的数据被抽取到加固区,也可能是header头被抹掉了一部分。所以重组的第一步永远是“体检”:打开hexdump,对照标准dex结构,一项项看哪里对不上。

4.2 重组的三个核心环节

第一个环节是“修复header”。假设你从内存里dump出来一份dex,首先要看file_size是否等于整个文件的大小,再看header_size是否等于0x70,然后是data_off和data_size。很多dump脚本会把data段跟header混在一起拷贝,导致data_off指向的位置不对。修复方法很简单,就是重新计算各段的实际位置,将对应offset改成正确的值。

第二个环节是“重建class_defs”。这是最复杂的部分。class_defs是dex的“班级名册”,里面记录了每一个类的定义,包括类名、访问标志、父类、接口、注解、静态字段、实例字段、方法列表。DexProtector的抽取加固通常会把这些信息从静态dex里抹掉,等运行时再还原到内存中。如果你dump的时机不对,拿到的class_defs可能是空的或者错位的。这时候就需要通过其他线索——比如method_id里的字符串索引、type_id里的类型描述——反向重建class_defs结构。这个过程非常繁琐,但也最能体现基本功。

第三个环节是“修复指令偏移”。dex里每条指令中的跳转偏移(branch offset)、异常处理偏移、字符串引用等,都是以文件头为基准的绝对偏移,也可能是指令间相对偏移。如果重组后这些偏移没有同步更新,运行时会直接崩在“Bad offset”一类的错误上。

为了不让你看得太抽象,我把dex头里最关键的几个字段列一下:

字段偏移含义重组时的作用
file_size0x20dex文件总长度验证dump是否完整
string_ids_size/off0x38/0x3C字符串索引数量与偏移定位字符串
type_ids_size/off0x40/0x44类型索引数量与偏移定位类型
method_ids_size/off0x58/0x5C方法索引数量与偏移定位方法
class_defs_size/off0x60/0x64类定义数量与偏移核心目录
data_size/off0x68/0x6C数据段大小与偏移校验数据完整性

4.3 实操示例:用Python脚本做最基础的dex头修复

在授权实验里,拿到dump出来的dex后,我习惯先写一个简短的Python脚本做头部校验和修复。下面这个示例只是最基础的版本,不要指望它能处理加固后的复杂情况,但思路是通用的:

import struct def fix_dex_header(path, output_path): with open(path, 'rb') as f: data = bytearray(f.read()) # 读取header关键字段 file_size = struct.unpack_from('<I', data, 0x20)[0] header_size = struct.unpack_from('<I', data, 0x24)[0] data_off = struct.unpack_from('<I', data, 0x6C)[0] data_size = struct.unpack_from('<I', data, 0x68)[0] print(f'当前file_size: {file_size:#x}, 实际文件长度: {len(data):#x}') print(f'header_size: {header_size:#x}, data_off: {data_off:#x}, data_size: {data_size:#x}') if header_size != 0x70: print('[!] header_size异常,强制设为0x70') struct.pack_into('<I', data, 0x24, 0x70) if file_size != len(data): print('[!] file_size与文件长度不一致,尝试修正') struct.pack_into('<I', data, 0x20, len(data)) # 这里只是演示,实际还要校验data_off是否在有效范围内 with open(output_path, 'wb') as f: f.write(data) fix_dex_header('dump.dex', 'dump_fixed.dex')

这种脚本一般只能解决“文件长度对不上”的问题。如果class_defs里的方法数据被抽取了,或者指令偏移已经错乱,就要用更专业的工具配合手动分析了。

4.4 重组后如何验证

重组完的dex能否被Art正常加载,最简单的验证方式是把它放到一个测试app里,用Art的dalvikvm命令单独执行dex中的某个类方法。如果它能跑通,说明结构基本完好;如果报错,就要根据错误信息反向排查。

比如java.lang.VerifyError往往说明指令流有问题,NoClassDefFoundError说明class_defs索引有问题,StringIndexOutOfBoundsException则可能是常量池里的字符串索引被改乱了。在我实际测试中,dumped dex最常见的问题还是出现在“抽取方法体被还原后,code_item的偏移没有同步修正”上,这一类问题会让你忙活很久。

5. 实操环境与授权实验:一次完整的“加固评估”记录

5.1 环境准备:root设备、Frida、以及耐心

做这种实验,你需要一台root过的测试机或模拟器,一套能跑Frida/Xposed的环境,以及一份样本apk。我建议在样本选择上首选自己写的demo app,把它用DexProtector加固后做测试,这样既没有法律风险,又能在出错时拿到最完整的日志。

环境清单如下:

  • 一台Pixel或任意可root的测试机(模拟器也可以,但有些加固工具对模拟器有指纹检测)
  • Frida 16.x + frida-server
  • 一份自己开发、用DexProtector加固的demo app
  • IDA Pro或Ghidra用于静态分析native层
  • 010 Editor或HxD用于hexdump分析dex结构

我自己习惯用frida脚本先做一次“盲dump”:找到进程中所有看起来像dex的内存区域,把数据导出来,然后用脚本查看文件头是不是dex\n035\0魔数。这一步在评估加固方案强度时非常有用,能快速摸清对方在内存中保留了哪些暴露面。

5.2 内存提取:如何判断哪里是真正的dex区域

dex在内存里不是孤零零的一块。Art会把header、string_ids、class_defs等区域分散在不同的内存块中管理。如果你直接遍历maps,把所有可读区域都dump下来,会发现大多数区域根本不算dex。判断的关键是找魔数dex\n,然后验证它后面的file_size和实际可读区域是否匹配。

我写过一个简化版Frida脚本,原理就是遍历maps里的匿名可读区域,逐个匹配64 65 78 0a(即dex\n),再读取+0x20位置的file_size,确认这个区域大小是否足够。这样可以快速定位出候选dex区域。不过实际dump时,尽量不要只dump一个区域,最好把相邻可读区域一起拉下来,方便之后做class_defs补全。

5.3 重组实操:从dump到可加载dex的完整步骤

我以一次对自写demo app的实验为例,讲一下从dump到重组出来的完整步骤:

第一步,启动app后等它完全运行起来,用frida脚本枚举所有候选dex区域,把内存数据保存为dump1.bin、dump2.bin。

第二步,用十六进制工具打开dump文件,检查头部。常见情况是:文件中确实有dex\n035\0,但file_size很可能为0,或者data段偏移不对。此时需要根据内存中周围的log和Art的解析行为来猜测正确的file_size。

第三步,如果文件头基本正常,就用上一节的Python脚本修复头部。如果class_defs里很多内容为空,就要对照dex结构手动重建。这个过程非常耗时间,我通常先看string_ids里有没有关键类名,再用baksmali尝试反编译,把报错信息当成路标。

第四步,把重组好的dex重新打包到一个全新的apk里,不加固,直接安装运行,看业务逻辑是否正常。如果正常,说明重组成功;如果不正常,就去logcat里找崩溃堆栈,定位是哪个类、哪个方法出了问题。

5.4 时间开销与难度评估

做一次完整的dumped dex重组,在理想情况下大概需要几个小时。但如果是被DexProtector深度抽取的方法体,可能耗上一整天也不一定跑完。我个人的体会是,判断一个加固工具“难不难”,不重要看它用了多少反调试,更要看重它是不是每个方法体都抽取了、抽取的粒度细不细、是否会对dump进行主动干扰。DexProtector在这方面的防御确实做得比较扎实。

6. 常见问题与排查技巧实录

6.1 dump下来的dex打不开:头部校验都过不了

这是我遇到过最多的问题。原因多半是Art在加载dex时,header被单独处理过,或者内存中分散存储。你要做的是不要只看第一个dex\n,把它当作唯一线索。可以试试扫描多个区域,找出完整的header,然后把data段按实际布局拼回去。

还有个小技巧:一些加固工具会把dex的file_size故意写错,误导dump工具。这时候可以用/proc/pid/stat里的内存布局和运行时日志来反推实际大小。这个方法不是百分百可靠,但至少能帮你缩小范围。

6.2 方法指令跳错地方:重定向失败

重组后的dex如果出现验证错误或崩溃,最常见的原因就是指令里的跳转偏移没有正确修复。因为dex的指令集不允许直接用绝对地址跳转,而是基于当前指令位置的相对偏移。重组改变了指令位置,就意味着所有branch、switch、exception handler的偏移都要重新计算。

我自己常用的排查方式是把崩溃地址转换成文件偏移,再回到hexdump里看当前位置的指令是什么。如果是goto或if-eqz这类跳转指令,一步一步算过去,看看它跳到的位置是不是一个合法指令的开头。

6.3 app跑了三五分钟才崩溃:这是定时自校验

如果你发现重组dex在app启动后能正常跑很久,但过了一阵子突然闪退,大概率是DexProtector的定时自校验线程在工作。它会周期性地比对内存中的dex内容和原始文件hash,一旦发现不一致就退出。

应对思路是让dump出的dex内容与“原文件”在hash层面保持一致。这在实际操作中很难做到,因为加固后的完整dex本来就不以文件形式存在。退一步讲,在授权测试中,我们通常会直接关掉自校验线程(找到线程入口并suspend),而不是试图去瞒hash。这需要更深的native层逆向功底,也再一次印证了DexProtector的防御重心是“耗时间”而不是“拦不住”。

6.4 绕过一层绕过不了一层:多层检测叠起来才是真麻烦

DexProtector的防护不是只有内存态匿名段检测这一层,它还有防调试、防Hook、完整性校验、模拟器检测等。单独拿出某一层都能想办法“骗过去”,但所有层叠在一起,就不停地逼迫你必须在很短的窗口内完成dump和重组,还要保持动静够小。

这一点给我的实际感受是:与其研究如何绕过,不如认真研究它的检测原理,顺势调整自己的代码设计和业务逻辑。对加固方案评估来说,把“检测点在哪里”“检测逻辑怎么触发”“触发后有什么反应”搞清楚,比单纯“干穿它”更有价值。

7. 写在后面的个人体会

这套实验做下来,我最大的体会是:内存态匿名段检测不是一个孤立的“小技巧”,它背后反映的是Android运行时加载机制的一个基本事实——所有的逻辑最终都要变成内存里的数据,而内存里的数据永远不可能完全隐形。加固工具能做的,只是让这些数据变得难以辨认、难以提取、难以重放。而对抗侧能做的,也只是在这个基础上一点一点缩小误差、补齐结构。

如果你也在做类似的移动安全研究,我的建议是不要贪快,先把dex文件格式、Art运行时加载流程、内存映射机制这些基础啃扎实。工具可以帮你省时间,但只有基础够牢,遇到新思路、新检测点的时候,你才不会慌。另外再强调一句:所有实验都请在授权和合法的范围内进行,多用自己写的demo app反复验证,这才是能长期走这条路的安全姿势。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 6:15:18

ePWM与SDFM同步配置详解:从寄存器到Driverlib的电流采样对齐实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:15:10

东华考研OJ进阶篇:并查集、Dijkstra、LIS与拓扑排序实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:15:03

工业嵌入式存储选型:MRAM与STM32F373RC的SPI驱动实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:14:47

用hybrid混合势函数跑通FeCMnSiTi五元合金分子动力学模拟

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:14:03

OpenCV车牌识别课程设计:从HSV分割到GUI调试的完整实战系统

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:13:58

PBR核心BRDF与Cook-Torrance模型从原理到Shader实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华