I recently got introduced to how vehicles are registered in the UK. I learned a lot while looking into this topic. This blog post will be a mix of two topics: a summary of how the plates are registered and a script to brute-force unknown vehicle registration numbers.
Vehicle Registration
I will be summarizing mostly from the "Vehicle registration numbers and number plates" (INF104 Vehicle Services) PDF by the government of U.K. (Source #1)
The "registration numbers" (which also include the letter values) are a way to identify vehicles that are owned by the Secretary of State. According to the government of U.K., "The registration number is given to the vehicle, not the registered keeper. It will stay with the vehicle (until the vehicle is broken up, destroyed or exported permanently out of the country) unless the registered keeper applies to take it off and put it on another vehicle or on to a retention certificate (V778)". The current format was introduced on September 1, 2001. It consists of:
2 letter memory tag (these refer to the region in the country where a vehicle is first registered)
"I" (capital "i"), "Q", and "Z" are not used in local memory tags
2 numbers (these tell you when it was issued)
a space and 3 letters chosen at random
"Z" can be used as a random letter
The memory tags can tell us what region the vehicle was first registered in. Using the example above, "BD" would tell us the vehicle was registered in Birmingham. For the age, "51" would tell us it was registered between Sept 2001 and Feb 2002. The last three values are just random letters. The PDF listed in the Sources section below has a great cheat-sheet for all of the regions where this is applicable. With this information about a vehicle, as an OSINT Analyst, we can make assumptions about where an individual lives based on their vehicle registration. However, there is the possibility of a person buying a vehicle registered in another region.
From an OSINT perspective, there will be times when information is limited or redacted, and we must make an assumption or educated guess to move the investigation further. For example, using the registration number above, imagine if we were given only the first six values: BD51 SM. We do not know the last value. Based on our research, we can assume it is an alphabetical letter between A and Z, but we cannot say which one for sure. Using code to automate this process, we can quickly generate all 26 possible values for the registration number and check those with the U.K. Driver & Vehicle Licensing Agency's database. There is a website that I had found that does this well: https://www.partialnumberplate.co.uk/. However, I did not find many other websites like this, so I decided to write my own code to automate this process.
Registration Number Wildcard Search
The code I had written works, but it definitely is not beginner-friendly. I will show the code first, then describe what it does.
#!/usr/bin/python3import stringimport requestsimport timeAPI_key ="API_KEY_HERE"#Enter your API key here#format for vehicles is LLNN LLL (L=Letter; N=Number)first_letter =list(string.ascii_uppercase)#DVLA does not use I, Q, or Z in memory tagsfirst_letter.remove("I")first_letter.remove("Q")#might have to removefirst_letter.remove("Z")second_letter =list(string.ascii_uppercase)second_letter.remove("I")second_letter.remove("Q")#might have to removesecond_letter.remove("Z")third_digit =list(string.digits)# remove 3,4,8,9 as they currently cannot be possible valuesthird_digit.remove("3")third_digit.remove("4")third_digit.remove("8")third_digit.remove("9")fourth_digit =list(string.digits)fifth_letter =list(string.ascii_uppercase)sixth_letter =list(string.ascii_uppercase)seventh_letter =list(string.ascii_uppercase)#Tracking the amount of wildcards in the plate - max 3 currentlyalphabet_wildcard =0number_wildcard =0# Can be used to output a list of all possible values as well - might have to dedup after deffull():# license_plate = "@@$$@@@" #Removed space to make it easier to use with API; @ for letter, $ for number# used symbols to mitigate the issue of overwriting true positive license characters license_plate ="@@$$@@@"#All letters and numbers are unknown; NOT Recommended.for first in first_letter:for second in second_letter:for third in third_digit:for fourth in fourth_digit:for fifth in fifth_letter:for sixth in sixth_letter:for seventh in seventh_letter: print(license_plate.replace("@", first, 1).replace("@", second, 1).replace("$", third, 1).replace("$", fourth, 1).replace("@", fifth, 1).replace("@", sixth, 1).replace("@", seventh, 1))
license_plate ="AA1$AAA"#Removed space to make it easier to use with API; @ for letter, $ for numberdefformat_check():#Simple check; can be expanded to check each position, but deemed not necessaryiflen(license_plate)!=7:print("License Plate Size Incorrect")exit()defwildcards():# Using global to be able to modify the variables outside of the functionglobal alphabet_wildcardglobal number_wildcard#Search for "@" and "$" in licence plate to see how many wildcards to work off offor index in license_plate:if index =="@": alphabet_wildcard +=1if index =="$": number_wildcard +=1#Cue the API to output information regarding a specific registration numberdeflicense_print():global alphabet_wildcardglobal number_wildcard# These do NOT account for the I,Q,Z not referenced in the memory tags - TLDR; extra outputif alphabet_wildcard ==1and number_wildcard ==0:for upper inlist(string.ascii_uppercase):print("Post request submitted for: {}".format(license_plate.replace("@", upper)))api_request(license_plate.replace("@", upper))elif alphabet_wildcard ==0and number_wildcard ==1:for digit inlist(string.digits):print("Post request submitted for: {}".format(license_plate.replace("$", digit)))api_request(license_plate.replace("$", digit))elif alphabet_wildcard ==1and number_wildcard ==1:for upper inlist(string.ascii_uppercase):for digit inlist(string.digits):print("Post request submitted for: {}".format(license_plate.replace("@", upper).replace("$", digit)))api_request(license_plate.replace("@", upper).replace("$", digit))elif alphabet_wildcard ==2and number_wildcard ==1:for first inlist(string.ascii_uppercase):for second inlist(string.ascii_uppercase):for digit inlist(string.digits): print("Post request submitted for: {}".format(license_plate.replace("@", first, 1).replace("@", second,1).replace("$", digit)))
api_request(license_plate.replace("@", first, 1).replace("@", second,1).replace("$", digit))elif alphabet_wildcard ==1and number_wildcard ==2:for upper inlist(string.ascii_uppercase):for first inlist(string.digits):for second inlist(string.digits): print("Post request submitted for: {}".format(license_plate.replace("@", upper, 1).replace("$", first,1).replace("$", second, 1)))
api_request(license_plate.replace("@", upper, 1).replace("$", first,1).replace("$", second, 1))elif alphabet_wildcard ==3and number_wildcard ==0:for first inlist(string.ascii_uppercase):for second inlist(string.ascii_uppercase):for third inlist(string.ascii_uppercase): print("Post request submitted for: {}".format(license_plate.replace("@", first, 1).replace("@", second,1).replace("@", third, 1)))
api_request(license_plate.replace("@", first, 1).replace("@", second,1).replace("@", third, 1))elif alphabet_wildcard ==0and number_wildcard ==3:for first inlist(string.digits):for second inlist(string.digits):for third inlist(string.digits): print("Post request submitted for: {}".format(license_plate.replace("$", first, 1).replace("$", second,1).replace("$", third, 1)))
api_request(license_plate.replace("$", first, 1).replace("$", second,1).replace("$", third, 1))#not working for some reasonelse: ("Currently not supported")exit()defapi_request(reg_num):# Actual Environment#url = 'https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles'# Test Environment url ='https://uat.driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles' payload ="{\n\t\"registrationNumber\": \"%(reg)s\"\n}"%{'reg': reg_num}# String interpolation with % headers ={'x-api-key': API_key,'Content-Type':'application/json'} response = requests.request("POST", url, headers=headers, data = payload)#Using print(response.text.encode('utf8')) outputs lines with b' which then needs .decode("utf-8") to fixprint(response.text) time.sleep(2)# 2 second time out to prevent floodingif__name__=='__main__':format_check()#full()wildcards()license_print()
There are two functions that print out the registration numbers: full and license_print. The full function is disabled by default, but outputs all possible values for the registration numbers. It takes a while to output all. I kept it there as a proof of concept of how to get all of the values. license_print is there to output the registration numbers based on wildcards. In the code, I use "$" for a numerical wildcard, and a "@" for an alphabetical wildcard. The code will not work as expected until you put in an API key at the top. I requested an API key from the DVLA (currently located at: https://register-for-ves.driver-vehicle-licensing.api.gov.uk/) and got the key overnight. Keep in mind, the more alphabetical wildcards there are, the more output there will be. The code currently is set up to query the test API, so un-comment the regular API to query actual data. You will then have to comment out the test API line. Below is output I got from the test API. I know the value of "AA19AAA" would give me a result, so I made the position of the "9" to be the wildcard: AA1$AAA. This wildcard would go from AA11AAA to AA19AAA. We can see this in the following output of the code:
Post request submitted for: AA10AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA11AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA12AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA13AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA14AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA15AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA16AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA17AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA18AAA{"errors":[{"status":"404","code":"404","title":"Vehicle Not Found","detail":"Record for vehicle not found"}]}Post request submitted for: AA19AAA{"registrationNumber":"AA19AAA","artEndDate":"2025-03-30","co2Emissions":300,"engineCapacity":2000,"euroStatus":"EURO1","markedForExport":false,"fuelType":"PETROL","motStatus":"No details held by DVLA","revenueWeight":0,"colour":"RED","make":"FORD","typeApproval":"M1","yearOfManufacture":2019,"taxDueDate":"2024-07-31","taxStatus":"Taxed","dateOfLastV5CIssued":"2019-05-20","wheelplan":"2 AXLE RIGID BODY","monthOfFirstDvlaRegistration":"2019-03","monthOfFirstRegistration":"2019-03","realDrivingEmissions":"1"}
If you want to use jq to make the output look much cleaner, you can remove the print statements. There are a lot of guardrails I contemplated on including in this code. If I was to include the guardrails for the positions of the wildcards to be correct and making dictionaries for all combinations of the first two letters, that would end up taking a lot of lines of code. I do have that at the back of mind, and might consider it, if time and interest permits.
Code Limitations
The code is limited to 3 wildcards. I felt like any more than 3 wildcards, and you are just brute-forcing a government portal without any actual direction of what you are looking for. My code is a bit on the greedier side. It outputs more than is needed. In instances where a wildcard is one of the first 2 letters, it would print out all letters from A-Z, without excluding the letters not used: "I", "Q", "Z". If I ever do a revisit to this code, or a redo, I plan on making it exclude those values and be able to be more precise in the output. My goal was to make a code that just works. I was not able to find many resources that did just this, so I decided to make code that .... well, just works.
Updated Code
# Import modulesimport requestsimport timeimport sysimport stringimport argparseimport signal # To catch the KeyboardInterrupt when CTRL-C is run#Parsing the command-line argumentsparser = argparse.ArgumentParser( prog='licenseV2.py', description='This code uses UK partial plates to convert that into potential plates. It then searches the DVLA API with that information.',
epilog='Created by: Haris Qazi. Assisted by: AccessOSINT.')parser.add_argument("license", type=str)parser.add_argument('--api', help='Use API Key. If no key is provided, it will not search DVLA database.')args = parser.parse_args()# Plate Valuesmemory_tag =list(string.ascii_uppercase)memory_tag.remove("I")memory_tag.remove("Q")memory_tag.remove("Z")third_number = ("0","1","2","5","6","7")# fourth number is done in the loops themselvesrandom_letter =list(string.ascii_uppercase)random_letter.remove("I")random_letter.remove("Q")# The following follow a "waterfall effect". wildcards_plates -> wildcard_plates -> plates. The wildcard plates get placed on the top and then one by one the wildcards are removed.
wildcards_plates = [] # 3 wildcards -> which turn to 2wildcard_plates = [] # 2 wildcards -> which turn to 1plates = [] # Completed List; 1, which turn to 0# Command Line Inputpartial = args.license.upper().replace(" ", "")# Gets the indexes of where wildcard characters are.# uses list compressionindices = [i for i, char inenumerate(partial)if char =='?']#count the amount of wildcardswildcard =len(indices)# Checkif args.license =="":print("No argument provided")exit()eliflen(partial)!=7:print("Size Incorrect")exit()elif"?"notin partial:print("No Wildcards found")exit()elif wildcard >3or wildcard <1:print("Only 1 - 3 Wildcards Allowed")exit()# ---------------------------------------------------------------------------------------------defone_wildcard():# Works for ?D51AAA and B?51AAAif indices[0]<2:for letter in memory_tag: wildcards_plates.append(partial.replace("?", letter, 1))# BD?1AAAelif indices[0]==2:for number in third_number: wildcards_plates.append(partial.replace("?", number, 1))# BD5?AAAelif indices[0]==3:if partial[2]=="0":for number inrange(2, 10): wildcards_plates.append(partial.replace("?", str(number), 1))elif partial[2]=="2"or partial[2]=="7":for number inrange(1, 4):#Until February 2024 wildcards_plates.append(partial.replace("?", number, 1))else:for number inlist(string.digits):#could have done a set, but would be overkill wildcards_plates.append(partial.replace("?", number, 1))# BD51?AA and BD51A?A and BD51AA?elif indices[0]>3:for letter in random_letter: wildcards_plates.append(partial.replace("?", letter, 1))# ---------------------------------------------------------------------------------------------deftwo_wildcards():for p in wildcards_plates:if indices[1]<2:for letters in memory_tag:#print(p.replace("?", letters)) wildcard_plates.append(p.replace("?", letters, 1))# BD?1AAAelif indices[1]==2:for number in third_number: wildcard_plates.append(p.replace("?", number, 1))elif indices[1]==3:if p[2]=="0":for number inrange(2, 10): wildcard_plates.append(p.replace("?", str(number), 1))elif p[2]=="2"or p[2]=="7":for number inrange(1, 4):#Until February 2024 wildcard_plates.append(p.replace("?", str(number), 1))else:for number inlist(string.digits):#could have done a set, but would be overkill wildcard_plates.append(p.replace("?", number, 1))else:for letter in random_letter: wildcard_plates.append(p.replace("?", letter, 1))# ---------------------------------------------------------------------------------------------defthree_wildcards():for p in wildcard_plates:if indices[2]<2:for letters in memory_tag:#print(p.replace("?", letters)) plates.append(p.replace("?", letters, 1))elif indices[2]==2:for number in third_number: plates.append(p.replace("?", number, 1))elif indices[2]==3:if p[2]=="0":for number inrange(2, 10): plates.append(p.replace("?", str(number), 1))elif p[2]=="2"or p[2]=="7":for number inrange(1, 4):#Until February 2024 plates.append(p.replace("?", str(number), 1))else:for number inlist(string.digits):#could have done a set, but would be overkill plates.append(p.replace("?", number, 1))else:for letter in random_letter: plates.append(p.replace("?", letter, 1))# ---------------------------------------------------------------------------------------------defapi_request(reg_num):# Actual Environment#url = 'https://driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles'# Test Environment url ='https://uat.driver-vehicle-licensing.api.gov.uk/vehicle-enquiry/v1/vehicles' payload ="{\n\t\"registrationNumber\": \"%(reg)s\"\n}"%{'reg': reg_num}# String interpolation with % headers ={'x-api-key': args.api,#using the argument for --api'Content-Type':'application/json'} response = requests.request("POST", url, headers=headers, data = payload)#Using print(response.text.encode('utf8')) outputs lines with b' which then needs .decode("utf-8") to fixprint(response.text)#time.sleep(2) # 2 second time out to prevent flooding# ---------------------------------------------------------------------------------------------# Not Needed - felt like it would make the output cleandefsigint_handler(signal,frame):print ('KeyboardInterrupt Error Caught') sys.exit(0)defmain():global plates signal.signal(signal.SIGINT, sigint_handler)if wildcard ==1:one_wildcard() plates = wildcards_plateselif wildcard ==2:one_wildcard()two_wildcards() plates = wildcard_plateselse:one_wildcard()two_wildcards()three_wildcards()#plates = * not needed, as we are writing to plates variable directly# Check if API key is provided hereif args.api isnotNone:for plate in plates:api_request(plate)else:for plate in plates:print(plate)if__name__=="__main__":main()
To run the code, simply run python3 licenseV2.py "license_plate", where you replace the string with the partial plate with the wildcard ?. An example of this would be python3 licenseV2.py "AA1?AAA" on Linux. This would output all possibilites for the 4th value. If you have an API key and would like to use it, use the --api argument and provide the key in quotes after that, such as: python3 licenseV2.py "AA1?AAA" --api "ThisIsMyAPIKey". If the key is provided, the DVLA API will be queried. If not, the plates will be printed out.
Here are the following changes updated in this version of the code:
Replacing the wildcard from $ and @ to just ?
Command line arguments for the registration number and the API key