Last Updated: 2023-12-19

electron-rce-cover

Electron은 화면(UI)을 구성하는 Renderer Process와 기본 프로세스를 담당하는 Main Process로 나뉜다.

Renderer Process와 Main Process

이 둘 간의 통신은 기본적으로 ipcRendereripcMain으로 통신하는데 ipcRenderer는 Renderer Process에서 Main Process로 비동기 통신을 할 때 사용하며, ipcMain은 반대로 Main Process에서 Renderer Process로 비동기 통신할 때 사용된다. Electron의 프로세스 간 통신 방식에 대한 자세한 내용은 [Electron] Electron IPC 모듈로 Electron 통신 방법 파헤치기를 참고하도록 하자.

Remote Code Execution (RCE)

Electron의 두 프로세스를 쉽게 설명하면 Front-End (Renderer Process)와 Back-End (Main Process)로 볼 수 있다.

Front-End는 Chromium을 기반으로 하고, Back-End는 전체 운영 체제의 액세스 권한을 갖는 Node.js 런타임으로 동작된다. 그렇기에 Main Process는 Electron 모듈 이외의 Node.js 내장 기능과 NPM을 통해 설치된 모든 패키지에 액세스할 수 있는 반면에, Renderer Process는 Chromiumn을 사용하여 웹 페이지를 실행하는데 보통 우리는 javascript로 UI를 구성할 때 외부 또는 내부 모듈을 자연스럽게 require() 또는 import를 사용하지만, 이 require()를 통해서 시스템 리소스에 충분히 접근할 수 있는 문제가 발생한다. 이러한 보안적 취약점은 Remote Code Execution(RCE), 임의 코드 실행이라고 한다.

Electron에서 RCE(Remote Code Execution) 취약점이 발생하는 부분은 위에서 설명한 require()와 연관이 있다. Renderer Process에서 require()를 사용하기 위해서는 webPreferencenodeIntegration 옵션과 연관이 있는데, 대부분 require is not defined 오류를 해결하기 위해 nodeIntegrationtrue 지정하여 사용하지만, 무척이나 위험한 행위이다. stackoverflow나 검색된 어느 블로그 글들을 봐도 require is not defined 오류 해결 방안을 nodeIntegration을 true로 제안하고 있다.

run-calculator

위 이미지를 보면 개발자 도구(dev tools)에서 require('child_process')를 접근하여 실제로 시스템의 계산기를 실해하는 모습을 볼 수 있다.

nodeIntegration

nodeIntegation을 공식 문서의 설명을 보면 노드 통합이 활성화되어 있는지의 여부 라고, 하는데 Electron은 Node.js 기반에서 동작하기 때문에 이를 쉽게 설명하면 Renderer Process에서도 Node.js를 사용할 수 있게 하겠냐 것이다. 문제는 nodeIntegration의 기본값이 false인 데도 불구하고 이유도 모르고 true로 설정하였다가는 낭패를 볼 수 있다.


그렇다면 nodeInteration을 사용하지 않고(false) 어떻게 Renderer Process에서 require()를 사용하라는 말인가?

이 포스팅은 이 부분에 대하여 필자가 겪고 해결한 방법을 공유하고자 작성한다.

Renderer Proces와 Main Process 간의 Context 분리

Remote Code Execution(RCE) 취약점을 해결하기 위해서는 Renderer Process와 Main Process의 Context를 기본적으로 분리해야 한다. 이러한 역할을 하기 위해서는 BrowserWindow 객체 생성 시 nodeIntegration은 기본값인 false, 그리고 contextIsolation 옵션 역시 기본값 true로 사용하면 된다.

webPreferences: {
nodeIntegration: false,
contextIsolation: true
}

contextIsolation은 Electron API와 지정된 스크립트를 별도의 Javascript 컨텍스트에서 실행할지 여부이며, Renderer Process에서 스크립트를 실행할 수 있는 컨텍스트는 오로지 preload를 통해서만 접근이 가능하다.

preload 는 HTML DOM 과 Node.js 및 Electron API의 제한된 하위 집합에 모두 액세스할 수 있다. 쉽게 말하면 분리된 Renderer Process와 Main Process 모두 접근할 수 있는 컨텍스트에서 실행된다.

nodeintegration: false로 인한 require is not defined 오류는 이 preload 통해 해결할 수 있다.

preload.js

const {ipcRenderer, contextBridge} = require('electron')

contextBridge.exposeInMainWorld('api', {
send: (channel, data) => {
const channels = ['channel'] // ipc 채널

if (channels.incluse(channel)) {
ipcRenderer.send(channel, data)
}
},
receive: (channel, func) => {
const channels = ['onChannel'] //ipc 채널

if (channels.include(channel)) {
ipcRenderer.on(channel, (event, ...args) => func(event, ...args))
}
}
})

Electron의 contextBridge는 Renderer Process에서 구동되며, 격리된 컨텍스트 간에 양방향 통신을 할 수 있도록 해준다. ContextBridge의 exposeInMainWorld는 Main Process 코드가 실행되는 javascript 컨텍스트로 Renderer Process에서 window 객체를 통해 접근할 수 있다.

preload.js에 정의된 sendreceive는 쉽게 생각하면 Interceptor로 볼 수 있다. Renderer Process 또는 Main Process에서 ipcRenderer를 호출하는 대신 이 sendreceive를 호출하면 해당 메소드에서 ipcRenderer를 호출하게 된다.

이렇게 정의한 preload.js를 BrowserWindow 객체를 생성할 때 webPreference의 옵션으로 지정한다.

index.js

const { app, BrowserWindow, ipcMain } = require('electron')

const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: join(__dirname, './preload.js')
}
})

win.loadFile('index.html')
}

app.whenReady().then(() => {
createWindow()
})

ipcMain.on('channel', evt => {
evt.reply('onChannel', 'Main World!!')
})

index.js는 Main Process에 해당하기 때문에 가장 하단에 보면 ipcMain을 통해 channel을 호출한다. 이후 callback이 완료되면 다시 onChannel을 호출한다. 이때 evt.reply를 호출하게 되면 preload.js의 receive를 호출한다.

이제 Renderer Process 부분에서 UI를 구성해 보자.

index.html

<html>
<head>
<script type="text/javascript" src="js/script.js"></script>
</head>

<body>
<h1 id="text-box">Hello World</h1>
<button id="btn">Change Text</button>
<body>
</html>

js/script.js

window.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('btn')

btn.addEventListener('click', () => {
window.api.send('channel')
})

window.api.receive('onChannel', (evt, text) => {
const titleBox = document.getElementById('text-box')

titleBox.innerText = text
})
})

js/script.js를 보면 window.api.send('channel')로 preload.js에 선언된 send를 호출하고 있고, index.js에서 evt.reply('onChannel')에 대해 window.api.receive('onChannel')을 통해 수신하고 있다.

전반적인 통신의 흐름을 보면 다음과 같다.

rce-flow


이처럼 preload 와 nodeInegration을 적절하게 사용하여 Renderer Process와 Main Process 간의 Context를 분리하면 Remote Code Execution(RCE) 취약점을 해결할 수 있다.