A write with no login: an unauthenticated database write in a FIS financial API
A .NET web service put its methods on the internet and checked who you were on most of them. Two methods that change rows in the backend database did not. With no account, no cookie and no token — just a plain curl — an anonymous request could insert and delete loan-configuration records on a live FIS financial platform, and read the whole product catalog on the way in. The host, product and exact method names are withheld here; the issue was reported through FIS's bug bounty program.
Most access-control bugs are about doing something as someone you are not: reading another customer's record, reaching an admin screen from an ordinary account. This one was simpler, and in a way worse. There was no account at all. A single web request, carrying no login, no session cookie and no token, reached into a financial platform's backend and changed a row in its database. A second request changed it back.
The platform belongs to FIS, one of the largest financial-technology companies in the world. The specific product is a system that banks and lenders use to run loan applications. I am keeping the host, the product name and the exact method names out of this writeup — the reasons are at the end — but the shape of the bug is worth walking through slowly, because it grows from a mistake that is very easy to make and very easy to miss.
What I was looking at
The endpoint was an .asmx service. If you have not met one, it is a style of
web service that ASP.NET has shipped for two decades. Each operation is a named method, you
call it by putting the method name directly in the URL, you send it a small JSON body, and
it answers with JSON wrapped in a little {"d": ...} envelope. That envelope is
the unmistakable fingerprint of the framework — once you see {"d":...}
come back, you know exactly what you are talking to and how to talk to it.
Services like this often expose a long list of methods, because it is convenient for the front end to have one endpoint per thing it needs to do. That convenience is also the danger: every one of those methods is a separate door, and each door needs its own lock.
The read that returned everything
I started with the most boring possible test: call a method with no credentials and see what happens. One read method answered a bare request with the platform's entire loan-product catalog — dozens of products, each with its internal ID, its terms, and the marketing template attached to it. Tens of kilobytes of configuration, handed to a request that never said who it was.
On its own, an unauthenticated read is a problem. But it is also a map. The catalog it returned contained the exact product IDs the write methods expect as input, which meant the next step was already set up for me.
A write method usually needs a valid identifier to act on — here, a real loan-product ID and a site ID. An attacker normally has to guess those. This endpoint handed over every valid ID for free, turning a blind write into an aimed one.
The two methods that did not check
Among the service's methods were two that did not read data but changed it. One attaches a loan-product configuration to a site; the other removes it. Both accepted the same credential-free request that the read did, and both actually performed the operation against the live backend. This is the whole finding in one sentence: two state-changing methods on a financial platform were reachable, and effective, with no authentication.
The request shape was mundane, which is part of the point — there was no trick, no bypass, no clever encoding. Just the method, a content type, and a JSON body:
curl -s 'https://<redacted-host>/Services/<service>.asmx/<attach-method>' \
-H 'X-Bug-Bounty: Bugcrowd-M1S0' \
-H 'Content-Type: application/json; charset=utf-8' \
--data-raw '{"loanTypeID":<valid-id>,"websiteID":1}'
Proving it was a real write — without leaving a mark
A write finding has to clear a higher bar than a read: you have to show the state actually changed, and a responsible test has to put that state back. So I used a five-step pattern that proves the change and leaves their data exactly as it was found: read, write, read, undo, read.
Step one — the baseline. Read the configuration for a site before
touching anything. It came back empty: {"d":[]}.
Step two — the write. Call the attach method anonymously. It
returned {"d":null}, the .NET way of saying “done, nothing to report.”
{"d":null}. Host, method and the loan-product ID are redacted.Step three — read it back. This is the step that turns “the server accepted my request” into “the server wrote to its database.” The same read as step one now returned a row — and, crucially, a server-assigned, auto-incrementing primary key. The database did not just echo my input; it minted a new record and gave it an ID.
A server-generated primary key is proof that an actual row was inserted into the backend database by a request that never authenticated. Not a cached echo, not a simulated response — a persistent record in a financial platform's configuration store, created anonymously.
Step four — undo it. Call the remove method, again anonymously, to
delete exactly what step two created. {"d":null} once more.
Step five — confirm it is back. Read one last time. Empty again:
{"d":[]}, identical to the baseline.
The point of a proof of concept is to show the door opens, not to walk through and rearrange the furniture. Read, change, read, undo, read — and leave it exactly as you found it.
The control test: was the lock missing, or never installed?
One honest question remained. Maybe this service simply had no security at all — in which case the story is “an endpoint was left unprotected,” which is bad, but different. To tell the difference, I aimed the same credential-free request at a method that absolutely should be protected: one that deletes an account.
It refused. Instead of the tidy JSON envelope, it returned a full HTML page — the application's login/error screen. That single contrast settles the root cause: the authentication layer exists and works. Sensitive siblings on the same surface — methods that delete accounts, remove MFA devices, change credentials, search customer records — all correctly turned the anonymous request away. The two write methods were simply standing outside the gate that everything around them stood behind.
Why this is Critical
I initially scoped the report conservatively, but the program triaged it as P1 / Critical — an authentication bypass — and that is the right call. The severity comes from four things stacking up:
- Pre-authentication integrity on financial data. An anonymous attacker could add and remove loan-product configurations against the live backend, by iterating over site identifiers. On a lending platform that is direct tampering with what products a site offers.
- It is a write, not just a read. Read bugs leak; write bugs change reality. Attaching a wrong product, or detaching a legitimate one, corrupts the configuration a real lending workflow depends on.
- Full catalog disclosure. The same anonymous surface handed over the entire product catalog, including internal IDs and marketing templates — both intelligence and the exact inputs needed to aim the writes.
- A warning about the whole surface. A write primitive sitting right next to account-deletion and credential methods strongly suggests the gating is applied one method at a time, which means the entire service needs an audit, not just a two-line patch.
Root cause: authorization applied one method at a time
The tell is the control test. Because some methods enforced authentication and others did not, the check was almost certainly attached per method — an annotation or filter on each operation — rather than enforced once, for the whole service, by default. In that model, protection is opt-in: a method is safe only if someone remembered to mark it. Two write methods were not marked, so they were open. Nothing errored, nothing looked wrong in the code, and the front end never called them without a session — so the gap was invisible until someone sent the request the front end never would.
“All the methods the UI uses are protected” is not the same as “all the methods are protected.” Attackers do not use your UI. Any model where safety depends on remembering to add a check will eventually ship a method where someone forgot.
The fix
- Deny by default, at the service layer. Require an authenticated, authorised session for the whole endpoint, and make individual methods opt in to being public — never the other way around. The default for an unmarked method must be “refuse.”
- Enforce authorisation in the service, not the client. The browser front end asking nicely is not a control. The check has to live server-side, on every state-changing operation.
- Audit the entire
.asmxsurface. The contrast between gated and ungated methods means this must be enumerated method by method, not patched where it was found. - Treat configuration reads as sensitive too. A full catalog dump to an anonymous caller is disclosure in its own right and a force-multiplier for writes.
- Log and alert on configuration changes. Because the gap may have existed silently for a long time, add audit logging and alerting on configuration mutations so an anonymous change can never again happen without a trace.
Key takeaways
- A write with no authentication beats a read with no authentication. One leaks data; the other changes it. Prioritise accordingly.
- Per-method authorisation is per-method risk. One forgotten annotation is one open door. Deny by default so the forgotten case fails closed.
- Prove a state change safely: read, write, read, undo, read. A server-assigned primary key is the cleanest evidence a real row was created — and restoring the original state is non-negotiable.
- The cheapest way to prove a gap is a bug and not the design is a control test: show a sibling method correctly refusing the exact same request.
- When you first see
{"d":...}, you are looking at a .NET.asmxservice — enumerate its methods and test each one anonymously.
Disclosure & redaction
This was independent bug bounty research, reported to FIS through its bug bounty program and triaged as a P1 / Critical authentication bypass. All testing was deliberately non-destructive and reversible: every change made during the proof was immediately undone, and the configuration was confirmed back to its original state.
I have withheld the target host, the product name, the service and method names, the backend entity type and the specific identifiers used. Those details are not necessary to understand the issue, and publishing them while remediation is in progress would help no one but an attacker. The screenshots are redacted for the same reason — what you see is enough to verify the finding, and no more. This writeup is published for educational and defensive purposes.