练手redext扩展插件-地址劫持&密码劫持

Posted by Closure on November 13, 2025

为什么选择基于redext写

新增版本

普通的自定义恶意插件功能太单一了() 只有一个Keylogger或者将Cookie发送到Discord Webhook 的脚本,RedExt是一个完整的C2框架;且普通插件几乎都是Fire and Forget,死板地把数据发出去,如果没有回连机制就无法控制它停止 休眠 更改目标。(还有一个方便操作的后端面板和Agent

现在很多旧的红队工具还基于Manifest V2,但是已经Chrome强制推行Manifest V3。

MV2&MV2

MV2的Background Page拥有DOM且理论上可以永远在后台运行的隐藏Tab,只要浏览器开着恶意代码就在跑,并且可以把窃取的东西一直存在全局变量里,等到合适的时机再传回C2,且如果后台页面有window和document对象,可以直接在这里解析窃取来的html页面,提取 CSRF Token。

到了MV3就改成了事件驱动和Ephemeral,并且浏览器认为没事干就会把它杀掉,且无 DOM 访问权,无法维持长连接,必须依赖复杂的Alarm API来保活,而且全局变量随时会被清空,数据必须写入落盘。

Dropper的终结是MV3改的最严的,MV2允许CSP开启unsafe-eval和加载远程脚本,所以当时的大多数恶意插件是上架一个干干净净的插件,安装后插件从C2下载一段恶意的JS或者直接更改攻击逻辑,不需要重新通过Google审核。

MV3基于上面的,绝对禁止加载插件包以外的 JS 代码,禁止动态执行机制,所有代码必须包含在安装包内,所以导致Payload必须硬编码和没有办法动态下发新的exploit,只能通过下发配置数据来控制已有的逻辑。

还有流量劫持权的收回,在MV2是可以拦截每一个网络请求,可以暂停它和修改 Header,修改Response Body然后放行,所以在浏览器内部就完美实施MITM。MV3不再直接接触请求数据,扩展只能向Chrome 提交RuleSet。

这个项目遵循了MV3的事件驱动模型,解决了短暂和无状态的问题是使用 chrome.alarms API维持保活,以及在 background.js中没有尝试维持一个长连接,而是创建一个周期性的闹钟来轮询 C2。

chrome.runtime.onInstalled.addListener(() => {
               chrome.alarms.create('poll_c2', { periodInMinutes: 1 });
             });
     
             // When the alarm fires, poll the C2 for commands
             chrome.alarms.onAlarm.addListener((alarm) => {
               if (alarm.name === 'poll_c2') {
                 fetchCommands();
              }
            });

Service Worker在大部分时间是休眠的,只有在onAlarm 事件触发时才被唤醒去执行 fetchCommands函数,执行完毕后再次休眠。

最核心的iframe状态隔离问题解决方案正是 MV3模式下的标准做法,没有依赖随时可能被清空的全局变量。

// The 'input' handler writes credentials to disk storage in real-time
             function handleInput(event) {
                 // ... logic to identify field ...
                 chrome.storage.local.get(STORAGE_KEY, (data) => {
                     const credentials = data[STORAGE_KEY] || {};
                     credentials[name] = target.value;
                     chrome.storage.local.set({ [STORAGE_KEY]: credentials });
                 });
             }
    
            // The 'click' handler reads from disk storage to exfiltrate
            function handleClick(event) {
                chrome.storage.local.get(STORAGE_KEY, (data) => {
                    const capturedCredentials = data[STORAGE_KEY];
                    if (capturedCredentials) {
                        // ... send message to background.js ...
                        chrome.storage.local.remove(STORAGE_KEY); // Clear storage
                    }
                });
            }

这解决了两个问题,一是保证了即使Service Worker被终止捕获到的数据也不会丢失,二是允许在不同 iframe中运行的 状态隔离的内容脚本实例通过一个共同持久化的存储来共享数据。

无DOM访问这个问题,我们的background.js没有尝试访问任何DOM,所有需要DOM操作的任务都通过消息传递委托给了content_script.js,后者运行在页面上下文中拥有DOM访问权。这是一个标准的 MV3 架构模式。

就像上面说的,MV2允许加载远程脚本和unsafe-eval,可以动态下发恶意代码,MV3 禁止加载外部JS,所有代码必须包含在安装包内,Payload必须硬编码,只能通过下发配置数据来控制已有逻辑。

RedExt项目的解决方案是让C2只下发配置数据,而不是代码,C2 服务器的 /api/commands 端点返回的不是JS代码,而是一个JSON对象,比如[{ “type”: “form_submit_capture”, “payload”: {“domains”:[“example.com”]} }]

这个JSON对象仅是指令和参数,告诉扩展应该执行哪个已经存在的功能以及如何配置该功能。

background在收到C2指令后,使用一个switch或if/else 结构来解析指令的 type 字段,然后执行一个早已打包在扩展内部的函数。

function handleCommand(command) {
               switch (command.type) {
                 case 'form_submit_capture':
                   // Save the config to storage and notify content scripts
                   chrome.storage.local.set({ form_capture_config: command.payload });
                   notifyAllTabs({ command: 'update_form_capture_config' });
                   break;
                 case 'replace_crypto':
                   // Save new crypto addresses to storage
                  chrome.storage.local.set({ crypto_config: command.payload });
                  break;
                // ... other cases for other hardcoded features
              }
            }

这其实是完全符合MV3的安全模型,攻击者无法通过C2注入新的击代码,只激活或配置那些在插件审核时就已经存在于代码库的功能。

还是之前说的,MV2可以通过webRequestAPI来拦截 暂停和修改请求与响应,实现浏览器内 MITM。MV3收回了这个权限改为用 declarativeNetRequest API提交静态规则集。

RedExt项目从一开始就没有打算在网络层进行攻击,因为攻击发生在DOM层,而非网络层。

我写的新功能凭据收割不是在拦截登录时发出的post网络请求,而是在content_script.js中监听 input 和 click DOM 事件,在数据被打包成网络请求之前就直接从页面的输入框中把它偷走。

新功能剪贴板劫持同样是clipboard_hijacker.js,监听的是copy这个DOM事件,在用户按下 那一刻修改剪贴板数据⬅️这是发生在任何网络请求之前的。

replace_crypto

视频版demo在这里

这个功能做出来的目标是在用户复制加密货币地址时替换为自己指定的地址,币种现在只有三个常用的。在C2上创建一个新任务,在参数部分输入一个JSON对象包含要替换的地址。

后台Service Worker里background.js中的beaconToC2函数会定期轮询C2的/api/commands 端点,当它获取到replace_crypto指令后,handleCommand函数被调用,在 handleCommand 函数内部,case ‘replace_crypto’ 分支被触发。

它将C2发来 地址配置通过chrome.storage.local.set({crypto_replace_config:configPayload}) 保存到浏览器的本地存储中,配置存储后,调用broadcastMessage({command:’update_crypto_config’}),向所有打开的标签页中的内容脚本广播一条消息,通知它们配置已更新。

接下来内容脚本的注入与初始化,根据我们manifest.json的配置,”js”: [“content_script.js”]会被注入到所有页面,但是这里的clipboard_hijacker.js不是通过manifest.json直接注入的。真正的注入发生在background.js中,chrome.tabs.onUpdated事件监听器确保了每当一个页面加载或更新完成时,injectClipboardScript(tabId)就会被调用,通过 chrome.scripting.executeScript 动态地将 clipboard_hijacker.js注入到页面中。这使得劫持逻辑更加灵活和隐蔽。

clipboard_hijacker.js脚本一旦被注入会立即执行 loadConfig() 函数,从chrome.storage.local 读取并加载地址配置到内存中的 hijacker_config 变量。同时会注册document.addEventListener(‘copy’, handleCopy,true),开始监听页面上的所有复制操作。

用户在网页上选中一个加密货币地址并按下复制的时候,copy事件被触发,handleCopy 函数开始执行。函数首先检查用户是否有选中文本 (document.getSelection()) 以及配置是否已加载,然后会遍历一个预设的货币顺序,对于每种货币使用预定义的正则表达式来检查用户复制的文本是否匹配。

在替换之前,检查selection.toLowerCase()===hijacker_config[currency].toLowerCase()。 如果找到匹配项并且它不是攻击者的地址,脚本就会执行selection.replace(regex, hijacker_config[currency]),生成一个被替换过的文本。

event.preventDefault() 被调用,阻止浏览器执行默认的复制操作,event.clipboardData.setData(‘text/plain’, replacedText)被调用,将攻击者的地址写入用户的剪贴板。最后通过 chrome.runtime.sendMessage 向后台发送一条 exfil消息。

如果我在C2下发了新的地址配置,background.js 会再次广播update_crypto_config 消息,clipboard_hijacker.js 中注册的chrome.runtime.onMessage监听器会收到这条消息,并再次调用 loadConfig() 函数。

踩的坑

一开始是想遍历整个页面的DOM,找到所有看起来像加密货币地址的文本然后直接替换它们。

但是现在网站使用的是React/Vue等框架动态渲染内容,地址可能出现在不断变化的DOM节点中,难以稳定捕获,而且实时扫描整个页面的DOM会非常消耗性能,还有地址可能存在于< input> 元素、< textarea>,甚至是图表中,简单的innerHTML搜索无法覆盖所有情况。

所以放弃在页面上找,直接监听复制拦截。

一开始忘记了在 manifest.json中声明clipboardRead和clipboardWrite权限。

Form Submit Capture

看看你的账号密码是几.jpg

C2是payload可以留空,代表攻击所有网站,也可以指定一个域名列表定点攻击。

background.js部分和上面一样

当content_script.js首次被注入到一个网页时,会立刻调用loadFormCaptureConfig() 来加载初始配置,同时通过 chrome.runtime.onMessage.addListener(…)注册一个消息监听器,时刻准备接收来自后台的通知。

当update_form_capture_config广播消息抵达时,这个监听器被触发并再次调loadFormCaptureConfig()。这一次loadFormCaptureConfig() 从 chrome.storage.local 中读到了最新的配置,发现enabled 标志为true。至此捕获功能在该页面上被正式激活。

接下来是实时数据捕获 ,用户开始在页面上的输入框中打字,每一次按键handleInput函数都会被触发,函数首先检查enabled标志,确认功能是否处于激活状态,只有当输入框位于一个同时也包含密码框 (<inputtype="password">) 的容器内时才会继续执行。

如果条件满足,脚本会获取输入框的唯一标识然后从chrome.storage.local中读出已暂存的凭据对象,将当前输入框的值添加或更新进去,再将整个对象写回chrome.storage.local。这个过程在用户输入的每一个字符上都会发生。

用户输入完账号密码,点击了登录 确认 下一步等按钮,handleClick函数就会被触发,它关心点击发生在哪里,而是直接从chrome.storage.local读取暂存的凭据,如果读出的凭据对象存在且不为空就确认捕获成功。

将捕获到的凭据和当前页面的URL打包成一个 payload,通过 chrome.runtime.sendMessage({ type: ‘exfil’, … }) 将这个 payload发送给后台的 background.js。

踩的坑

第一个版本是监听表单的submit 事件,然后把所有数据打包发走,但是在QQ邮箱这类网站上立刻失效了,它们根本不使用传统的< form> 提交,而是用JS监听按钮的click事件来驱动 AJAX/Fetch请求,submit事件没有被触发过。

第二个版本是Click-and-Scrape,submit 不行就监听click事件,用户点击时找到离按钮最近的那个表单,然后遍历所有输入框把值抓出来,这个搞出来密码为空,日志里面用户名能捕获,但密码字段总是空的。

⬆️在我click事件处理器执行时,网站自身的JS可能已经为了安全读取并清空了密码框的值。

第三个版本的实时输入捕获,就是不能等到点击的时候再抓,必须在用户打字的时候就把数据记下来,这次用的input事件监听器,把凭据实时存到一个全局变量里。这个方法在简单的登录页上会成功。但在 QQ邮箱上再次失败,打开后台发现账号密码框在一个 iframe里,而验证码和最终的登录按钮在另一个完全不同iframe里。所以iframe A 中运行的脚本所设置的全局变量,对于在 iframe B中运行的另一个脚本实例来说是完全不可见的。

继续改第四个版本,问题的核心是状态共享,我需要一个所有脚本无论它们在哪个 iframe里都能访问的公共存储空间。所以用chrome.storage.local作为解决方案,首先改了 manifest.json,加入了”all_frames”: true,确保脚本能被注入到所有iframe 中。

然后重构了handleInput函数,不再写入内存变量,而是写入chrome.storage.local。最后重构了 handleClick 函数,从 chrome.storage.local读取数据,无论点击事件发生在哪个 iframe,都能拿到之前在另一个iframe中捕获的数据。如何让这个功能更精准,动静更小?加了启发式过滤,在handleInput中加入 form.querySelector(‘input[type=”password”]’)的检查。