Manipulating stored geocoordinates in cellphone photos

Programming Snapshot – Go Geofuzzer

Article from Issue 240/2020

Mike Schilli loves his privacy. That's why he's created a Go program that adds a geo-obfuscation layer to cellphone photos before they are published on online platforms to prevent inquisitive minds from inferring the location.

If you sell your stuff online, you might overlook the potential risk of sales-promoting cellphone photos revealing highly sensitive private information. When you take a picture of the goods at home with your cellphone, the image file may also contain the geodata with which the private address can be determined to within a few yards. Large sales platforms generally do not publish this meta-information, but who wants to give away more information than is absolutely necessary on Ebay or Facebook?

The cellphone also erases geodata directly if desired – but then it looks as if the user has something to hide. That's why the self-written Go program in this issue adds a geo-obfuscation layer to image files to make sure that the geocoordinates are randomly blurred. From this, it might be possible to determine the seller's location down to the neighborhood, but not the exact address.

Matches in the Radius

The procedure's goal is to move a photo's geotags randomly to an area within a defined action radius. If several snapshots are made, the target values are all within the action radius. In order to avoid anybody determining the center – and thus the location of the photographer – by analyzing hundreds of shots, the geofuzzer also shifts the center of the random circle to a neighboring area beforehand. To do this, it uses fixed, but secret, values for latitude and longitude (Figure 1).

Figure 1: From the original location, the algorithm jumps to a new point and randomly selects geocoordinates within a predefined radius.

An image file's geolocation is contained in the JPEG format's Exif tags [1] and can be read out with tools, such as exiftool, or on the GeoImgr website [2]. The latter even conveniently displays the shot's location on a Google map.

Figures 2 and 3 each show the photo's geotags – a picture of a box with a Google Voice Kit I wanted to sell on Ebay. The original in Figure 2 shows my home address in San Francisco where I snapped the photo in my study. After calling the geofuzz program, the image file's geolocation shifts further north to the Financial District. Figure 3 shows additional target values after several successive calls to the fuzzer, which scattered the results within the set action radius.

Figure 2: The actual location of this photo is near San Francisco's Mission District.
Figure 3: Successive calls of the geotag fuzzer move the geotag to locations within the action radius in the Financial District.

The geofuzz program generated from Listing 1 expects the name of the JPEG file to be manipulated on the command line, as shown in Listing 2. For the user to follow along, the running program prints both the original and the modified geolocations on Stdout. The fuzzer modifies the specified file directly, and the user can now post it without revealing too much about their location.

Listing 1


001 package main
003 import (
004   "bytes"
005   "fmt"
006   exif ""
007   "math"
008   "math/rand"
009   "os"
010   "os/exec"
011   "path/filepath"
012   "time"
013 )
015 func usage(msg string) {
016   fmt.Printf("%s\n", msg)
017   fmt.Printf("usage: %s image.jpg\n",
018              filepath.Base(os.Args[0]))
019   os.Exit(1)
020 }
022 func main() {
023   if len(os.Args) != 2 {
024     usage("Missing argument")
025   }
027   img := os.Args[1]
029   lat, lon, err := geopos(img)
030   if err != nil {
031     panic(err)
032   }
034   latFuzz, lonFuzz := fuzz(lat, lon)
036   fmt.Printf("Was: %f,%f\n", lat, lon)
037   fmt.Printf("Fuzz: %f,%f\n",
038              latFuzz, lonFuzz)
039   patch(img, latFuzz, lonFuzz)
040 }
042 func patch(path string,
043            lat, lon float64) {
044   var out bytes.Buffer
045   cmd := exec.Command(
046     "exiftool", path,
047     fmt.Sprintf("-gpslatitude=%f", lat),
048     fmt.Sprintf("-gpslongitude=%f", lon))
049   cmd.Stdout = &out
050   cmd.Stderr = &out
052   err := cmd.Run()
053   if err != nil {
054     panic(out.String())
055   }
056 }
058 func geopos(path string) (
059   float64, float64, error) {
060   f, err := os.Open(path)
061   if err != nil {
062     return 0, 0, err
063   }
065   x, err := exif.Decode(f)
066   if err != nil {
067     return 0, 0, err
068   }
070   lat, lon, err := x.LatLong()
071   if err != nil {
072     return 0, 0, err
073   }
075   return lat, lon, nil
076 }
078 func fuzz(lat, lon float64) (
079   float64, float64) {
080   r := 1000.0 / 111300 // 1km radius
082   // secret center
083   lat += .045
084   lon += .021
086   s1 := rand.NewSource( // random seed
087     time.Now().UnixNano())
088   r1 := rand.New(s1)
090   u := r1.Float64()
091   v := r1.Float64()
093   w := r * math.Sqrt(u)
094   t := 2.0 * math.Pi * v
095   x := w * math.Cos(t)
096   y := w * math.Sin(t)
098   x = x / math.Cos(lat*math.Pi/180.0)
099   return lat + x, lon + y
100 }

Listing 2

Invoking the Fuzzer

$ geofuzz ebay.jpg
Was: 37.756795,-122.426903
Fuzz: 37.804414,-122.407682

Look at the numbers in the output: My home in San Francisco is located at longitude 37° north and latitude 122° west, so the value for 37 is positive and 122 is negative. For comparison: Munich's Marienplatz is located at the geocoordinates 48.137365 and 11.575127, which can easily be retrieved in Google Maps by right-clicking the mouse on the corresponding location and selecting What's here? in the context menu (Figure 4). This confirms that Munich is further north than San Francisco and not west of the prime meridian (zero degree longitude), but east. Therefore, the value for Munich's 11° longitude is positive.

Figure 4: Geocoordinates of Munich's Marienplatz.

Reading Is Easier than Writing

If the number of arguments passed on the command line is less than expected, line 24 in Listing 1 branches to the usage() function that starts in line 15, which displays the error, demonstrates the correct use, and terminates the program with an exit code of 1.

The geodata are available as latitude and longitude in degrees, minutes, and seconds in the Exif tags of the photo file's JPEG format. The go-exif2 library on GitHub makes it surprisingly easy to read this relatively complex structure [1]. Luckily, I remembered that I had used the library once before in this magazine in an application for geosearching in a photo collection [3].

The geopos() function in line 58 of Listing 1 opens the image file passed to it by name, decodes the JPEG format with the call of the library function Decode(), and finds the latitude and longitude information of the location stored in the Exif tags with LatLong(). The function returns both values as floating-point numbers to the main program. This in turn calls the fuzz() function with them in line 34, which puts on the obfuscation filter (in line 78).

Math in Space

If you travel any distance on the surface of the earth, you are not, strictly speaking, moving in a two-dimensional space, but on the surface of a more or less even sphere. The distance travelled from one place to another, which are given as latitude and longitude, therefore cannot be computed by using simple two-dimensional Euclidean geometry, but it has to take into account the third dimension on the great circle of the sphere.

The fuzzer therefore has to calculate the distance (x, y) from the latitude and longitude of a starting point (x0, y0) from which someone moves away in a random direction on the great circle within the radius r. Fortunately, an expert on stack overflow has already found the solution to this geometric puzzle [4].

The radius r of the circle within which the algorithm scatters the coordinates is given in meters and not in degrees. To convert, line 80 divides the value of 1,000 meters (which corresponds to a scattering circle with a radius of one kilometer) by 111,300. Where does this constant come from? It corresponds to the distance in meters travelled by someone on the equator who moves exactly one degree. Since the earth has a circumference of about 40,075 kilometers at that point, one degree corresponds to the 360th part of it (i.e., about 111,300 meters).

As far as scattering random points is concerned, it helps to first simplify the assumption that the algorithm places the target points in a two-dimensional circle with the radius r. With two randomly generated values u and v in the range of [0,1[, Listing 3 gives the polar coordinates of the move, which can be converted into Cartesian coordinates x and y with Listing 4.

Listing 3

Polar Coordinates

01 w = r * sqrt(u)
02 t = 2 * Pi * v

Listing 4

Cartesian Coordinates

01 x = w * cos(t)
02 y = w * sin(t)

Attentive readers may be wondering about the root sqrt(u) in the first line in Listing 3 – why doesn't the moving vector length w simply result from r * u, creating values evenly between zero and r? This is because if the radii w were distributed linearly between zero and r, the random points would not be distributed evenly on the circular surface. If half of the points were below r/2, half of the results would be concentrated on the inner circle area, which contains only a quarter of the entire circular area. The root function corrects this and distributes the points evenly over the entire circular area.

However, the algorithm now has to take into account the fact that the circular surface is not on a two-dimensional plane, but on the globe. On the surface of the earth, radial distances given in degrees at the equator are at scale, but as the globe gets narrower toward the poles, the same segments of a circle get shorter in the west-east direction. After all, a degree in latitude at the equator is a longer distance than a degree further up or down and shrinks to zero near the poles. A correction formula extends the degree values for the calculated circle's x-direction (i.e., the determined difference in longitude) for regions further away from the equator:

x' = x / cos(y0)

Lastly, the latitude y0 is given in degrees and not as a radian, but the implementation of the cosine function in many programming languages expects radian values. Therefore, fuzz() converts the degrees into radians before applying the correctional east-west expander:

x = x / math.Cos(y*math.Pi/180.0)

Keep in mind that this method provides only an approximation, but it works well enough for relatively small circles and far away from the polar regions. Truth be told, since we're only dealing with random placements, a simpler approach to the fuzzing problem at hand would also have been possible, but hopefully this excursion shed some light on the fascinating field of globe geometry.

Buy Linux Magazine

Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

  • ExifTool and jExifToolGUI

    ExifTool lets you modify and analyze metadata in multimedia files from the command line, but its comprehensive feature set results in a lengthy learning curve. Luckily, jExiftoolGUI offers an intuitive interface that makes using ExifTool easier, even for less experienced users.

  • Easy Geotagging with ExifTool
  • Programming Snapshot – Go

    Every photo you take with your mobile phone stores the GPS location in the Exif data. A Go program was let loose on Mike Schilli's photo collection to locate shots taken within an area around a reference image.

  • Workspace: ExifTool

    Understanding the full power of ExifTool can be daunting. We show how to put it to practical use.

  • Perl: Elasticsearch

    The Elasticsearch full-text search engine quickly finds expressions even in huge text collections. With a few tricks, you can even locate photos that have been shot in the vicinity of a reference image.

comments powered by Disqus
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters

Support Our Work

Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.

Learn More