从零起步做一个拦截蜜罐XSSI的Chrome扩展 - Monyer/antiHoneypot GitHub Wiki

从零起步做一个拦截蜜罐XSSI的Chrome扩展

1. 起因

随着今年某大型实战攻防演习活动的临近,不仅攻击侧在抓紧时间筹备武器弹药,防守侧也做好了各项防护准备和对策。

由于防守侧对攻击行为的发现及溯源是加分的,所以蜜罐基本上成为了今年防守的必选项,我们在今年多次实战演习和红队评估项目中均有发现蜜罐的应用。

为了实现对攻击者进行溯源和追踪,发现的WEB蜜罐中普遍使用了XSSI和Fingerprint相关技术。利用国内互联网大厂普遍通过JSONP跨域传输用户信息(没错,就是国内,国外大厂基本不用JSONP)的漏洞来实现对攻击者的身份截获。利用浏览器在使用代理或进入隐身模式后的某些特征不变性(canvas、font、webGL、audio等对象中的固定特征)来标识浏览器,再结合一些类似evercookie的方法来持久化身份标识,实现行为跟踪。

对于这些溯源技术,直接安装NoScript、ScriptSafe等浏览器扩展是可以达到一劳永逸的效果的。但这些扩展的误杀实在是太严重了,非常影响正常的网络访问体验。同时也希望能够在一定程度上发现蜜罐并予以提示,因此,萌生了开发一个Chrome扩展的想法。

2. 扩展的基本功能和实现思路

2.1 基本功能

通过分析几个蜜罐的功能和特性,希望扩展能够具备如下几个功能:

  1. 截获页面中发起的XSSI请求,通过特征识别阻断可疑的XSSI
  2. 分析和攫取蜜罐固有特征,识别蜜罐并拦截所有请求
  3. 判断fingerprintjs库是否存在并提示,判断是否有其他web指纹的相关调用
  4. 判断是否有持久化身份标识的相关调用

2.2 实现思路

如果第一次开发浏览器扩展,小茗同学的Chrome插件(扩展)开发全攻略是一份非常不错的参考文档。再参考Chrome扩展的官方开发文档,只要具备常规的WEB前端开发技能(HTML、CSS、JavaScript)便可以开发出一个不错的扩展来。

Chrome扩展的一切开始于manifest.json,可在该文件配置不同类型脚本,使之具备不同的生命周期和功能:

  • background脚本的生命周期伴随着整个浏览器,不区分tab,可以通过添加webRequest监听器来实现对请求的拦截和阻断。
  • content_scripts脚本的生命伴随着每一个tab页面,可以实现对DOM的查看和操作,但不能实现对页面中JS的调用。可以用来做“基本功能”中提到的2-4的相关功能点。

如下是一个简要的manifest.json配置项

{
    "manifest_version": 2,
    "name": "AntiHoneypot",
    "version": "0.0.5",
    "background": {
        "page": "background.html"
    },
    "content_scripts": [
        {
            "js": ["content-script.js"],
        }
    ],
    "permissions": [
        "webRequest",
        "webRequestBlocking",
        "notifications"
    ],
    "homepage_url": "https://www.monyer.com"
}

需要注意的是:由于content_scripts没办法调用页面中的JS,所以我们需要DOM操作将预执行的JS注入到页面中,方法跟正常的JS注入也类似。

var script_1 = document.createElement('script');
script_1.textContent = "(" + inject + ")()";
document.documentElement.appendChild(script_1);

另外,由于各自生命周期不同,互相间传递信息需要通过消息来通信。页面中的JS只能与content-scripts传递信息,可通过window.postMessage和window.addEventListener实现。content-script向background传递消息需要通过chrome.runtime.sendMessage和chrome.runtime.onMessage.addListener实现。popup从background获取信息较为方便,通过chrome.extension.getBackgroundPage()来获取background对象,然后便可以直接调用background中的变量或方法了。具体的实现方式参见本扩展的代码或者小茗同学的文档,此处不再展开。

有了上述的基础垫底,我们可以梳理出功能的实现思路:

  • 在background中添加webRequest监听器,通过特征识别阻断可疑的XSSI。
  • 在content-scripts中通过DOM的判断,或者通过注入JS对页面中JS变量或方法进行判断来识别fingerprint、指纹调用和身份标识持久化等操作。通过消息传递,将信息传递到background或popup,实现提示功能。

3. 功能实现

3.1 XSSI识别和阻断实现

chrome扩展对网络请求的阻断是通过webRequest实现的。通过查看webRequest API文档,我们最终选择onBeforeSendHeaders和onHeadersReceived两个事件API来截获请求。这两个事件API一个是在HTTP已请求建立,但是Header还未发送前触发;一个是在服务器端返回的HTTP Header已到达本地,但body还没到达前触发。这两个API都具备阻断请求的功能。

虽然对比onBeforeSendHeaders,onBeforeRequest更为靠前,此时TCP链接尚未建立。但onBeforeRequest阶段没办法获得和修改HTTP请求中的Header,譬如Cookie等。所以综合考虑,使用onBeforeSendHeaders更为合适。

我们通过调用API的addListener方法来实现对请求的捕获,url过滤规则设置成所有,并添加阻断、额外头、发送头、响应头等权限。之后当请求发起时,在对应的事件节点,便会调用我们设置的回调函数。

//设置监听器于Header发送开始前
chrome.webRequest.onBeforeSendHeaders.addListener(
  beforeSendHeaders, {
    urls: ["<all_urls>"]
  }, ['blocking', 'extraHeaders', 'requestHeaders']
);
//设置监听器于服务器端header发送后,body发送之前
chrome.webRequest.onHeadersReceived.addListener(
  headersReceived, {
    urls: ['<all_urls>']
  }, ['blocking', 'extraHeaders', 'responseHeaders']
);

通过控制回调函数的返回值,我们便可以实现对本次请求的阻断。

//当回调函数返回如下值时,请求将被阻断。
return {
  cancel: true
};

onBeforeSendHeaders和onHeadersReceived的回调函数参数均为details,是一个包含请求类型、请求url、请求类型、发起者url、tabId等信息的对象。

chrome默认的请求类型有:"main_frame", "sub_frame", "stylesheet", "script", "image", "font", "object", "xmlhttprequest", "ping", "csp_report", "media", "websocket", "other"。XSSI的利用基本上都是“script”类型。在蜜罐中,我们也发现通过创建隐藏iframe,利用XSS获取虚拟身份的办法,所以"sub_frame"的类型也在监控之列。在很久之前,有人琢磨出通过远程调用css的方式来跨域获取一定敏感数据的方法,不过利用条件挺苛刻,所以"stylesheet"类型我们暂不考虑。

由于只针对跨域情况,所以首先通过判断请求url和发起者url,将同顶级域名请求放行,之后便可以根据请求链接中域名的特征、URI的特征、QueryString的特征对请求进行判断和拦截。

//利用自定义函数获取发起者和请求者URL的域名和顶级域名
let {
  topDomain: initiatorTopDomain
} = _getDomain(details.initiator);
let {
  topDomain: urlTopDomain
} = _getDomain(details.url);
//域名相同不拦截,顶级域名相同不拦截。
if (initiatorTopDomain == urlTopDomain) {
  return;
}
if (['script', 'xmlhttprequest', 'sub_frame'].includes(details.type)) {
    //ban掉蜜罐中出现过的jsonp域名
    let blackJsonpDomain = ['comment.api.163.com', 'now.qq.com', 'node.video.qq.com', 'passport.game.renren.com', 'wap.sogou.com', 'v2.sohu.com', 'login.sina.com.cn', 'bbs.zhibo8.cc', 'appscan.360.cn', 'wz.cnblogs.com', 'api.csdn.net', 'so.v.ifeng.com', 'api-live.iqiyi.com', 'account.itpub.net', 'm.mi.com', 'hudong.vip.youku.com', 'home.51cto.com', 'passport.baidu.com', 'chinaunix.net', 'www.cndns.com', 'remind.hupu.com', 'api.m.jd.com', 'passport.tianya.cn', 'my.zol.com.cn', 'account.cnblogs.com', 'pcw-api.iqiyi.com', 'stadig.ifeng.com', 'account.xiaomi.com', 'cmstool.youku.com', 'api.ip.sb', 'log.mmstat.com', 's1.mi.com', 'g.alicdn.com', 'fourier.taobao.com', 'cndns.com', 'sitestar.cn', 'tie.163.com', 'musicapi.taihe.com'];
    if (blockKeywords(urlDomain, blackJsonpDomain, "black jsonp domain", details)) {
      return {
        cancel: true
      };
    }
    //ban掉其他危险的域名,譬如统计网站。其实对于防追踪,用一些adblock扩展会更全一些,效果更好
    let blackOtherDomain = ['hm.baidu.com', 'cnzz.com', '51.la', 'google-analytics.com', 'googletagservices.com'];
    if (blockKeywords(urlDomain, blackOtherDomain, "danger domain", details)) {
      return {
        cancel: true
      };
    }
    //ban掉uri部分中含有.json、jsonp等关键词的请求
    let blackUriKeywords = ['.json', 'jsonp'];
    if (blockKeywords(url.split("?").slice(0, 1).join('?'), blackUriKeywords, "black url keyword", details)) {
      return {
        cancel: true
      };
    }
    //ban掉querystring部分中含有callback等关键词的请求
    let blackQueryKeyWords = ["callback", "jsonp", "token=", "=json", "json=", "=jquery", "js_token", "window.name", "eval("];
    if (blockKeywords(url.split("?").slice(1).join('?'), blackQueryKeyWords, "black query keyword", details)) {
      return {
        cancel: true
      };
    }
}

如果想再严格一些,我们可以再判断下返回header的头,看是否存在“x-powered-by”,或者content-type是否为“json”或“text/html”。一般这几种情况下,js为动态生成的概率很大。当然这种策略的误杀率也会蛮高,很可能会导致页面功能失常。如果想在误杀和宁缺毋滥之间找个平衡的话,可以使用另外一种策略:不对打中的请求进行阻断,但对请求头中的Cookie字段进行移除。这样请求依然会发送和接收,但因为少了Cookie,自然也就不再会获得用户隐私信息。

/**
 * 移除requestHeaders中的Cookie字段
 * @param {*} details 
 */
function _removeCookie(details) {
  for (var i = 0; i < details.requestHeaders.length; ++i) {
    if (details.requestHeaders[i].name.toLowerCase() === 'cookie') {
      details.requestHeaders.splice(i, 1);
      break;
    }
  }
  return {
    requestHeaders: details.requestHeaders
  };
}

3.2 蜜罐的识别

一个完美的蜜罐本应该除了IP、域名和数据外,所有的一切都跟正常的应用系统一模一样,是不可能被识别出来的。如果一个蜜罐出现了可被识别的特征,那么必然是因为画蛇添足加了不该加的东西,譬如上面提到的XSSI的利用。通过对蜜罐进行分析,还发现有FingerprintJS和evercookie的利用,这些都可以作为识别的特征。

某蜜罐除了这些外还有两个特征:一个是自定义了header中的server字段,另一个是页面中定义了两个JS变量。

针对自定义的server字段,在onHeadersReceived阶段判断所有请求返回的header即可。

let getHeader = headerKey => details.responseHeaders.filter(header => header.name.toLowerCase() == headerKey);
  //某蜜罐服务器的Server字段特征
  let headerServerBlackKeywords = ['*****'];
  let headerServer = getHeader('server');
  if (headerServer.length !== 0 &&
    blockKeywords(headerServer[0].value, headerServerBlackKeywords, "black header[server]", details)
  ) {
    return cancel;
  }

针对页面中定义的变量判断起来略微麻烦一些,大致流程如下:

  • content-script进行DOM操作,向页面插入脚本
  • 页面脚本执行,并生成结果
  • 页面脚本调用window.postMessage发送消息
  • content-script通过window.addEventListener监听消息
  • conent-script调用chrome.runtime.sendMessage发送消息
  • background通过chrome.runtime.onMessage.addListener接收消息
  • background执行后续操作流程,譬如全局控制或阻断
var inject = function() {
  //某蜜罐用了两个全局变量:token、path。token为使用短横线链接的随机值,path为js_开头的目录
  if (window.token !== undefined && window.path !== undefined) {
    if (typeof token == "string" && token.includes("-") &&
      typeof path == "string" && path.includes("js_")) {
      document.documentElement.innerHTML = "This is a Honeypot.";
      window.postMessage({
        honeypot: true,
        blockInfo: "token=" + token + " | path=" + path
      }, '*');
    }
  }
};

var script_1 = document.createElement('script');
script_1.textContent = "(" + inject + ")()";
document.documentElement.appendChild(script_1);

window.addEventListener("message", function(e) {
  if (e.data && e.data.honeypot !== undefined) {
    console.log("这是一个蜜罐");
    chrome.runtime.sendMessage({
      honeypot: true,
      blockInfo: e.data.blockInfo
    });
  }
}, false);

注:对前端页面的判断,在不知本扩展判断规则的前提下,并不好规避。不过在公开判断规则的情况下,想要规避就轻而易举了。

3.3 FingerprintJS识别和指纹调用监控实现

FingerprintJS的判断采取了与上面蜜罐判断类似的办法,主要是判断window下的全局函数是否有x64hash128和getV18两个方法,这是它所独有的。

//如果使用了fingerprint库,全局函数的内部存在x64hash128和getV18两个对外公开的函数,以此作为判断。
  var fp = Object.keys(window).filter(func => func != 'webkitStorageInfo' && typeof window[func] == "function" && window[func]['x64hash128'] && window[func]['getV18']);
  if (fp.length !== 0) {
    window.postMessage({
      fingerprint2: true,
      fp: fp.join(',')
    }, '*');
  }

指纹调用识别采取的拿来主义,把mybrowseraddon.com的AudioContext Fingerprint DefenderCanvas Fingerprint DefenderFont Fingerprint DefenderWebGL Fingerprint Defender四个扩展的脚本直接拷过来了。这样四个扩展变一个,可同时实现对四种指纹的混淆,同时也可直接对四种指纹的提取进行监控。当一个页面同时调用了四种指纹提取所需要的函数时,页面上可以弹出一个通知。(也正是因为Copy了四份代码,所以本扩展代码仅作为Chrome扩展编写学习和个人研究使用,并不会发布到chrome网上应用店中)

3.4 识别身份持久化标识的实现

通过测试evercookieFingerprintJS Pro Demo并查阅相关资料发现,目前通用性较好的做身份标识持久化的方法大体上有两大种:一种主要利用浏览器的缓存来实现,譬如利用特殊的etag值、利用cache缓存在本地的资源等;另一种是利用浏览器针对本站的存储空间,包括:Cookies、LocalStorage、SessionStorage、indexedDB、WebSQL等。

对于浏览器缓存,没有太好的办法,要么禁止本地缓存,譬如打开并勾选DevTools下Network的Disable cache,或者是安装Classic Cache Killer等扩展,再或者每隔一段时间做一次清理。

对于浏览器存储空间,针对Cookies、LocalStorage、SessionStorage三种存储,由于都是key-value模式,可以直接利用JS获取其中的值,然后两两比较。当其中某个存储空间中的值与另外一个存储空间的相等时,即说明网页开发者有意想多处保留数据,那么是一个身份持久化标识的可能性就很大,就可以让扩展弹出一个警告通知。

//试验性质的功能,check evercookie。比较localStorage、sessionStorage、cookies
  if (request.ls !== undefined && request.ss !== undefined) {
    chrome.cookies.getAll({
      url: sender.url
    }, cos => {
      var co_vals = [];
      cos.forEach(c => co_vals.push(c.value));
      ls_vals = Object.values(request.ls);
      ss_vals = Object.values(request.ss);

      sameIdCompare(ls_vals, ss_vals, urlDomain);
      sameIdCompare(co_vals, ls_vals, urlDomain);
      sameIdCompare(ss_vals, co_vals, urlDomain);
      //   console.log(sameIds);
    });
  }

对于indexedDB、WebSQL的情况,因为用的人很少,其次里面又是数据库、又是表、又是字段,结构复杂。所以并没有采取枚举每个字段值去比较(WebSQL是一个废弃的功能,甚至连枚举数据库的功能都没有),而是直接劫持了indexedDB.open和openDatabase两个函数。当这两个函数被调用时,触发警告通知。然后需要大家慧眼识珠,人肉去鉴别这个行为的“正义”和“邪恶”了……

var inject = function() {
  //劫持openDataBase
  const openDb = openDatabase;
  openDatabase = (...args) => {
    window.top.postMessage("openDatabase-alert", '*');
    return openDb(...args);
  }
  //劫持indexedDB.open
  const idxDbOpen = indexedDB.__proto__.open;
  Object.defineProperty(indexedDB.__proto__, "open", {
    "value": function() {
      window.top.postMessage("indexedDB-alert", '*');
      return idxDbOpen.apply(this, arguments);
    }
  });
};
var script_1 = document.createElement('script');
script_1.textContent = "(" + inject + ")()";
document.documentElement.appendChild(script_1);

到此为止,我们对于该扩展所有预想的功能便都实现了。

剩下的就是代码组装调试、icon状态控制、popup.html扩展弹出页、options.html选项页等常规的杂七杂八的功能实现了,本文就不多赘述了。

4. ANTI-CSRF和ANTI-XSSI大杀器SameSite

今年2020年2月4日谷歌发布了Chrome的80版本,其中有一个很大的变化就是将Cookie中SameSite属性的默认值从None变成了Lax。虽然是仅仅是改变了Cookie一个属性的默认值这么小的变化,但是基本上一下子就把CSRF和XSSI给干掉了。

SameSite属性是谷歌于2016年4月向IETF提交的一个用于改善跨域请求携带Cookies的解决方案标准。Chrome随即从51版本,开始支持这项功能。

SameSite可以设置三个值:Strict、Lax、None。

设置cookie时的用法如下:

Set-Cookie: CookieName=CookieValue; SameSite=Strict;

当SameSite为Strict时,出现任何跨站点请求时,均不会带上这个Cookie。这里说的“任何”包括页面中的所有请求类型,甚至是从某一个网站点击本网站的链接,都不会带上这个Cookie。这种点击链接跳转都不带Cookie很明显在通常情况下是很难承受的,所以在大部分情况下,我们都应该选择Lax模式。

当设置为Lax时,为宽松模式。允许三种情况下的跨站访问携带本Cookie,分别是:弹出新窗口、顶级页面跳转和预加载模式(prerendering)。其他不管是script、iframe、css还是img的跨站点加载均不会发送Cookie。这使得CSRF和XSSI基本上被终结了。

当设置为None时,跟往常一样。可以任意地跨站点传递该Cookie。但SameSite=None与Cookie的另外一个属性“Secure”形成了互斥关系,即如果设置SameSite=None,则必须同时设置Secure属性,否则无效。意味着如果要跨域传递Cookie则必须使用HTTPS模式,HTTP下无论如何都传递不了。

不过即便SameSite设置如此严格,但由于各大网站的开发工程师为了兼容性在疯狂地给SameSite设置None值,所以未来这种情况下的XSSI依然会存在。再加上上述实现的其他几项功能,本扩展依然有着它的的实用性和价值。

代码下载&扩展使用:

  • 下载代码:https://github.com/Monyer/antiHoneypot
  • 解压到本地合适位置
  • 打开chrome扩展程序页
  • 切换到开发者模式
  • 使用“加载已解压的扩展程序”加载扩展即可

5.参考文献

⚠️ **GitHub.com Fallback** ⚠️