0x00 绪论
Parallels Desktop for Mac是macOS上最流行的虚拟机软件之一,关于它的公开漏洞研究却寥寥无几。去年11月,Reno Robert(@renorobertr)向ZDI报告了Parallels中的多个漏洞,其中之一允许客户机操作系统中的本地用户提升权限,在主机中执行代码。该漏洞在15.1.3(47255)版中修复,分配了漏洞编号CVE-2020-8871(ZDI-20-292)。本文将更深入地分析此漏洞及Parallels进行修补的代码。
0x01 初步分析
以下分析全部基于15.1.2版,客户虚拟机使用默认选项配置。
原始的报告很短,漏洞是通过简单fuzzing发现的。以下是POC中的相关代码:
while (1) {
port = random_range(0x3C4, 0x3C5+1);
value = random_range(0, 0xFFFF+1);
outw(value, port);
}
这就只是不停地随机往IO端口0x3C4和0x3C5写入字而已。如果在受影响的Parallels版本上运行POC,主机上的prl_vm_app
进程会崩溃。系统中的每个虚拟机都有一个独立的prl_vm_app
进程。
Process 619 stopped
* thread #31, name = 'QThread', stop reason = EXC_BAD_ACCESS (code=2, address=0x158d28000)
frame #0: 0x0000000108c7a082 prl_vm_app`___lldb_unnamed_symbol5076$$prl_vm_app + 738
prl_vm_app`___lldb_unnamed_symbol5076$$prl_vm_app:
-> 0x108c7a082 <+738>: mov dword ptr [rsi], ecx
0x108c7a084 <+740>: cmp r12d, 0x2
0x108c7a088 <+744>: jb 0x108c7a0a0 ; <+768>
0x108c7a08a <+746>: mov dword ptr [rsi + 0x4], ecx
Target 0: (prl_vm_app) stopped.
(lldb) bt
* thread #31, name = 'QThread', stop reason = EXC_BAD_ACCESS (code=2, address=0x158d28000)
* frame #0: 0x0000000108c7a082 prl_vm_app`___lldb_unnamed_symbol5076$$prl_vm_app + 738
frame #1: 0x0000000108c7ac8b prl_vm_app`___lldb_unnamed_symbol5078$$prl_vm_app + 907
frame #2: 0x0000000108c7dd52 prl_vm_app`___lldb_unnamed_symbol5093$$prl_vm_app + 1442
frame #3: 0x0000000108ce66dc prl_vm_app`___lldb_unnamed_symbol6282$$prl_vm_app + 636
frame #4: 0x0000000108c77bfc prl_vm_app`___lldb_unnamed_symbol5063$$prl_vm_app + 1468
frame #5: 0x0000000108c7762c prl_vm_app`___lldb_unnamed_symbol5062$$prl_vm_app + 28
frame #6: 0x000000010b91c153 QtCore`___lldb_unnamed_symbol228$$QtCore + 323
frame #7: 0x00007fff6879bd76 libsystem_pthread.dylib`_pthread_start + 125
frame #8: 0x00007fff687985d7 libsystem_pthread.dylib`thread_start + 15
(lldb)
稍做研究我们发现,0x3C4和0x3C5分别是VGA定序器(sequencer)基址寄存器和定序器数据寄存器。初步查看似乎是VGA设备中出现了越界写漏洞。如前面所说,POC是由fuzzing触发的,原始报告也没有提供详细分析。是时候深入看看了。
0x02 追根溯源
崩溃位于一个巨大的函数sub_100185DA0
中,相关代码简化注释如下:
char __fastcall sub_100185DA0(__int64 a1, unsigned int a2, unsigned int a3)
{
//...
vga_context = a1;
v12 = 0;
v13 = 0;
//...
while ( 1 )
{
//...
w = (_DWORD *)(vga_context->w);
//...
dst = (unsigned int *)((_QWORD *)(vga_context->buf) + 4LL * v12 * w);
v24 = 0;
do
{
v27 = 8;
do
{
//...
v31 = (_DWORD *)((_DWORD *)(vga_context->array[ 4LL * ((_BYTE *)v29) ]) | 0xFF000000);
*dst = v31; // 崩溃
++dst;
--v27;
}
while ( v27 );
v24 += 8;
v11 = (_DWORD *)(vga_context->w);
}
while ( v4 * v24 < v11 ); // v4 = 1
//...
}
v12 = v3 * ++v13;
if ( v3 * v13 >= (_DWORD *)(vga_context->h) ) // v3 = 1
break;
...
}
//...
}
vga_context
结构体分配于VGA设备初始化期间,其中保存VGA设备的状态和变量。该函数试图用三层循环顺序写入缓冲区vga_context->buf
,总长度计算为vga_context->h * vga_context->w * sizeof(DWORD)
字节。然后,执行越界写,由于长度无效而在循环内崩溃。
首先,我们要确定缓冲区vga_context->buf
内容的来源。
mapped file 00000001539e1000-00000001579e1000 [ 64.0M 47.9M 0K 0K] rw-/rwx SM=ALI
这个64MB的大缓冲区是屏幕缓冲区,经由客户机配置(硬件->图形->内存)来设置,看起来vga_context->h
和vga_context->w
是客户机屏幕分辨率的高和宽。
接着,我们要确定vga_context->h
和vga_context->w
内容的来源,可以从调试器找到答案,来自sub_100184F90
里的vga_state
。
char __usercall sub_100184F90@<al>(int *a1@<rdx>, __int64 a2@<rdi>, _DWORD *a3@<rsi>, unsigned int a4@<r11d>)
{
//...
vga_state = (_QWORD *)(vga_context->vga_state);
v6 = *(_DWORD *)(vga_state->flaggg);
if ( v6 )
{
width = (unsigned __int16 *)(vga_state->w);
height = (unsigned __int16 *)(vga_state->h);
// they will save to vba_context later
//...
}
但是,vga_state
对象的来源又是哪呢?
shared memory 000000011150e000-0000000111514000 [24K 24K 24K 0K] rw-/rwx SM=SHM
我们发现,是来自共享内存。本案例中,该内存共享于主机的ring0和ring3之间,在ring0的VGA IO端口handler更新这块内存,然后ring3的视频工作线程(位于sub_100183610
)使用之。
__int64 __fastcall VgaOutPortFunc(__int16 port, unsigned int cb, unsigned __int64 a3, void *val, void *vga_state, __int64 a6)
{
v11 = *(_DWORD *)val;
v8 = *(_BYTE*)val;
//...
switch ( (unsigned __int16)(port - 0x3B4) )
{
//...
case 0x10u: // 0x3c4
vga_state->sr_index = v8;
return v7;
case 0x11u: // 0x3c5
switch ( vga_state->sr_index + 95 )
{
//...
case 9:
(_WORD *)vga_state->w = v11;
vga_state->sr_index = 0xABu;
return v7;
case 10:
(_WORD *)vga_state->h = v11;
vga_state->sr_index = 0xACu;
return v7;
//...
case 13:
if ( v11 & 1 )
{
(_DWORD *)vga_state->flag8 = 1;
}
else
{
(_DWORD *)vga_state->flag8 = 0;
}
//...
}
//...
case 0x15u: // 0x3c9
LOBYTE(i) = vga_state->i;
vga_state->i = (_BYTE)i + 1;
if ( (_BYTE)i == 2 )
{
v19 = vga_state->index2;
vga_state->array[4 * v19] = 4 * v8;
vga_state->i = 0;
vga_state->index2 = (_BYTE*)(v19 + 1);
}
else if ( (_BYTE)i == 1 )
{
*((_BYTE*)vga_state->array[4 * vga_state->index2 + 1]) = 4 * v8;
}
else if ( (_BYTE)i == 0)
{
*((_BYTE*)vga_state->array[4 * vga_state->index2 + 2]) = 4 * v8;
}
//...
return v7;
//...
}
由以上伪代码看出,0x3C4端口作为选择子,控制着0x3C5端口发生什么。0x3C5端口可以把vga_context->h
和vga_context->w
设为任意的16位值。当ring3视频工作线程得到屏幕的新宽高时,就试图更新整个屏幕缓冲区(vga_context->buf
)。然而,该线程并未对新宽高做验证,导致屏幕缓冲区的溢出漏洞。
此外,溢出的长度也是可以控制的,溢出的值则可以通过0x3C9端口(见vga_context->array
)部分控制。因此,我们判断此漏洞很可能可以利用。
0x03 审视补丁
补丁发布后,我比较了15.1.2和15.1.3版的二进制之间的差异,研究厂商是如何修复漏洞的。仔细查看差异发现,sub_100185DA0
的调用者中做了微调。
__int64 __usercall sub_100186900@<rax>(__int64 vga_context@<rdi>, unsigned int a2@<r11d>)
{
//...
sub_100184F90((int *)&v29, vga_context, &v28, a2); // 上面已解释
vga_state = (_QWORD *)vga_context->vga_state);
//...
if ( *(_BYTE *)(vga_context->flaggg) ) // 打补丁后
//if ( (_DWORD *)(vga_state->flaggg) ) // 打补丁前
{
//...
}
else if ( *(_DWORD *)(vga_state + 15828) ) // 似乎总是1
{
//...
sub_100185DA0(vga_context, v28, v29); // 触发越界写
//...
}
//...
}
其中一个if分支变动了。补丁把vga_state->flaggg
改成了vga_context->flaggg
。
flaggg
是什么?
如sub_100184F90
中所见,flaggg
必须为TRUE才能从vga_state
获取受控制的宽和高。但是,flaggg
又必须是FALSE才能进入导致崩溃的函数。两约束条件冲突。
怎样才能满足这些条件?
追根溯源时,我们说过,vga_state
是ring0和ring3间的共享内存,flaggg
功能可以通过0x3C5端口配置。因此,可以翻转flaggg
,并在ring3视频工作线程中利用double fetch。
补丁把vga_state->flaggg
改成了vga_context->flaggg
,由于vga_context
是ring3堆分配的,不受double fetch影响,因此不会触发越界写的那条路经。
0x04 总结
本文分析了Parallels Desktop中虚拟设备漏洞的流程和根源。厂商将漏洞标为低严重性,但是考虑到其CVSS得分和从客户提升到主机的可能性,用户应当认为这是严重漏洞并尽快安装补丁。Parallels Desktop的漏洞提交并不多,也许本文会鼓励其他分析人员往这个方向尝试。如果你找到了漏洞,我们很乐意看一看。