24 July 2025 · web · – views
A loose regex to auth bypass, then SQL injection to admin
This is my favourite of the lot. On its own the first bug is "a rule is a bit too generous" and the second is "one query forgot some quotes." Neither sounds like much. Chained, an unauthenticated attacker rewrites the admin's password and logs in as them. It's a clean example of how two small, boring-looking mistakes in different layers stack into a critical, and of why reading config files carefully pays off.
The setup: a security filter with rules
The app sat behind a security filter that decides, per request, whether
authentication is required. It's driven by rules in a config file. One rule
existed to let static assets through without login, images, CSS, fonts, and
so on, which is normal. You don't want to force a login just to fetch a
.png. The rule looked roughly like this:
<url path="([a-zA-Z0-9\-\./]+)\.(png|jpg|js|css|svg|woff|gif|html|ico|...)"
app-roles="PUBLIC"
authentication="optional" />
Read that carefully, because the whole bug is in it. Three things are wrong together.
Bug one: the rule matches far more than static files
The regex has no prefix anchor. It doesn't say "the path must start with
/static/." It says "any path that ends with one of
these extensions." So the filter looks at the whole request URI, and as long
as it ends in something like .png, the rule fires and the
request is treated as a public, no-auth static file. On top of that the rule
allows extra query parameters and requires no authentication at all.
Now combine that with how the app routes requests. Plenty of real,
privileged servlets are mapped with a wildcard, so a servlet handling
/admintool/ is actually mapped as /admintool/*,
meaning anything under that path reaches it. Put the two facts side by side
and the trick appears: append a fake static-file segment to a protected
path.
GET /admintool/anything/x.png
The routing sees /admintool/* and hands the request to the
privileged admin servlet. The security filter sees a path ending in
.png and waves it through as a public static file. The
x.png is a lie, it's just there to satisfy the regex. The
servlet never gets the authentication it assumed the filter was enforcing.
The filter checked what the path looked like, not what it reached. A rule that trusts a file extension is trusting the attacker, because the attacker writes the path.
There were dozens of wildcard-mapped servlets reachable this way. That turns one loose rule into a large unauthenticated attack surface. So the next job was to walk those servlets and find one that did something dangerous with input. One did.
Bug two: SQL injection through an unquoted column name
One of the now-reachable admin servlets updated a row in a table, and the update was built from a query template like this:
update SomeTable set ${COLUMN} = '${VALUE}' where id = ${ID}
Look at where ${COLUMN} sits. The value is inside
quotes, so it's treated as data, that part is fine. But the column
name is not quoted, because a column name never is, it's an identifier,
not a string. And its value comes straight from a request parameter. So
whatever I put in the column parameter gets dropped into the SQL as raw
query text, not as data. That is textbook SQL injection, just in the one
place people forget to check, the identifier position rather than the value
position.
Because I control raw SQL there, I can end the intended statement and add my own. The goal: rewrite the admin's password hash to one I know, then log in.
... set VERSIONNO = 1 where 1=0;
update users set password = '<bcrypt hash of a password I choose>'
where id = <admin id>; --
The where 1=0 harmlessly finishes the original update, the
semicolon starts mine, and the -- comments out whatever trailed
behind.
The one hurdle: no quotes allowed
There was a small catch. Quote characters in the input got escaped, which breaks a payload that needs a string literal for the password hash. This is a very common obstacle in SQL injection, and there's a standard way around it: build the string without ever typing a quote.
PostgreSQL's chr() turns an ASCII number into a character, and
|| concatenates. So instead of writing
'abc', you write chr(97)||chr(98)||chr(99). No
quote characters anywhere, same resulting string. I encoded the whole bcrypt
hash that way, and the escaping had nothing to catch.
password = chr(36)||chr(50)||chr(97)||chr(36)|| ... -- the hash, one char at a time
Putting the chain together
One unauthenticated GET does all of it. The path ends in
.png so the filter treats it as a public static file. The
wildcard mapping routes it to the admin servlet anyway. The servlet runs the
update, and the injected column name carries a second statement that
rewrites the admin password to a value I chose, spelled out with
chr() so no quote gets escaped. Then I log in as admin with the
password I just set.
No account, no session, no user interaction. That's why it's a 9.8. An anonymous request on the network becomes full admin control of the product.
The fix
The right fix is at bug one, the entry point, and it's small. Anchor the
static-file rule to real static paths: require the path to start
with the actual asset folders, not merely end with an asset
extension. Once the rule only matches things like
/static/...png, appending x.png to
/admintool/ no longer slips past authentication, and every one
of those wildcard servlets is behind the login again. The SQL injection
should be fixed too, by validating the column name against an allowlist of
real columns rather than trusting input, but closing the auth bypass is what
collapses the whole chain.
What to take from it
Two habits this one sharpened. When I see an allow rule based on a pattern, I
now ask what else that pattern matches, not just what it was written for. A
rule anchored to the end of a path, or to an extension, is almost
always wider than intended. And for SQL injection, I look at the identifier
positions, table and column names, not only the value positions. Values are
usually parameterised; identifiers usually aren't, and that's where the
forgotten injection tends to live. The quote-escaping defence, on its own,
stopped nothing, chr() walked right past it, which is a good
reminder that escaping one character class is not the same as fixing the
injection.