← Home

June 30, 2026 · CERT/CC VU#785225

The Only Lock Was a License Plate

If you read the SlugMeter posts, you know I spent a while pulling parking citations out of a vendor called AIMS. Most of those portals had no lock at all. You typed a citation number, you got the whole ticket. That was the entire project.

UCSC ran a newer version of the same software. This one actually asked for two things before it showed you a ticket: the citation number and the license plate on the car. A real check, finally. I wanted to know if it held up.

It did not. And because dozens of cities and universities run the exact same software, the same gap was sitting in all of them. This is the writeup I promised, now that the disclosure window has closed and the fix has gone out.

The detail that stood out

The lookup form wanted a citation number and a plate. The idea is reasonable. You should only be able to see a ticket if you already know whose car it is. The plate is the password.

Except the password is printed on the ticket. Every physical citation has a QR code on it so you can scan and pay. That QR code points at a URL, and the URL has both values right in it.

https://[city].aimsparking.com/?c=t&tp=26SC1XXXXXcitation number_9XYZ123license plate
The QR code on the paper ticket. The license plate, the thing the website later asks you to prove you know, is sitting in plain text in the link taped to your windshield.

So the plate is not a secret. Anyone holding the ticket has it, and the citation numbers are sequential, so you can guess those too. That is already weak. But it gets worse, because it turned out you did not need the plate at all.

Identifying the backend

Before poking at anything, I wanted to know what database the site was talking to. The responses gave it away. The JSON had fields like $numberDecimal, which is how MongoDB encodes precise numbers, and the cookies were all named AW9_*. That combination, a PHP app talking to MongoDB, has a very well known weak spot.

It is called NoSQL injection. It is the lesser known cousin of SQL injection. The short version: in PHP, if a form field is named field[something], PHP turns it into an array before your code ever sees it. MongoDB, meanwhile, treats any key starting with $ as a command, not a value. So if an app takes form field names and drops them straight into a database query without checking them, you can stop sending data and start sending commands.

One field

Normally the search sends your plate as a value, and the database looks for the one record that matches it. Instead of a plate, I sent a single command: plate_vin[$exists]=true.

I picked $exists on purpose. All it does is check whether a field is present. It cannot read data and it cannot change anything. It was the smallest, most harmless thing I could send to confirm the flaw was real. And it changed what the query meant entirely.

What the form is for

plate_vin = "9XYZ123"
{ plate_vin: "9XYZ123" }

Matches one ticket. Yours.

What I sent instead

plate_vin[$exists] = true
{ plate_vin: { $exists: true } }

Matches every ticket. All of them.

The query went from "find the one ticket that matches this plate" to "find every ticket that has a plate field." That is all of them. The plate check, the only thing standing between the public and the records, was gone.

With that, any citation number plus this one command returned the full record. License plate, vehicle make, model, color, registered state, location, time, violation, fine amount, payment status. None of it required the plate I was supposedly there to prove I knew.

Bypassing the Cloudflare challenge

There was one more obstacle. The portal sits behind Cloudflare, which serves a managed challenge page (the "checking your browser" screen) before letting automated traffic reach the site. In theory that should make casual enumeration difficult.

But the QR payment shortcut, the same /?c=troute from the ticket, is handled by the application before Cloudflare's challenge runs. Requesting it returns a valid set of session cookies without the challenge ever appearing. The same shortcut built to make paying a ticket easy also handed out working sessions.

I want to be precise about what this does and does not mean, because it matters. Cloudflare was never the security boundary here. It is an abuse and rate-limiting control, not an authorization check, and the session this shortcut returns is unauthenticated. The actual problem is that the citation API accepted that unauthenticated session and never enforced its own plate check once the injection was in play. The challenge being easy to bypass only mattered because the endpoint behind it did not verify who was asking.

1

GET /?c=t

The app hands back valid session cookies. Cloudflare's challenge never runs.

2

POST /api/tickets/index.php?cmd=search_tickets

Send a citation number plus plate_vin[$exists]=true. Get back the internal ID of a matching record.

3

GET /api/tickets/index.php?cmd=get_ticket_detail

The full citation: plate, make, model, color, location, time, fine.

Two requests after the cookie handshake and you have a complete citation. There is no login anywhere in this chain.

How far it went, and where I stopped

This is the part I want to be careful about, because it is the part that matters. The same software runs parking for dozens of cities and universities across North America. On paper that adds up to millions of plate, location, and time records, which is effectively a partial map of where specific cars have been. That is the kind of data that should never fall out of a website this easily.

But I did not go collect it, and I want to be clear that I did not. I confirmed the actual injection on one deployment, UCSC's. To check whether the gap existed elsewhere, I only sent the harmless cookie request from step one and looked for an HTTP 200. That tells you the door is the same without ever touching a single record. I never enumerated other cities, and I never built a scraper for any of this.

I also went looking for where the boundaries held, because that matters just as much. The login endpoint was not vulnerable to the same trick. Account information, the emails, phone numbers, and payment details tied to permit holders, was walled off from the ticket search and stayed out of reach. Some things were built correctly, and it is only fair to say so.

Reporting it

The first problem was finding someone to tell. The vendor, EDC Corporation, had no obvious security contact. No security.txt, no disclosure page, no inbox meant for this. That is a common gap and a frustrating one, because the person who finds a bug should not have to also figure out who to hand it to.

So I took it to CERT/CC, the coordination center at Carnegie Mellon that exists for exactly this. They picked it up, assigned it VU#785225, and ran a standard coordinated disclosure on a 90-day clock. They reached the vendor, EDC built a fix, and rolled it out across customer deployments before any of this became public. I also flagged the QR code leaking the plate in the URL, with a suggestion to swap it for a one-time token so it stops ending up in browser history and server logs.

The whole thing was calm and professional, which is how it is supposed to go and is not always how it goes. CVE identifiers are pending and I will add them here once they are assigned.

What I took away

The thing I keep coming back to is that the bug was not really inside any one component. The QR code worked. MongoDB worked. Cloudflare worked. The problem lived in the seams between them. The QR code assumed the plate was private, the backend assumed form fields were just values, and the CDN assumed it saw every request. Each assumption was fine on its own. Together they left the door open.

That is the part of systems work I find genuinely interesting. The failures that matter are usually not in the boxes. They are in the wiring between the boxes, in the gap between what one layer promises and what the next one believes.

Big thanks to the engineering team at EDC Corporation for taking it seriously and fixing it quickly, and to CERT/CC for handling the coordination. First responsible disclosure done. It taught me that the interesting half of security is not finding the bug. It is everything that happens after.