
[CVE-2017-10235] VirtualBox E1000 장치 버퍼 오버플로우에 대한 설명 및 PoC
다음 문서는 VirtualBox v5.1.22(현재 v5.1.24에서 수정됨)의 게스트 장치 에뮬레이션 구성 요소 DevE1000(Intel 82540EM 이더넷 컨트롤러 에뮬레이션)의 e1kFallbackAddToFrame 함수에서 발견된 버그에 대해 상세히 설명하며, 이 버그는 게스트 OS가 공격자에 의해 제어될 때 호스트에서 버퍼 오버플로우를 유발합니다.
Oracle은 2017년 7월 CPU에서 발급된 CVE-2017-10235로 이 버그를 인정했습니다.
이 취약점은 Linux(Ubuntu 16.04) 및 Windows(v8.1) 호스트에서 Linux(Ubuntu 16.04) 게스트를 실행하는 환경에서 모두 확인되었지만, 다양한 호스트/게스트 조합에서도 트리거될 수 있습니다. 모든 시나리오에서 기본 네트워크 구성이 가정됩니다: Intel PRO/1000 MT Desktop (82540EM) 유형의 NAT에 연결된 네트워크 어댑터 하나만 존재합니다.
제어 구조(함수 포인터 포함)는 공격자가 제어하는 데이터로 덮어써질 수 있으므로, 많은 시나리오에서 원격 코드 실행이 달성될 수 있다고 안전하게 가정할 수 있습니다. Oracle은 이 버그가 기밀성 위험 None과 무결성 위험 Low를 가진 것으로 간주하여 낮은 CVSS 점수를 부여했지만, 우리는 이것이 이 버그의 전체적인 손상 잠재력을 반영하지 않는다고 생각합니다(RCE 가능성에 대한 설명은 아래에 제공됩니다).
Intel 82540EM 이더넷 컨트롤러의 에뮬레이션을 구현하는 VirtualBox 코드(src/VBox/Devices/Network/DevE1000.cpp)는 e1kFallbackAddToFrame 함수에서 하드웨어 TCP 분할(TCP Segmentation)을 구현합니다:
static int e1kFallbackAddToFrame(PE1KSTATE pThis, E1KTXDESC *pDesc,
bool fOnWorkerThread)
{
#ifdef VBOX_STRICT
PPDMSCATTERGATHER pTxSg = pThis->CTX_SUFF(pTxSg);
Assert(e1kGetDescType(pDesc) == E1K_DTYP_DATA);
Assert(pDesc->data.cmd.fTSE);
Assert(!e1kXmitIsGsoBuf(pTxSg));
#endif
uint16_t u16MaxPktLen = pThis->contextTSE.dw3.u8HDRLEN +
pThis->contextTSE.dw3.u16MSS;
Assert(u16MaxPktLen != 0);
Assert(u16MaxPktLen < E1K_MAX_TX_PKT_SIZE);
이 함수는 최대 TX 패킷 길이(u16MaxPktLen)가 표준 최대값인 16288바이트(E1K_MAX_TX_PKT_SIZE) 미만인지 올바르게 확인하지만, 릴리스 빌드에서 비활성화되는 Assert 매크로 형태로 수행하므로 사실상 최종 사용자에게는 쓸모없는 검사가 됩니다. 이는 Assert 대신 명시적인 if로 검사를 수행하는 유사한 함수 e1kAddToFrame와 대조됩니다:
static bool e1kAddToFrame(PE1KSTATE pThis, RTGCPHYS PhysAddr,
uint32_t cbFragment)
{
PPDMSCATTERGATHER pTxSg = pThis->CTX_SUFF(pTxSg);
bool const fGso = e1kXmitIsGsoBuf(pTxSg);
uint32_t const cbNewPkt = cbFragment + pThis->u16TxPktLen;
if (RT_UNLIKELY( !fGso && cbNewPkt > E1K_MAX_TX_PKT_SIZE ))
{
E1kLog(("%s Transmit packet is too large: %u > %u(max)\n",
pThis->szPrf, cbNewPkt, E1K_MAX_TX_PKT_SIZE));
return false;
}
일반 함수(e1kAddToFrame)와 폴백(e1kFallbackAddToFrame)의 사용 차이는 e1kXmitDesc()에서 결정되며 두 가지 요소에 따라 달라집니다: 데이터/컨텍스트 디스크립터에서 TSE 플래그가 활성화되어 있어야 하고(게스트 머신을 사용하는 OS에 의해 제어됨) GSO 플래그가 비활성화되어 있어야 합니다. 후자는 여러 요인에 따라 달라지므로 비활성화하는 방법이 다양하지만, 가장 편리한 방법은 게스트 OS에서도 제어되는 수신 제어 레지스터(RCTL.LBM 비트)를 통해 구성되는 루프백 모드를 활성화하는 것입니다.
루프백 모드를 활성화하면 e1kXmitAllocBuf 함수가 언급된 16288바이트(E1K_MAX_TX_PKT_SIZE) 길이의 PDM scatter/gather 버퍼 할당에 aTxPacketFallback 버퍼(TSE 폴백 및 루프백에 사용되는 전송 패킷 버퍼)를 사용하고(pvUser에 NULL을 설정하여) GSO가 비활성화될 것임을 알리게 됩니다.
if (RT_LIKELY(GET_BITS(RCTL, LBM) != RCTL_LBM_TCVR))
{
...
}
else
{
/* Create a loopback using the fallback buffer and preallocated SG. */
AssertCompileMemberSize(E1KSTATE, uTxFallback.Sg, 8 * sizeof(size_t));
pSg = &pThis->uTxFallback.Sg;
pSg->fFlags = PDMSCATTERGATHER_FLAGS_MAGIC |
PDMSCATTERGATHER_FLAGS_OWNER_3;
pSg->cbUsed = 0;
pSg->cbAvailable = 0;
pSg->pvAllocator = pThis;
pSg->pvUser = NULL; /* No GSO here. */
pSg->cSegs = 1;
pSg->aSegs[0].pvSeg = pThis->aTxPacketFallback;
pSg->aSegs[0].cbSeg = sizeof(pThis->aTxPacketFallback);
}
이로 인해 (e1kXmitDesc 내부의) e1kXmitIsGsoBuf 함수 호출이 False를 반환하게 되고, 데이터 디스크립터에서 TSE가 활성화된 경우 실행 흐름은 (올바른 검사를 수행하는 더 안전한 함수 e1kAddToFrame 대신) e1kFallbackAddToFrame로 이동합니다.
/*
* Add the descriptor data to the frame. If the frame is complete,
* transmit it and reset the u16TxPktLen field.
*/
if (e1kXmitIsGsoBuf(pThis->CTX_SUFF(pTxSg)))
{
...
}
else if (!pDesc->data.cmd.fTSE)
{
...
}
else
{
STAM_COUNTER_INC(&pThis->StatTxPathFallback);
rc = e1kFallbackAddToFrame(pThis, pDesc, fOnWorkerThread);
}
e1kFallbackAddToFrame 내부에서는 앞서 언급한 검사가 릴리스 빌드에서 비활성화되므로 MSS를 임의로 크게(최대 64K에서 HDRLEN을 뺀 값) 설정할 수 있으며, 이에 따라 임의로 큰 DTALEN이 e1kFallbackAddSegment에 전달될 수 있습니다:
/*
* Carve out segments.
*/
int rc;
do
{
/* Calculate how many bytes we have left in this TCP segment */
uint32_t cb = u16MaxPktLen - pThis->u16TxPktLen;
if (cb > pDesc->data.cmd.u20DTALEN)
{
/* This descriptor fits completely into current segment */
cb = pDesc->data.cmd.u20DTALEN;
rc = e1kFallbackAddSegment(pThis, pDesc->data.u64BufAddr, cb,
pDesc->data.cmd.fEOP /*fSend*/, fOnWorkerThread);
e1kFallbackAddSegment 함수는 이 값(이제 인수 u16Len으로 전달됨)을 사용하여 이 길이에 대한 추가 검사 없이 게스트 메모리에서 호스트 메모리의 aTxPacketFallback 버퍼로(PDMDevHlpPhysRead를 통해) 복사하므로 버퍼 오버플로우(16288바이트 버퍼 용량에 최대 64K 크기의 메모리)를 유발합니다.
static int e1kFallbackAddSegment(PE1KSTATE pThis, RTGCPHYS PhysAddr,
uint16_t u16Len, bool fSend, bool fOnWorkerThread)
{
int rc = VINF_SUCCESS;
/* TCP header being transmitted */
struct E1kTcpHeader *pTcpHdr = (struct E1kTcpHeader *)
(pThis->aTxPacketFallback + pThis->contextTSE.tu.u8CSS);
/* IP header being transmitted */
struct E1kIpHeader *pIpHdr = (struct E1kIpHeader *)
(pThis->aTxPacketFallback + pThis->contextTSE.ip.u8CSS);
E1kLog3(("%s e1kFallbackAddSegment: Length=%x, remaining payload=%x,
header=%x, send=%RTbool\n", pThis->szPrf, u16Len,
pThis->u32PayRemain, pThis->u16HdrRemain, fSend));
Assert(pThis->u32PayRemain + pThis->u16HdrRemain > 0);
PDMDevHlpPhysRead(pThis->CTX_SUFF(pDevIns), PhysAddr,
pThis->aTxPacketFallback + pThis->u16TxPktLen, u16Len);
이 취약점이 RCE로 이어질 가능성을 높이는 요소로 주목해야 할 점은 버퍼 바로 다음의 변수가 버퍼의 인덱스(u16TxPktLen)라는 것입니다. 이 값은(PDMDevHlpPhysRead 인수의 오프셋으로) 버퍼에 쓰는 데 사용됩니다. 초기 버퍼 오버플로우(E1K_MAX_TX_PKT_SIZE + 2바이트 길이의 첫 번째 데이터 디스크립터로 인해 발생)로 이 값을 제어하면 (두 번째 데이터 디스크립터를 사용한 PDMDevHlpPhysRead의 두 번째 호출에서) 버퍼로부터 최대 64K 거리에 있는 모든 메모리 주소에 쓸 수 있으며, 그 사이의 모든 메모리를 덮어쓸 필요가 없습니다(그렇게 하면 잠재적 크래시를 피해야 하는 공격이 더 복잡해집니다).
aTxPacketFallback 대상 버퍼 근처, 몇 줄 아래이자 64K 범위 내에는 g_aE1kRegMap 구조체가 정의되어 있습니다. 이 구조체에는 읽기 및 쓰기 핸들러(pfnRead 및 pfnWrite)를 구현하는 함수 포인터 벡터가 포함되어 있어, RCE를 용이하게 하는 두 번째 버퍼 오버플로우의 이상적인 대상이 됩니다.
e1kXmitAllocBuf의 버그완전성을 위해 이 공격 벡터의 (사소한) 복잡성을 언급할 가치가 있습니다: e1kXmitAllocBuf 함수에는 버그로 보이는 것이 있는데, 루프백 모드의 경우 cbTxAlloc(다음 패킷의 바이트 수)이 일반적인 경우(해당 if의 다른 분기)처럼 0으로 재설정되지 않습니다. 이로 인해 스레드가 e1kLocateTxPacket의 while 루프(e1kXmitPending 내부)에 갇히게 됩니다:
while (e1kLocateTxPacket(pThis))
{
fIncomplete = false;
/* Found a complete packet, allocate it. */
rc = e1kXmitAllocBuf(pThis, pThis->fGSO);
/* If we're out of bandwidth we'll come back later. */
if (RT_FAILURE(rc))
goto out;
/* Copy the packet to allocated buffer and send it. */
rc = e1kXmitPacket(pThis, fOnWorkerThread);
/* If we're out of bandwidth we'll come back later. */
if (RT_FAILURE(rc))
goto out;
}
이것은 cbTxAlloc이 0이 아닌 경우 e1kLocateTxPacket이 True를 조기 반환하고, iTxDCurrent가 nTxDFetched와 같은지 확인하는 코드(모든 디스크립터가 처리된 일반적인 경우)에 도달하지 못하기 때문에 발생하는 것으로 보입니다. 이 확인 코드는 일반적으로 함수가 False를 반환하게 하여 앞서 언급한 루프를 효과적으로 종료합니다.
static bool e1kLocateTxPacket(PE1KSTATE pThis)
{
LogFlow(("%s e1kLocateTxPacket: ENTER cbTxAlloc=%d\n",
pThis->szPrf, pThis->cbTxAlloc));
/* Check if we have located the packet already. */
if (pThis->cbTxAlloc)
{
LogFlow(("%s e1kLocateTxPacket: RET true cbTxAlloc=%d\n",
pThis->szPrf, pThis->cbTxAlloc));
return true;
}
즉, (루프백 모드를 설정한 후) 장치로 전송되는 첫 번째 패킷이 오버플로우를 트리거하는 패킷이어야 한다는 요구 사항으로 이어집니다. 그렇지 않으면 VM이 중단되어 RCE가 아닌 DoS로 끝나게 됩니다.
네트워크 장치의 설정이 결코 간단하지 않고 이를 위한 맞춤형 드라이버를 구축하는 것을 피하기 위해, 오버플로우를 트리거하는 디스크립터(컨텍스트 및 데이터 모두)를 생성하도록 일반 Linux 커널의 E1000 드라이버가 수정되었습니다. 이 수정된 커널은 이 저장소에서 다운로드할 수 있습니다. Ubuntu 16.04 게스트에서 테스트되었으며 Linux 및 Windows 호스트 모두에서 크래시를 유발했습니다. 자세한 설명은 여기에서 확인할 수 있습니다.
이 취약점은 Changeset 67974(https://github.com/fundacion-sadosky/vbox_cve_2017_10235/blob/HEAD/%60bugref:8881%60)에서 수정되었습니다. e1kFallbackAddToFrame에서 Assert로 수행되던 검사가 if 문을 사용한 명시적 검사로 변환되어 릴리스 빌드에서도 계속 활성화됩니다(e1kAddToFrame에서 이미 수행된 방식과 유사). 또한 e1kXmitAllocBuf의 두 분기(루프백 모드 및 일반 모드) 모두에서 cbTxAlloc이 이제 0으로 설정됩니다.
여기서 제안하는 추가적인(방어적) 검사로, 변경 집합에는 구현되지 않았지만, e1kFallbackAddSegment(e1kAddToFrame에서도 유사하게)의 PDMDevHlpPhysRead 호출 전에 게스트 메모리로 인한 버퍼 오버플로우 가능성을 명시적으로 확인하는 것입니다(주로 u16TxPktLen과 u16Len의 합이 aTxPacketFallback 버퍼 길이보다 작은지 확인).