All Your Parking Tickets Are Belong to Me

January 22, 2026 · #api #reversing #security · 23 views

In December of 2024, I was paying a parking ticket that I had gotten in Tampa after foolishly parking next to a fire hydrant (seriously, the dude took a measuring tape and wrote down the exact distance I was away from it. Really? They can't pay you enough for that).

Image.png

(9 feet, 8 inches by the way.)

After being pissed off about the fact that was going to cost me $34 dollars, I flipped the ticket over and saw that the City of Tampa was using a system called RMCPay to collect payment for citations.

I found the payment process surprisingly convenient. Once I was back home, I visited the site, entered my license plate number, and discovered it had retrieved my complete citation history from Tampa.

Interesting! My nosiness as an engineer got the better of me, so I opened DevTools and replayed the request to examine its structure.

A request looked something like this:

https://rmcpay.com/rmcapi/api/violation_index.php/searchviolation
?operatorid=[a unique number]
&violationnumber=[can also be a parking ticket ID]
&stateid=[the internal system ID of the state]
&lpn=[a license plate number]
&vin=
&single_violation=0 [meaning get more than just one ticket]

Looks like a well-structured request! I tried fiddling around with the parameters lightly, but couldn't really get anything working to get more data than I was supposed to.

After clicking on the "View More Information," which is used to actually get specific violation information, I saw a second request in the Network tab.

https://rmcpay.com/rmcapi/api/violation_index.php/getviolationimages
?operatorid=[a unique number]
&violationid=[the parking ticket ID]

This returns a JSON object of Base64 encoded images that are then embedded on the page. I thought to myself, "that ID number looks very .. not randomized"! I decided to copy the request into cURL, change the ID to N+1, and fire it off.

Nothing. Body just said "violation not found". Fair enough!

Until I had an idea: what if I just... changed the operator ID to something else? Surely there wouldn't be a global query parameter that would allow me to see other ticket photos, right?

I changed the operator ID to 0, again added the ID as N+1, and sent the request off again.

Image.png

... that's not my car!

Didn't they teach y'all not to use sequential IDs?

Alright. So now I know that using operator ID as zero bypasses whatever ID they pass in. This is a good start! I wrote a small Python script to start counting up on the getviolationimages route and sure enough, it started spitting out lots and lots of 200s with pictures of violations that had all been written in the same day around the same time. But every few requests (maybe every 50-100), it would spit out a violation not found error across some IDs. I figured it was either a product of ratelimiting and didn't think much of it.

I started to then wonder - was this operator ID "issue" across the entire site too? I went back to the first searchviolation request and decided to see if I could remove the license plate parameter, add my own violation ID and operator ID, and see if that would give me information about the person's ticket too.

https://rmcpay.com/rmcapi/api/violation_index.php/searchviolation
?operatorid=0
&violationnumber=1234567
&lpn=ABC123D

I got kicked back with a familiar error: violation not found. I knew that wasn't right, because the same violation number gave me the photos of whoever's car that was, so I tried with my actual ticket number. Same error. This told me that I was probably missing some parameter from the request and needed to provide more information.

I decided to pass the operator ID from the original request, which was 1371(further research made me realize that this was internally just an ID given to a parking enforcement officer or the entire city, and is used to track the tickets written). Sure enough, with the correct operator ID, license plate, and incorrect violation number, I was able to view all the information of the parking ticket that wasn't really my parking ticket.

The information inside of a violation is... expansive. Some of the fun parts include:

  • License plate number
  • VIN number
  • Make, model, color and year (sometimes) of the vehicle
  • The location (which is up to the officer - this sometimes was an address, or other times a description of the area).
  • How much the ticket was
  • If the ticket was paid, and if the ticket was late on payment
  • If the car had a boot put onto it, and if the boot was enabled or disabled
  • Sometimes, the name of the parking enforcement officer who wrote the ticket (other times, it's just a generic City of x)

But dear reader, you must be thinking to yourself: "Jack, it's fine! You have to have the operator ID of each state, and a license plate, which must not be easy to find right? It would be really hard to enumerate through all of the system's parking tickets!"

It wasn't hard to enumerate through the system.

After far more digging through this site than I should have, I ended up finding a buried route inside of a JavaScript file: getviolationoperatorinfo. I'm assuming the route is used somewhere on a backend management portal to get information about who wrote the ticket, but that's absolutely not what I decided to use it for.

The request looks something like this:

https://rmcpay.com/rmcapi/api/violation_index.php/getviolationoperatorinfo
?operatorid=[the operator's ID]
&violationnumber=[the parking violation ID]

Which in turn, gives this:

{
"status": 200,
"data": {
"operators": [
{
"ticketnumber": [a ticket number],
"datecreated": [the date the ticket was written],
"lpn": [license plate number],
"vin": [VIN number],
"operator_name": [the operator's name],
"operator_id": [the operator's ID],
"subdomain": [the subdomain of the city / place using RMCPay],
"operator_location": [where the operator wrote it],
"redirect_url": [a URL to pay the ticket]
}
]
}
}

And for the smart reader who's starting to put pieces together, this is the exact body we need to enumerate through tickets! That's great, but we still need the operator ID right?

Wrong. I was able to write a script that called getviolationoperatorinfo with operator ID as 0 and my enumerated ticket ID, verify that the ticket existed, and then used the license plate / VIN number from that request to pass it into searchviolation, giving us full access to the violation.

Image.png

The kicker about using the global operator ID variable, along with enumerating through the IDs, meant that I was able to get tickets across many, many, many cities. While I don't know the exact number of customers they have (yet), doing a quick Google dork with "rmcpay.com" reveals pages upon pages of cities that rely on RMCPay for collecting parking citation payments. Which means not only was the City of Tampa's parking violators at risk of their data being scraped, but also anyone that had ever gotten a ticket in any city that uses RMCPay.

Image.png

I probably should tell someone about this.

After I fooled around with the site for a few hours, I had forgotten to pay my parking ticket and had now incurred myself a $10 late fee, on top of the $34 fine I already owed.

Image.png

Shortly after I found this issue, I unfortunately went into an incredibly busy period in my life. The proof of concept sat for three months (this will be a recurring theme), and in March of 2025, I verified again that the data was still enumerable. I had realized they now implemented some form of ratelimiting on enumeration attempts, but not a very strict one - 150 requests in a 60 second period, followed by a 60 second cooldown. It's better than what was initially there (nothing), but I still decided to send them an email through their customer support chat system to let them know I had found something.

Image.png

I checked my email over the next few weeks and .. nothing. I never got anything back! Again, I slipped into a busy period of life and shortly after moved to Seattle for a security engineer internship where I learned so much valuable information about website penetration testing, and especially why enumerable IDs were never a good thing.

In July of 2025, I decided to make a more proactive effort to reach out to Passport (the parent company of RMCPay) and explored every channel I possibly could. I sprayed-and-prayed, emailing their customer support email, their privacy request email, and their sales team hoping that anyone would read what I was saying and get back to me. I tagged the CEO on Twitter, mentioned the Passport, Inc. account directly - doing just about everything except going to their in-person office and knocking on the door.

Eventually I got a generic email back that I need to submit yet another request for "technical support". When I clarified that I really didn't need technical support and that I was the one trying to provide the technical support, I was given a phone number to call.

The phone call was one of the most bizarre that I had. The representative I talked to had no idea what I was trying to tell him (which, valid, if I had some nerd calling me up and telling him I could get all the data of a parking ticket I'm not sure what I'd do either), and when I asked if there was a manager or supervisor I could talk to, he just said "No. Not really." and hung up.

Okay! At this point I was completely lost, so I reached out to a friend who I knew had navigated the responsible disclosure process and asked for advice. They were kind enough to see if they could scrape up a few more contacts, and on July 31st, 2025 I fired off my very official responsible disclosure notification.

Image.png

I outlined the issue that I had found in the API, how to reproduce it, and offered to talk in this email. I also mentioned that I was going to disclose this in 90 days, unless I heard from them otherwise. I hate applying pressure like that, but I really hoped it was going to light a fire under ANYONE at the company and they'd finally get back to me.

175 days later...

Yeah, so that didn't happen. The enumeration issue still very much exists, and I actually found a way to bypass their ratelimiting as well. Like stated before, RMCPay is a product of Passport Parking, who also does ParkMobile-esque time-based parking in multiple cities.

When I launched that website (https://ppprk.com), I opened up DevTools and saw a very familiar request in there.

https://parkbyapp.ppprk.com/mobile/api/index.php/sendrmcpayrequest?
operatorid=[operator ID]
&violationnumber=[violation number]
&endpointPath=searchviolation
&router=violation_index
&domain=parkbyapp.
&lpn=[license plate number]

Under the hood, Passport Parking actually uses RMCPay's backend for parking violations as well! The sendrmcpayrequest took the parameters endpointPath (which was the endpoint) and router (which was the PHP file that we were routing the request to), along with all the query parameters we were previously familiar with.

In my proof of concept using this API route, I was able to gather ~100,000 parking violations with this method in under 24 hours. At scale with proxies, you most likely could scrape into the millions per day.

Remember when I said things were sequential?

Turns out they're kind of not! Despite seeming sequential in the database, dates often jump around quite a lot. Internally, tickets have two reference numbers: a violation_id, and then a violation_number. Neither of these are sequential, meaning that while I can enumerate ticket id + 1, it doesn't actually guarantee it's the most recent ticket written in the system.

I have no idea the engineering decision behind this, but I found tickets dating back to the mid-2010s, so I'm assuming it exists as some kind of technical debt or workaround for a legacy system.

Image.png

BONUS ROUND: As If Sequential Tickets Weren't Enough

While doing this API exploration, I also found a route called getoperatorinfo, and when combined with an operator ID, returned a JSON body that looked something like this:

{
"operator_id": 1,
"status": "active",
"name": "Sample Operator",
"parentname": "Parent Organization",
"id": "1",
"parentid": "100",
"subdomain": "example",
"contactname": "John Doe",
"contactnumber": "555-0100",
"parkerhelpnumber": "555-0200",
"parkerhelpemail": "[email protected]",
"technical_email": "[email protected]",
"address1": "123 Main Street",
"operator_location": "City, State",
"ticketing_url": "tickets.example.com",
"ticketing_transactionfeeincents": "250",
"ticketing_conveniencefeeincents": "250"
}

I sat there hoping and praying that this enumeration issue existed only on their ticketing system and that I wouldn't possibly be able to get a list of all customers that use Passport's system.

λ cat found_operators.json | jq '{operators: ([.[].operator_id] | uniq
ue | length), parents: ([.[].parentid] | unique | length)}'
{
"operators": 1339,
"parents": 98
}

... anyways, remember when I said I didn't know how many customers they used yet? As it turns out, there are 1339 current operators using RMCPay / Passport Parking with 98 unique parent companies listed in their database. I used the same method as the ticket IDs to enumerate through their operators and went up pretty high ID wise! I'm not sure if more or less exist, but these seem to be the most active of them at least.

Presenting: the world's greatest parking violation data visualization project

Like any great chaos engineer, I decided to build a frontend to visualize the data, which is the magnum opus of this entire research project: parking.exposed is now live and visualizes a great deal of tickets that I've been able to collect over the last week or so. After fiddling around with ticket IDs I've managed to get within a 6 to 12 hour range of recently written tickets.

Image.png

Image.png

Image.png

There's a great heatmap visualization that allows you to see where tickets are primarily clustered. Unfortunately, ticket operators aren't required to put in the location of where tickets are written, which meant I had to use some geocoding magic to try and figure it out. I'd say a good percentage are correct, but if you see big clusters in random areas (like the water) don't blame me!

My personal favorite feature is the Pay Now button. Because I have the data of the city and the associated payment subdomain, you theoretically could pay someone's parking ticket for them!

The site's is live and is constantly refreshing, so if you want to see tickets stream in on the stats page that's absolutely an option. It was such a blast building this and really puts into perspective the magnitude of this issue.

In an ideal world, someone from Passport Parking will reach out to me after this is published and I can finally get a response from them on working to fix the issue.

If this is you, please reach out to me on any of the following:
- Email: passport [at] jacklafond.com
- Signal: @interstellar.1111

Awards Wrap Up Speech

This piece of research is probably one of the coolest I'll ever work on or release in my career, and I would be remiss if I didn't mention some incredible people who helped this come to life.

  • The entirety of the Babgel group chat: Ari, Ana, Alistair, Adarsh, Conrad, Landon, Miguel, and Neesh all provided me with some incredible feedback throughout the iteration process (and proofreading of this post!)
  • Kai, who lit a fire under me to get this published and out into the world after sitting on it for far too long.
  • Bella, who attempted to help me contact them (and navigate the CVE process!)
  • Riley Walz, who not only has inspired many of my projects but also gave me a great idea to include the scope of impact on their customers.
  • Freeman Jiang, creator of citibike.nyc and the unofficial inspiration for some of the design language of the site.

Pay your parking tickets, kids!