← Writeups

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.

A terminal running a curl request that carries only an X-Bug-Bounty header and no authentication, followed by a redacted panel standing in for a 64 KB JSON response
An anonymous request — note the only header that matters is a bug-bounty tracking tag, no auth of any kind — returned the full loan-product catalog. The response body, host and method name are redacted.

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.

Why a dump matters before a write

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":[]}.

An anonymous read of a site's configuration returning an empty list, d colon empty array
The “before” state: an empty configuration for the target site. This is the value I had to restore at the end.

Step two — the write. Call the attach method anonymously. It returned {"d":null}, the .NET way of saying “done, nothing to report.”

An anonymous call to the attach method with a JSON body, returning d colon null
The write itself: an anonymous request to the attach method, acknowledged with {"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.

Reading the configuration again now returns a populated object with a server-assigned ID, where before it was empty
The “after” state: a populated row where step one was empty. The entity type name is redacted; what remains visible is that the backend created a real record with its own primary key.
This is the moment it stops being theory

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.

An anonymous call to the remove method, returning d colon null, undoing the earlier write
The remove method — the second unauthenticated write — deleting the record created a moment earlier.

Step five — confirm it is back. Read one last time. Empty again: {"d":[]}, identical to the baseline.

A final read showing the configuration is empty again, matching the baseline
Back to the baseline. Net change to their data: zero. The write primitive is proven; nothing was left behind.
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.

The same anonymous request shape sent to a protected account-deletion method returns an HTML login page instead of JSON
The control: the same anonymous request against a properly gated method returns an HTML page, not JSON. The gate is real — which proves the two write methods were mis-gated, not part of a service with no security. Host and method are redacted; internal paths are cropped out.

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:

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.

The trap

“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

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 .asmx service — 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.

Broken Access Control Authentication Bypass Unauthenticated Write ASMX / .NET Financial
Share on X

More writeups