How to Tell If Your Web App Was Breached: A 17-Hour Attack
In short
"One IP sent 17,108 requests at a client's API over 17 hours. Here is how five sets of logs proved nothing got through, and what was fixed within 24 hours."
On the night of Monday 28 September 2026, someone spent 17 hours trying to break into the API of a client of mine. I'm the only engineer on that system, so the investigation was mine to run.
I found out the next afternoon from a pile of strange form emails in the client's own inbox. The question was simple: did any of it work?
It didn't. But "nothing got in" is easy to say and harder to prove. This post shows how I proved it, what the attack found that was genuinely wrong, and what changed within 24 hours of the first request. Every number comes from the logs.
The attack in numbers
| Measure | Value |
|---|---|
| Requests | 17,108, all from one IP address |
| Where from | A rented server in a Netherlands hosting range |
| How long | 17 hours 2 minutes, from 10:37 pm Monday to 3:40 pm Tuesday, Brisbane time |
| Busiest hour | 15,436 requests between 3 am and 4 am, 90% of the total |
| Peak rate | 34 requests a second |
| Answered "not found" | 81% |
| Answered with an error in the 400 range | 98.9% |
| Successful requests to anything that needs a login | 0 |
| Emails sent to anyone outside the client's business | 0 |
| First request to both fixes merged | 23 hours 40 minutes |
What happened?
One computer ran scripts and an off-the-shelf exploit scanner against the API for most of a night and a day. It came in stages.
- Guessing. It tried 29 made-up web addresses on the client's domain, such as staging and admin, looking for forgotten systems. All of them returned "not found".
- Form attacks. It filled the contact, support and lead forms with SQL injection strings, hidden line breaks and null characters. Rate limits slowed it down.
- Logged-in probing. It signed in with a real account that had no role, then called the admin and billing endpoints. Every call was refused.
- A scanner sweep. Between 3 am and 4 am it fired WordPress, PHP and Java exploits at an API written in .NET, including Log4Shell. None of those pages exist, so nearly all of it bounced.
- A second round. Tuesday afternoon brought more email header and null character tricks through the forms.
It used Python and curl scripts, and rotated through 912 different browser names to look like ordinary visitors. Its Log4Shell test pointed at a callback domain used by nuclei, a popular open source scanner.
Was it an attack or a probe? The honest term is a targeted, automated probe. It was one operator with scripts, not a botnet and not a denial of service. But it went past random scanning. It held a working login and guessed addresses that only make sense for this app. I blocked the whole hosting range it came from.
How do you prove an attacker didn't get in?
You check that several independent records agree, and that none of them show a successful request on a protected page, an unexpected database write or data leaving the system.
Each record answers a different question:
| Record | Question it answers | What it showed |
|---|---|---|
| Load balancer logs | What arrived, and from whom? | 17,108 requests from one IP. 1,888 were turned away before they reached the app |
| Application logs | What did the app do with each request? | Every request to a protected endpoint was refused |
| Database command logs | What was written to the database? | Only the public contact and support forms. Zero SQL syntax errors |
| Traces | Did anything leave the app? | Fewer than 40 of 15,252 traces made an outbound call, and every one was expected: form emails and sign-in checks |
| Email delivery records | Where did mail go? | 25 emails sent and delivered, all to the client's own inbox |
None of these alone is proof. Together they are. The load balancer count matched the traces once the requests it turned away were taken out. The email totals matched the app's "sent email" log lines. The database writes matched the form posts that were accepted. When five sources tell the same story, you can trust it.
I started the investigation at 5:54 pm on Tuesday. The first answer, that nothing got through, came 14 minutes later. The trace check agreed 8 minutes after that. That speed was only possible because the logs were switched on before anything happened.
Why did the SQL injection fail?
Because the app never builds SQL by joining strings. Every database write sends values as parameters, separate from the SQL text, so an injection string is stored as plain text and never runs.
The numbers back that up:
- About 730 requests had SQL injection in the web address. All of them got "not found", because those routes don't exist.
- PostgreSQL logged zero syntax errors, error code
42601. A working injection usually causes one. - The only database errors were null characters refused as values, error code
22021. The database rejected the data, not the query. - The slowest response all night was 1.86 seconds, a normal call. So there was no sign of a time-based blind injection either.
The app is built on ASP.NET Core and Entity Framework Core, which sends normal queries and saves as parameters for you. The OWASP SQL Injection Prevention Cheat Sheet explains why this works. Microsoft's guide to raw SQL queries in EF Core covers the one place it can still go wrong.
A valid login is not access
The attacker held a working login. It didn't matter. Its 237 calls to protected endpoints were refused in about a millisecond each, before any code or query ran.
That's because every endpoint asks for a named role, such as a manager or an admin role. No endpoint accepts "anyone who is logged in". I checked all 154 endpoints across 27 controllers that night to be sure.
If your app trusts any signed-in user, fix that first. Sign-up is often open to anyone, so a login on its own proves very little.
What did the attack find that was actually wrong?
Nothing got through, but the attack still found real problems. That's the useful part.
- A null character in a form field reached the database and caused a server error, instead of a polite "invalid input".
- An email address with a hidden line break passed a standard email check, was saved, and split one log entry into two. That's called log injection, and it can be used to fake log lines.
- A sign-up step that had no real traffic yet had a wrong credential. The attacker found it before any customer did.
- Rejected form posts were logged as server errors while the visitor saw "bad request", because the request logger sat in the wrong place.
- The logs didn't record which user sent each request, so the refused calls couldn't be tied to an account that night.
Two sets of fixes were merged at 10:09 pm and 10:18 pm on Tuesday, 23 hours 40 minutes after the first request:
- One shared set of rules now rejects line breaks and control characters in every free-text field. Bad input gets a clear rejection and nothing is stored.
- A credential check runs when the app starts and every morning, and raises a critical alert if a sign-in secret is wrong.
- Every request log line now records the client IP and the user.
- AWS WAF now sits in front of the API with AWS managed rules and per-IP rate limits, and the attacker's hosting range is blocked.
- The alarms were re-tuned using the attack's own data, and production alerts now arrive by SMS as well as email.
The app change added 89 tests, and all 1,355 backend tests passed. One of the new tests replays both attack payloads. Run against the old code, it reproduces the original errors exactly.
Why didn't the alarms go off?
They were set for a busy system, and this one was quiet. The server error alarm needed several errors inside five minutes. The attacker's few server errors were spread across the night, so it never fired. Nothing watched for a surge of "not found" or "forbidden" responses.
So the first warning came from the inbox, not the monitoring. The first junk form email arrived at 11:19 pm on Monday. I started looking at 5:54 pm on Tuesday.
Every new threshold was then set from the attack's real five-minute counts, and every alarm was tested against real log lines before it shipped. That testing caught a log filter that would have matched nothing at all. The next day the new alarm fired on an unrelated scanner hunting for leaked config files, and cleared itself 15 minutes later. That's what a working alarm looks like.
What does this protection cost?
Less than most owners expect. The web application firewall costs about US$14 a month before GST for one web ACL and seven rules in Sydney, plus US$0.60 per million requests, based on AWS WAF pricing in September 2026. The whole attack added about 10 MB of app logs.
The logs that proved nothing got through were already part of the normal hosting bill. The expensive part of an attack is not knowing what happened.
Do you have to report an attack like this?
Only if it's an eligible data breach. Under the OAIC's Notifiable Data Breaches scheme, you must tell the OAIC and the people affected when personal information is accessed or lost and serious harm is likely. Write down your assessment either way.
This attack accessed no personal information, so there was nothing to notify. Cybercrime in Australia can be reported to the Australian Signals Directorate through ReportCyber.
What I'd tell another business owner
- Switch on access logs for your website and app now. You can't investigate what you didn't record.
- Keep production logs for at least 90 days. Most attacks are noticed late.
- Make sure every page that shows data checks for a specific role, not just a login.
- Reject strange characters at the form, before they reach your database or your logs.
- Test your alarms on real traffic. A threshold that suits a busy system is blind on a quiet one.
- Check the features nobody uses yet. Broken settings hide where there's no traffic.
- Save the evidence early. Logs and traces expire, some after only 14 days.
If you're not sure your own app would hold up, this is the kind of review I do as part of software project rescue.
For your IT person
The investigation used five sources and cross-checked them.
- Load balancer: ALB access logs in S3, kept for 90 days. I downloaded the 298 log files for the window and parsed them with a small Node.js script, grouping by IP, host, status, path, user agent and five-minute bucket.
- App logs: Serilog to CloudWatch, queried with CloudWatch Logs Insights for request lines, validation results and CORS rejections.
- Database: EF Core's "Executed DbCommand" log lines, with parameter values redacted. Together they list every statement the app ran. I counted every INSERT, UPDATE and DELETE in the window by table, then searched for PostgreSQL error codes 42601 and 22021.
- Traces: AWS X-Ray through the AWS Distro for OpenTelemetry collector, with no sampling. I pulled every trace in the window except load balancer health checks, then opened each one that made an outbound call.
- Email: SES CloudWatch metrics for sent, delivered, bounced and rejected, plus Virtual Deliverability Manager message insights for the message IDs in the app's "sent email" log lines.
Database spans are deliberately not exported to X-Ray from any live environment. The EF command log already gives a complete list of writes, and it means one less copy of query shapes leaving the app.
Things worth checking in your own ASP.NET Core app:
- Middleware order decides whether your controls work at all. Three weeks before the attack, a scan found the rate limiter registered before routing and authentication, which made every policy inert. That fix is the only reason the form limits worked on the night.
- The request logger has to sit outside the exception handler, or 400s get logged as 500s.
- Serilog writes levels as
[14:03:53 ERR]. A CloudWatch metric filter for"[ERR]"matches nothing, because the timestamp sits between the bracket and the level."ERR]"works. Test every pattern withtest-metric-filterbefore you rely on it. - WAF path rules must decode, normalise and lowercase the path the same way the app routes it, or simple tricks slip past.
- Reject control characters with one shared validation rule on every free-text field. A standard email validator let a line break through.
Need your app checked?
Hireadev is run by Ryan Brooker in Brisbane. I build and look after .NET systems for Australian businesses, and connect them to the tools they already use through system integrations. If you want someone to check how your app would hold up, book a free 30-minute chat.
FAQ
Want a second opinion on your project?
Book a free 30-minute call with Ryan. Straight answers, no sales pitch.
Book a free call