Tracking GPS Locations From Digital Photos (Guide)
- Dylan Gallus

- 4 hours ago
- 5 min read

Tracking GPS Locations From Digital Photos
GPS extraction from EXIF data is standard OSINT methodology. Here's the complete technical breakdown. This is an article on tracking GPS locations from digital photos.
How It Works
Digital photos store metadata in EXIF (Exchangeable Image File Format) tags embedded in JPEG, TIFF, and RAW files. Smartphones with GPS populate these tags automatically:
EXIF Tag | Value |
GPSLatitude | Degrees, minutes, seconds |
GPSLatitudeRef | N or S |
GPSLongitude | Degrees, minutes, seconds |
GPSLongitudeRef | E or W |
GPSAltitude | Meters above sea level |
GPSTimeStamp | UTC time of GPS fix |
GPSDateStamp | Date of GPS fix |
GPSImgDirection | Compass bearing of camera |
GPSProcessingMethod | Often reveals device type |
These tags persist through basic edits, email attachments, and file uploads unless explicitly stripped.
Extraction Tools
exiftool (The Gold Standard)
bash
# Install
sudo apt install exiftool
# Dump all metadata
exiftool photo.jpg
# Extract only GPS tags
exiftool -GPS* photo.jpg
# Machine-readable output (decimal degrees)
exiftool -c "%.6f" -GPSLatitude -GPSLongitude -GPSAltitude photo.jpg
# Extract GPS from every photo in a directory
exiftool -r -GPS* -c "%.6f" /path/to/photos/
# Output as CSV
exiftool -r -csv -GPSLatitude -GPSLongitude -GPSAltitude -GPSDateStamp /path/to/photos/ > gps_data.csv
# Create a KML file for Google Earth
exiftool -r -p '<Placemark><name>%f</name><Point><coordinates>${gpslongitude#},${gpslatitude#}</coordinates></Point></Placemark>' -ext jpg /path/to/photos/ > photos.kml
Alternative Tools
bash
# identify (ImageMagick)
identify -verbose photo.jpg | grep -i gps
# strings + grep (quick and dirty, find raw GPS data)
strings photo.jpg | grep -A5 -B5 "GPS"
# jhead (lightweight, fast)
jhead photo.jpg
# exiv2 (C++ library with CLI)
exiv2 -P E photo.jpg
Python with PIL/Pillow
python
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
import sys
def decimal_coords(coords, ref):
"""Convert EXIF GPS to decimal degrees"""
decimal = coords[0] + coords[1] / 60.0 + coords[2] / 3600.0
if ref in ['S', 'W']:
decimal = -decimal
return decimal
def extract_gps(filepath):
img = Image.open(filepath)
exif_data = img._getexif()
if not exif_data:
return None
gps_info = {}
for tag_id, value in exif_data.items():
tag = TAGS.get(tag_id, tag_id)
if tag == "GPSInfo":
for gps_tag_id in value:
gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id)
gps_info[gps_tag] = value[gps_tag_id]
if not gps_info:
return None
lat = decimal_coords(gps_info["GPSLatitude"], gps_info["GPSLatitudeRef"])
lon = decimal_coords(gps_info["GPSLongitude"], gps_info["GPSLongitudeRef"])
altitude = gps_info.get("GPSAltitude", None)
timestamp = f"{gps_info.get('GPSDateStamp', 'Unknown')} {gps_info.get('GPSTimeStamp', 'Unknown')}"
return {
"latitude": lat,
"longitude": lon,
"altitude": altitude,
"timestamp": timestamp
}
if __name__ == "__main__":
gps = extract_gps(sys.argv[1])
if gps:
print(f"Latitude: {gps['latitude']:.6f}")
print(f"Longitude: {gps['longitude']:.6f}")
print(f"Altitude: {gps['altitude']}")
print(f"Timestamp: {gps['timestamp']}")
else:
print("No GPS data found.")Bulk Extraction Script
For an engagement where you've collected photos from a target (authorized, per your scope):
python
#!/usr/bin/env python3
"""Bulk GPS extraction for pentest photo analysis"""
import os
import json
import csv
from datetime import datetime
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
def decimal_from_dms(dms, ref):
degrees, minutes, seconds = dms
decimal = float(degrees) + float(minutes) / 60.0 + float(seconds) / 3600.0
if ref in ('S', 'W'):
decimal = -decimal
return decimal
def extract_all_metadata(filepath):
"""Extract GPS and all useful EXIF from a single image"""
img = Image.open(filepath)
exif = img._getexif()
if not exif:
return None
result = {"filename": os.path.basename(filepath), "filepath": filepath}
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
if tag == "GPSInfo":
gps = {}
for gps_id in value:
gps_tag = GPSTAGS.get(gps_id, gps_id)
gps[gps_tag] = value[gps_id]
if "GPSLatitude" in gps and "GPSLongitude" in gps:
result["gps_lat"] = decimal_from_dms(gps["GPSLatitude"], gps.get("GPSLatitudeRef", "N"))
result["gps_lon"] = decimal_from_dms(gps["GPSLongitude"], gps.get("GPSLongitudeRef", "E"))
result["gps_altitude"] = gps.get("GPSAltitude", None)
result["gps_timestamp"] = f"{gps.get('GPSDateStamp', '')} {gps.get('GPSTimeStamp', '')}"
elif tag == "Make":
result["camera_make"] = str(value)
elif tag == "Model":
result["camera_model"] = str(value)
elif tag == "DateTimeOriginal":
result["photo_taken"] = str(value)
elif tag == "Software":
result["software"] = str(value)
return result if "gps_lat" in result else None
def walk_directory(root_path, output_csv="gps_findings.csv"):
"""Recursively scan directory for images with GPS data"""
results = []
for dirpath, _, filenames in os.walk(root_path):
for f in filenames:
if f.lower().endswith(('.jpg', '.jpeg', '.tiff', '.tif', '.png', '.heic')):
fullpath = os.path.join(dirpath, f)
try:
data = extract_all_metadata(fullpath)
if data:
results.append(data)
print(f"[+] GPS found: {f} → {data['gps_lat']:.6f}, {data['gps_lon']:.6f}")
except Exception as e:
print(f"[-] Error processing {f}: {e}")
# Write CSV
if results:
fieldnames = ["filename", "gps_lat", "gps_lon", "gps_altitude",
"gps_timestamp", "photo_taken", "camera_make",
"camera_model", "software", "filepath"]
with open(output_csv, 'w', newline='') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, extrasaction='ignore')
writer.writeheader()
writer.writerows(results)
print(f"\n[+] Wrote {len(results)} GPS-tagged photos to {output_csv}")
# Generate Google Maps URL for each
print("\n[+] Google Maps Links:")
for r in results:
print(f" {r['filename']}: https://maps.google.com/?q={r['gps_lat']},{r['gps_lon']}")
else:
print("[!] No GPS-tagged photos found.")
return results
if __name__ == "__main__":
import sys
target_dir = sys.argv[1] if len(sys.argv) > 1 else "."
walk_directory(target_dir)What GPS Actually Reveals in a Pentest
Beyond just coordinates, you can pivot on this data:
From a photo taken at coordinates (X, Y):
Physical reconnaissance: The target's home, office, or frequented locations. Cross-reference with property records, business registrations, social media check-ins.
Movement patterns: Multiple photos create a timeline of movement over days/weeks — commute routes, travel patterns, routines.
Security infrastructure: Photos taken outside buildings reveal security cameras, access control, guard positions, and physical layout.
Geolocation pivoting: Coordinates → reverse geocoding → address → property records → owner names → additional OSINT.
Proximity analysis: "Who else took photos at these coordinates on these dates?" — social graph reconstruction.
Reverse Geocoding After Extraction
bash
# Using Nominatim (OpenStreetMap, free, rate-limited)
curl -s "https://nominatim.openstreetmap.org/reverse?format=json&lat=51.5074&lon=-0.1278" | jq .
# Bulk reverse geocoding
for lat in $(awk -F, '{print $2}' gps_findings.csv | tail -n+2); do
lon=$(awk -F, '{print $3}' gps_findings.csv | tail -n+2)
curl -s "https://nominatim.openstreetmap.org/reverse?format=json&lat=$lat&lon=$lon&zoom=18" | \
jq -r '.display_name'
sleep 1 # Nominatim limit: 1 req/sec
done
Or with Python + geopy:
python
from geopy.geocoders import Nominatim
from geopy.extra.rate_limiter import RateLimiter
geolocator = Nominatim(user_agent="pentest_osint")
reverse = RateLimiter(geolocator.reverse, min_delay_seconds=1.1)
coordinates = [(51.5074, -0.1278), (40.7128, -74.0060)]
for lat, lon in coordinates:
location = reverse(f"{lat}, {lon}")
print(f"{lat}, {lon}: {location.address}")Photos That Won't Have GPS
When photos come back empty, here's why:
Screenshots: Nearly never contain GPS (screen capture, not camera capture)
DSLR/mirrorless cameras: Most don't have GPS modules unless tethered or using an external GPS accessory
Stripped by platform: Facebook, Twitter, Instagram, WhatsApp, and most messaging apps strip EXIF on upload (notable exception: email attachments often survive, and some forums/image hosts don't strip)
Edited and re-saved: Some editors preserve EXIF, some strip it, some corrupt it
Taken with location services off: iOS and Android both allow disabling camera location
PNG files: EXIF support in PNG is inconsistent — most PNGs from screenshots lack GPS
Defense Testing Angle
If you're pentesting a client who publishes photos (corporate website, blog, social media, job postings), you're testing for:
Are employees posting photos from inside secure facilities with GPS enabled?
Do marketing photos of the "office interior" reveal GPS to the building's exact coordinates, which is also the datacenter location?
Do job posting photos on LinkedIn show the secure facility's layout and GPS coordinates?
Has EXIF stripping been implemented in publishing workflows?
Testing script for web-based photos:
bash
#!/bin/bash
# Download and scan all images from a target URL for GPS EXIF
TARGET_URL="$1"
TEMP_DIR=$(mktemp -d)
# Scrape all image URLs
lynx -dump -listonly -image_links "$TARGET_URL" | \
grep -iE '\.(jpg|jpeg|png|gif|webp|tiff)' | \
awk '{print $2}' | sort -u > "$TEMP_DIR/urls.txt"
# Download them
cd "$TEMP_DIR"
wget -q -i urls.txt -P images/
# Scan for GPS
exiftool -r -quiet -GPSLatitude -GPSLongitude -GPSDateStamp images/
# Cleanup
rm -rf "$TEMP_DIR"Google Maps / Earth Visualization
After extracting coordinates, drop them on a map for the report:
bash
# Generate a KML file for Google Earth
exiftool -r -p '
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
$filenames
</Document>
</kml>
' -d '%Y-%m-%d %H:%M:%S' \
-p '<Placemark>
<name>$filename</name>
<description>Date: $gpsdatetime</description>
<Point>
<coordinates>$gpslongitude#,$gpslatitude#,0</coordinates>
</Point>
</Placemark>' \
-ext jpg /path/to/photos/ > findings.kml
Open findings.kml in Google Earth Pro and you'll have every photo plotted geographically with timestamps. This makes a powerful visual for the pentest report.




Comments