↓ 跳过正文

浏览器新建 Profile 就自动多出一个扩展:Windows 注册表外部安装机制的排查与清除

·8337 字·17 分钟
目录

症状:浏览器里把某个扩展删掉,事后新建一个 profile 它又自己回来了;而亲手删过的那份 profile 反而不再复发。这篇记录完整排查过程——从 Windows 注册表里定位到四处外部安装项,用注册表键的 LastWriteTime 反推写入时刻并与文件系统时间戳互证,再解包客户端的 Electron app.asar、反查同厂商另一款产品的配置工具二进制,最后确认厂商没有提供任何关闭这一行为的开关,给出的处置是可回滚的删除加一次实测验证。适用环境:Windows 11(shell 为 git-bash)、Edge 与 Brave(本机未装 Chrome),时间跨度 2026-09-12 至 2026-09-25。

免责声明:本文只讨论机制与处置方法。客户端名称、安装器二进制名、配置工具主程序名、扩展 ID、扩展包文件名、注册表路径中的产品段,一律替换为占位符,不影响方法本身的可复用性。

一、症状与第一手数据
#

现象是一句话能说清的:装过一个第三方办公客户端之后,浏览器里会自己长出一个「网页收藏/剪藏」类扩展;手动删掉不再复发,可新建的 profile 里又会冒出来。它不是商店安装,扩展管理页里也看不到「来自应用商店」那种来源标注。

先把三个浏览器的实际状态摊开(这一步决定了后面往哪里挖):

浏览器 该扩展是否存在 来源标注 备注
Edge(Default) 存在,版本 2.0.7 location = 3 无用户交互痕迹
Brave(Default) 不存在 —— 但 external_uninstalls 里记着该扩展 ID
Chrome 未安装 —— User Data 目录都不存在

三点值得注意:

一是 location = 3 这个值很关键。Chromium 的 Manifest::Location 枚举里,3 是 EXTERNAL_REGISTRY,含义是「由 Windows 注册表指定的外部安装」——与商店安装(from_webstore = true)、命令行装载(UNPACKED)都不是一回事。换成一句判断:这不是有人在商店点了安装,而是注册表里写着「请装上它」。

二是 Brave 那一栏的「不存在」有很强的误导性。它的 Preferences 里 extensions.external_uninstalls 正列着该扩展的 ID——这个字段的语义是「外部安装进来、又被用户手动卸掉」的扩展,也就是说 Brave 同样中过招,只是已被人工移除,并且浏览器记住了这件事。第一轮排查如果只看「现在装没装」,会得出「Brave 免疫」的错误结论。

三是 Chrome 没装,但注册表项照样在,装上就会中。

读这个状态要同时看两份文件——扩展注册在 Secure Preferences(Preferences 里只有不受保护的 install_signature.ids),卸载记忆在 Preferences:

import json, os

ud = os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Edge\User Data\Default")

secure = json.load(open(os.path.join(ud, "Secure Preferences"), encoding="utf-8"))
prefs = json.load(open(os.path.join(ud, "Preferences"), encoding="utf-8"))

for eid, meta in secure["extensions"]["settings"].items():
    m = meta.get("manifest") or {}
    if m.get("name"):
        print(eid, "| location =", meta.get("location"), "|", m.get("name"))

print("外部安装后被用户卸载的记录:",
      prefs["extensions"].get("external_uninstalls"))

二、第一轮:把注册表全量扫一遍,别只查 Chrome
#

最容易踩的坑是「只查一个浏览器、只查一个视图」。Windows 上 Chromium 系的外部安装点按厂商分路径,而 32 位安装器写进去的内容落在 WOW6432Node(32 位视图)——只按默认视图查 SOFTWARE\Google\Chrome\Extensions,会得出「Chrome 里只有 IDM 一个扩展,没有异常」的结论,而真正生效的那一条就藏在旁边。

所以扫描面铺满四维:品牌路径 × HKLM/HKCU × 32/64 位视图。品牌路径至少要覆盖 Chrome、Edge、Brave、Chromium、360(安全/极速)、QQ、搜狗、Vivaldi、Opera、Yandex——本机只有装了的那几个会命中,但漏检的代价是把结论搞反。

扫出来的结果(产品段与 ID 以占位符呈现):

表体 键 值 视图
HKLM SOFTWARE\Google\Chrome\Extensions\<Chrome 版扩展 ID> path = <安装目录>\extensions\<扩展包>_chrome.crx,version = 2.0.5 32 位(WOW6432Node)
HKLM SOFTWARE\Microsoft\Edge\Extensions\<Edge 版扩展 ID> path = <安装目录>\extensions\<扩展包>_edge.crx,version = 2.0.5 32 位(WOW6432Node)
HKCU Software\Google\Chrome\Extensions\<Chrome 版扩展 ID> 同上 ——
HKCU Software\Microsoft\Edge\Extensions\<Edge 版扩展 ID> 同上 ——

同一轮里还顺手排除了另一个可能:如果是管理员用组策略强推,会在 SOFTWARE\Policies\<品牌>\ExtensionInstallForcelist 里留编号项。实测这四个位置的 Policies 分支下只有遥测关闭类的自定义项,没有 Forcelist——也就是说这不是组策略,是普通的外部安装点,用户侧没有任何提示。

顺带把同机制的正常项也认了出来,避免误伤:一条是某下载器的浏览器集成扩展(值为 path 指向该下载器目录下的 .crx),另两条是 Microsoft Power Automate(值为 update_url,from_webstore = true,location = 6)。同一张注册表里既有该留的也有该清的,靠 path 指向哪里来区分,而不是靠「有没有」。

三、这条机制到底怎么运作
#

Chromium 在 Windows 上有一类「外部扩展」来源:浏览器启动(以及新建 profile)时扫描注册表里属于自己品牌的 Extensions 键,HKEY_LOCAL_MACHINE\SOFTWARE\... 与 32 位视图 HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\... 都会读。子键名就是扩展 ID,键下的 path 指向一个本地 .crx 文件(或 update_url 指向更新源),浏览器据此静默安装——不弹确认框、不需要用户点「添加扩展程序」。

但「静默安装」不等于「每次启动都重装」。这里有一个按 profile 生效的例外,也是本次排查里最关键的一条:

浏览器对「外部安装进来、又被用户手动卸掉」的扩展会留一份名单,存在该 profile 的 Preferences 里,键名就是 extensions.external_uninstalls。Chromium 源码对这个接口的注释写得很直白——IsExternalExtensionUninstalled():「Returns true if the specified external extension was uninstalled by the user.」而决定一个扩展能否装载启用的 ExtensionRegistrar::IsExtensionEnabled(),判断条件之一正是它:

return !extension_prefs_->IsExtensionDisabled(extension_id) &&
       !blocklist_prefs::IsExtensionBlocklisted(extension_id, extension_prefs_) &&
       !extension_prefs_->IsExternalExtensionUninstalled(extension_id);

三条叠起来,症状就全对上了:

  1. 新建 profile 立刻中招:外部安装点是机器级的,而全新 profile 里没有任何卸载记忆,扫描到就直接装。
  2. 手动删过的 profile 反而安静:删的那一下被记进 external_uninstalls,之后不再自动装回(该扩展若后来被重新装上,这条记录会被清掉)。
  3. 删扩展不解决问题:删掉的只是扩展本体和该 profile 的记录,注册表里那份「外部来源」还在——对新建的 profile、同一台机器上的其他账户、重装系统后的环境继续生效。

还有第四条,是这次差点写错的一条:「换个浏览器就没事」不成立。Software\Google\Chrome\Extensions 这个名字有误导性,它不是「Chrome 专用」——Chromium 把 Windows 外部扩展加载器的注册表键写死在代码里,Brave 这类下游直接沿用。本机有两个旁证:一是 Brave 自己的 external_uninstalls 里,同时记着另一款只写 Google 路径的外部扩展(就是上面那个下载器集成扩展)的 ID;二是 Brave 品牌自己的 SOFTWARE\BraveSoftware\Brave-Browser\Extensions 下空无一物,而 Brave 确实被装进过。这个「沿用 Google 键」的行为在 Brave 的 issue 区本身就是被当作问题提过的。

所以判据不能是「哪个浏览器受影响」,只能是「这个 profile 里有没有那份卸载记录」。

参考:

需要说明的是,HKCU\Software\Google\Chrome\Extensions 这两条并不在 Chromium 读外部安装项的路径上,属于写入方多写的一份冗余;但既然是「被配置过」的证据,清理时一并删掉更干净。

四、写入者溯源:注册表时间戳 × 文件系统 mtime
#

到这一步只知道「注册表被写了」,还不知道「谁写的、什么时候写的」。注册表键本身带 LastWriteTime,标准库里 winreg 不暴露它,但可以用 ctypes 直接调 RegQueryInfoKeyW 取回来。四个键的结果完全一致:

2026-09-12 20:58:43   HKLM\SOFTWARE\Google\Chrome\Extensions\<Chrome 版扩展 ID>      (32 位视图)
2026-09-12 20:58:43   HKLM\SOFTWARE\Microsoft\Edge\Extensions\<Edge 版扩展 ID>       (32 位视图)
2026-09-12 20:58:43   HKCU\Software\Google\Chrome\Extensions\<Chrome 版扩展 ID>
2026-09-12 20:58:43   HKCU\Software\Microsoft\Edge\Extensions\<Edge 版扩展 ID>

四处同秒,说明是一次批量写入,而不是浏览器运行时反复写的。

再把这个时刻和文件系统对一遍。被 path 指向的那个 .crx 所在的安装目录,目录时间为 2026-09-12 20:58:42,其 extensions\ 子目录为 20:58:39——注册表写入与安装目录的落盘在同一秒级窗口内。目录下三个文件并列摆着,对应三个浏览器品牌:

extensions\<扩展包>_chrome.crx
extensions\<扩展包>_edge.crx
extensions\<扩展包>_360se.crx

到这一步链条已经闭合:某个第三方办公客户端的安装/升级动作,把三份 .crx 放进了自己的 extensions\ 目录,并往注册表写入了 Chrome 与 Edge 两个品牌的路径(第三个品牌那份写了文件,但没有对应的注册表项)。

同时能确认的是这不是运行时的持续行为:注册表写入时刻是唯一的一次,之后没有任何新增;写入者进程当时也不在运行。

五、找官方关闭入口:公开信息与本地二进制两条线都落空
#

需求里还有一半是「能不能在软件自己的设置里关掉它」。两条线并行查。

5.1 公开信息:没有
#

中文社区(厂商官方社区、问答站、博客)关于这个扩展的讨论量不小,但全部是「怎么手动移除」「为什么收藏失败」这类,没有任何一条是「怎么阻止它被自动安装」,也没有官方说明或勘误。

搜索时容易踩的一个坑:拿这个问题去问通用搜索的 AI 摘要,它会热情地给出「进配置工具 → 高级 → 取消勾选后台服务/热点推荐」这类步骤。那些是同一厂商另一条产品线的通用广告开关,与注册表外部安装项无关;照着点一遍,注册表里的键纹丝不动。这类答案要用「改完再回读注册表」来证伪,不能只看步骤像不像。

5.2 本地二进制:有相关代码,但不是开关
#

客户端是 Electron 应用,资源包 resources\app.asar 是未压缩的,可以直接在字节里搜目标扩展 ID——命中位置就是商店链接与埋点。解包后主进程代码里,全仓库唯一一处会去执行安装器的地方是这一段(变量名与路径段照原样,仅替换了二进制名):

var Bh = "fileAssociation";
function AR(e) {
  let t = ko.app.getAppPath(), r = path.join(t, "../../<安装器>.exe");
  exec(`"${r}" ${e ? "-associate" : "-dissociate"}`);
}
async function IR(e, t) {
  switch (e) {
    case Ms: /* openAtLogin */ break;
    case Bh: AR(t); break;          // 设置项 fileAssociation 变更 → 装/卸关联
    case vc: /* asyncTab   */ break;
  }
}

顺着 fileAssociation 找 UI,它在客户端的「设置 → 系统设置」里,界面上写的标签是**「文件格式关联」**,副标题是「默认使用<该客户端>打开本地文件」,勾选框下面还列了支持接管的 docx/xlsx/pptx/pdf/ofd 后缀。它旁边另有一个弹窗叫「开启浏览器网页收藏插件」,但那个弹窗的按钮只是跳到商店页面做引导安装,不写注册表。

那 <安装器>.exe -associate 究竟会不会连浏览器一起关联?把它的字符串过一遍:命中的全是 COM 注册与文件关联相关的符号——CompReg、IRegistrarBase、RegisterAll、DllRegisterServer / DllUnregisterServer、AssocCreateForClasses、SHAssocEnumHandlers、RegCreateKeyExW / RegSetValueExW,以及一条编译期路径 C:\work\<product>_pc\builder\win\<安装器>.pdb。没有 Google\Chrome、没有 Microsoft\Edge、没有任何浏览器品牌路径(该文件未加壳,.text 段 rawsize 与 vsize 基本相等,另有 .detourd / .detourc 两个 Detours 段,静态字符串可信)。

结论只能给到这一步:「文件格式关联」这个开关绑定的确实是那个安装器,但安装器自身不含浏览器扩展路径,因此无法认定它能清掉浏览器外部安装项。 不把它当作关闭入口推荐,比推荐一个没验证过、点了还留下键的开关要诚实。

5.3 同厂商另一款产品:一个需要排除的干扰项
#

同一厂商还有另一款办公套件,其配置工具主程序里确实有 handle_installBrowserExtension、-installbrowserextensionhost、<厂商>.chrome.extension.host.json 这些串。但那是给浏览器扩展配的 native messaging 宿主(扩展通过它调用本机客户端),不是扩展本身的安装点;而且这台机器上该套件目录下没有任何 .crx,注册表里也没有它写入的任何浏览器扩展项。

「同一家公司的另一款产品」是个需要排除的干扰源,排除依据是文件与注册表,不是直觉。

5.4 小结
#

  • 公开信息里不存在针对这一自动安装行为的关闭开关。
  • 客户端设置里能绑定到安装器的那个开关(「文件格式关联」)描述的是别的事,且无法验证其效果。
  • 能真正断掉它的只有:删外部安装项、卸载带来它的那个客户端、或在浏览器侧屏蔽。

六、清除与验证
#

6.1 先备份,再删
#

四个键全部导出成 .reg(导出父键会连正常项一起带上,回滚时一起还原,语义上更接近「回到原样」):

reg export 'HKLM\SOFTWARE\WOW6432Node\Google\Chrome\Extensions'  chrome_HKLM.reg /y
reg export 'HKLM\SOFTWARE\WOW6432Node\Microsoft\Edge\Extensions' edge_HKLM.reg   /y
reg export 'HKCU\Software\Google\Chrome\Extensions'              chrome_HKCU.reg /y
reg export 'HKCU\Software\Microsoft\Edge\Extensions'             edge_HKCU.reg   /y

删除 HKLM 下的键需要管理员权限。用 PowerShell 走一次 UAC 提权,脚本把每条键的结果(DELETED / ABSENT / FAILED)写进日志,避免「以为删了」。

6.2 验证要分两层
#

第一层是注册表回读:HKLM\SOFTWARE\WOW6432Node\Microsoft\Edge\Extensions 变空,...\Google\Chrome\Extensions 只剩下载器那一条,HKCU 下只剩 Power Automate 与下载器。四个目标键全部消失。

第二层是浏览器侧是不是真被卸掉了——删注册表项只是断掉来源,已装扩展本体还在。这里不能靠推断,实测一次:干脆把 Edge 启动一遍(当时没有别的 Edge 实例在跑),等约 20 秒再关掉,然后看两处:

检查点 启动前 启动后
Extensions\<扩展 ID>\2.0.7_0 本体目录 存在 已移除
Secure Preferences 里该 ID 的条目 完整条目(含 manifest / path / location) 只剩一个 edge_last_update_check_time,无 manifest、无 path
该 profile 里 location = 3 的条目 含该扩展 只剩下载器扩展

也就是说浏览器启动时发现外部来源没了,把扩展本体一并卸载了——这条验证同时反证了机制判断:如果它不是注册表外部安装,来源消失不会触发自动卸载。

顺带一个 git-bash 的坑:在这套环境的 bash 里 taskkill //IM msedge.exe /F 不会把 //IM 转换成 /IM,会直接报「无效参数」;用 powershell -Command "Get-Process msedge | Stop-Process -Force" 更省事。

6.3 结果
#

清理后复查:四个键全部不存在,浏览器侧扩展本体已移除。Brave 那边本来就没有装着的实例(那份 profile 早已被手动卸干净、并因此留下了卸载记录),但这不代表它不受影响——如第三节所述,它读的是同一个键,同一台机器上的新 profile 一样会中招。此外,这台机器上该客户端本身也已被卸载——安装目录、卸载登记项、相关进程三者皆无,卸载后注册表项没有复发。

七、可复用的判据
#

观察到的现象 指向的结论
扩展在 Secure Preferences 里 location = 3 外部安装(注册表或偏好文件),非商店安装
扩展在 location = 6 且 from_webstore = true 商店安装,属正常
注册表键值带 path = 本地 .crx 外部安装点,尚未被手动卸载过的 profile 会被补装
注册表键值带 update_url 外部安装点,从远端拉取
Preferences 的 external_uninstalls 里列着该 ID 曾外部装过并被手动卸载;该 profile 不会自己复发,新 profile 会
SOFTWARE\Policies\<品牌>\ExtensionInstallForcelist 有内容 管理员强推,处置方式不同(要改策略或域)
只查了一个浏览器 / 一个视图就没发现问题 结论不成立,32 位写入方在 WOW6432Node
「换成 Brave 就没事」 不成立:Brave 读的就是 Software\Google\Chrome\Extensions
注册表键 LastWriteTime 同秒且与某个安装目录 mtime 吻合 可定位到具体是哪次安装写进去的

复发风险与兜底:外部安装项由客户端的安装/升级动作写入,下次升级或重装可能重新写回来。两条兜底——一是周期性回读这四个键(可挂到已有的巡检任务里,只在复发时告警);二是在浏览器侧加策略 ExtensionInstallBlocklist 屏蔽这两个扩展 ID,这样即使注册表被重写,浏览器也会拒绝安装。注意后者是「拒绝安装」,与「清理干净」互补而不是互相替代。

八、碎碎念
#

这次排查里最值钱的一步不是找到注册表项,而是没有停在「找到了、删掉了」。

如果在第五节看到 fileAssociation 绑定 <安装器>.exe -associate 就收工,写出来的结论会是「关掉设置里的文件格式关联即可」——听起来像是为读者省事,实际上把读者送进一个点了不生效的开关。多花的成本只是把 <安装器>.exe 的字符串过一遍,而这一步正好能把结论从「应该可以」推翻成「无法认定」。

第四节的时间戳互证同理:只凭「path 指向某个产品的安装目录」就点它的名,是拿猜测当结论;注册表写入与安装目录落盘同秒才是证据。这类取证用标准库加大约二十行 ctypes 就能做完,比装一堆工具快。

还有一个更隐蔽的教训,来自「哪个浏览器受影响」这个判断。三份证据摆在面前时,很容易顺手写成「Brave 不读那个键所以免疫」——这个归因很顺,但它是错的,而且错得不容易被发现:Brave 当下确实没装。真正区分开两种情况的,是一个平时没人会点开的偏好项 external_uninstalls。「结果看起来一样」的两台浏览器、两份 profile,可能走在两条完全不同的分支上;判断状态时要找那个能区分分支的字段,而不是拿最终现象去反推原因。

还有一条不算技术、但同样值得记:匿名化要按「可检索性」去做,不是按「看起来显眼」去做。 品牌名一眼就能看见,容易记得替换;真正会漏的是那些不起眼的标识符——扩展 ID、.crx 文件名、二进制名、native host 名、编译期路径里的产品段。它们看着像随机噪声,丢进搜索引擎却直达产品页。判断标准很简单:这个字符串拿去搜一下,会不会指向它?

最后一句坦白:本文明说「不存在官方开关」,严格讲只能证明「在公开信息与本机二进制里都没找到」。要把它升级成「厂商确实没有实现」,需要能看到服务端下发或运行时行为的证据,那超出单机取证的范围。把结论的边界写清楚,比把结论写得肯定更有用。


scripts/scan_ext_reg.py(注册表外部安装点全量扫描,只读)
# 扫描 Windows 注册表里的浏览器外部扩展安装点(只读,不改任何东西)
import winreg

VENDORS = {
    "Chrome":   [r"SOFTWARE\Google\Chrome"],
    "Edge":     [r"SOFTWARE\Microsoft\Edge"],
    "Brave":    [r"SOFTWARE\BraveSoftware\Brave-Browser"],
    "Chromium": [r"SOFTWARE\Chromium"],
    "360":      [r"SOFTWARE\360Chrome", r"SOFTWARE\360ChromeX", r"SOFTWARE\360se6"],
    "QQ":       [r"SOFTWARE\Tencent\QQBrowser"],
    "Sogou":    [r"SOFTWARE\SogouExplorer"],
    "Vivaldi":  [r"SOFTWARE\Vivaldi"],
    "Opera":    [r"SOFTWARE\Opera Software"],
    "Yandex":   [r"SOFTWARE\Yandex\YandexBrowser"],
}

# 关键:32 位安装器写的是 WOW6432Node,两个视图都必须查,否则漏掉一半。
HIVES = [("HKLM", winreg.HKEY_LOCAL_MACHINE), ("HKCU", winreg.HKEY_CURRENT_USER)]
VIEWS = [("64", winreg.KEY_WOW64_64KEY), ("32", winreg.KEY_WOW64_32KEY)]

# 组策略强推的判据,单独看一眼
POLICY = ["SOFTWARE\\Policies\\Google\\Chrome", "SOFTWARE\\Policies\\Microsoft\\Edge"]


def subkeys(root, path, view):
    out = []
    try:
        k = winreg.OpenKey(root, path, 0, winreg.KEY_READ | view)
    except OSError:
        return out
    i = 0
    while True:
        try:
            name = winreg.EnumKey(k, i)
        except OSError:
            break
        out.append((name, path + "\\" + name))
        i += 1
    winreg.CloseKey(k)
    return out


def values(root, path, view):
    d = {}
    try:
        k = winreg.OpenKey(root, path, 0, winreg.KEY_READ | view)
    except OSError:
        return d
    i = 0
    while True:
        try:
            n, v, _ = winreg.EnumValue(k, i)
        except OSError:
            break
        d[n] = v
        i += 1
    winreg.CloseKey(k)
    return d


print("=== 1. 各品牌的外部扩展安装点 ===")
for label, paths in VENDORS.items():
    for hive_name, hive in HIVES:
        for view_name, view in VIEWS:
            for p in paths:
                for sub, full in subkeys(hive, p, view):
                    if sub.lower() != "extensions":
                        continue
                    for ext_id, key in subkeys(hive, full, view):
                        print(f"[{label}][{hive_name}/{view_name}] {key}")
                        print(f"      {values(hive, key, view)}")

print()
print("=== 2. 组策略强推(ExtensionInstallForcelist)===")
for hive_name, hive in HIVES:
    for view_name, view in VIEWS:
        for root_p in POLICY:
            for sub, full in subkeys(hive, root_p, view):
                v = values(hive, full, view)
                if v:
                    print(f"[{hive_name}/{view_name}] {full} -> {v}")
scripts/reg_lastwrite.py(读注册表键的 LastWriteTime,定位写入时刻)
# winreg 不暴露 LastWriteTime,用 ctypes 直接问 RegQueryInfoKeyW
import ctypes
import ctypes.wintypes as w
import datetime

advapi = ctypes.WinDLL("advapi32", use_last_error=True)

HKLM = 0x80000002
HKCU = 0x80000001
KEY_READ = 0x20019
KEY_WOW64_32KEY = 0x0200
KEY_WOW64_64KEY = 0x0100


class FILETIME(ctypes.Structure):
    _fields_ = [("dwLowDateTime", w.DWORD), ("dwHighDateTime", w.DWORD)]


def to_datetime(ft):
    v = (ft.dwHighDateTime << 32) | ft.dwLowDateTime
    if v == 0:
        return None
    # FILETIME 是 UTC,输出时换成本地时刻便于和资源管理器的 mtime 对齐
    return datetime.datetime(1601, 1, 1) + datetime.timedelta(microseconds=v / 10)


def lastwrite(path, view=KEY_WOW64_32KEY, hive=HKLM):
    h = w.HKEY()
    if advapi.RegOpenKeyExW(hive, path, 0, KEY_READ | view, ctypes.byref(h)) != 0:
        return None
    subkeys, values, lw = w.DWORD(), w.DWORD(), FILETIME()
    advapi.RegQueryInfoKeyW(h, None, None, None, ctypes.byref(subkeys), None, None,
                            ctypes.byref(values), None, None, None, ctypes.byref(lw))
    advapi.RegCloseKey(h)
    return to_datetime(lw)


TARGETS = [
    (r"SOFTWARE\Google\Chrome\Extensions\<Chrome 版扩展 ID>", KEY_WOW64_32KEY, HKLM),
    (r"SOFTWARE\Microsoft\Edge\Extensions\<Edge 版扩展 ID>", KEY_WOW64_32KEY, HKLM),
    (r"Software\Google\Chrome\Extensions\<Chrome 版扩展 ID>", 0, HKCU),
    (r"Software\Microsoft\Edge\Extensions\<Edge 版扩展 ID>", 0, HKCU),
]

for path, view, hive in TARGETS:
    t = lastwrite(path, view, hive)
    tag = "HKLM" if hive == HKLM else "HKCU"
    # FILETIME 为 UTC,加 8 小时得到东八区时刻
    print(f"{tag} {path} -> {t and (t + datetime.timedelta(hours=8))}")
scripts/asar_extract.py(纯 Python 解包 Electron app.asar,不需要 node/npx)
# 解包 Electron 的 app.asar:asar 是「头部 JSON + 拼接的文件内容」,
# 不压缩也不加密,标准库足够,无需装 node/npx 的 asar 工具。
import json
import os
import struct
import sys

SRC = sys.argv[1] if len(sys.argv) > 1 else os.path.expandvars(
    r"%PROGRAMFILES(X86)%\<客户端目录>\resources\app.asar")
OUT = os.path.join(os.environ.get("TEMP", "."), "asar_out")

data = open(SRC, "rb").read()

# 头部 JSON 以 {"files": 开头,它前面 4 字节是 JSON 长度
i = data.find(b'{"files":')
if i < 0:
    raise SystemExit("未找到 asar 头部")
json_len = struct.unpack("<I", data[i - 4:i])[0]
header = json.loads(data[i:i + json_len].decode("utf-8"))

# 内容区起点 = 8 字节前缀 + 头部 pickle 大小(4 字节对齐)
base = 8 + struct.unpack("<I", data[4:8])[0]


def walk(node, path):
    files = node.get("files")
    if files:
        for name, meta in files.items():
            walk(meta, path + "/" + name)
        return
    off = node.get("offset")
    if off is None or node.get("unpacked"):
        return
    dest = os.path.join(OUT, path.lstrip("/"))
    os.makedirs(os.path.dirname(dest), exist_ok=True)
    with open(dest, "wb") as f:
        f.write(data[base + int(off): base + int(off) + node.get("size", 0)])


os.makedirs(OUT, exist_ok=True)
walk(header, "")
print("已解包到", OUT)
scripts/clean-forced-extensions.ps1(提权删除外部安装项,可回滚)
# 删除浏览器外部扩展安装项。用法:以管理员身份运行,或由提权包装调用。
# 回滚:双击运行删除前导出的 .reg 文件即可。
$ErrorActionPreference = 'Continue'
$log = Join-Path $PSScriptRoot 'clean.log'

function Log($m) { $m | Out-File -FilePath $log -Append -Encoding utf8 }

'=== cleanup run ' + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ===' |
    Out-File -FilePath $log -Encoding utf8
'IsAdmin = ' + ([Security.Principal.WindowsPrincipal] `
    [Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) |
    Out-File -FilePath $log -Append -Encoding utf8

$keys = @(
    'HKLM:\SOFTWARE\WOW6432Node\Google\Chrome\Extensions\<Chrome 版扩展 ID>',
    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Edge\Extensions\<Edge 版扩展 ID>',
    'HKLM:\SOFTWARE\Google\Chrome\Extensions\<Chrome 版扩展 ID>',
    'HKLM:\SOFTWARE\Microsoft\Edge\Extensions\<Edge 版扩展 ID>',
    'HKCU:\Software\Google\Chrome\Extensions\<Chrome 版扩展 ID>',
    'HKCU:\Software\Microsoft\Edge\Extensions\<Edge 版扩展 ID>'
)

foreach ($k in $keys) {
    if (Test-Path -LiteralPath $k) {
        try {
            Remove-Item -LiteralPath $k -Recurse -Force -ErrorAction Stop
            Log ("DELETED : " + $k)
        } catch {
            Log ("FAILED  : " + $k + "  ->  " + $_.Exception.Message)
        }
    } else {
        Log ("ABSENT  : " + $k)
    }
}

Log '--- verify ---'
foreach ($k in $keys) { Log ($k + '  exists=' + (Test-Path -LiteralPath $k)) }
Log '=== done ==='