Skip to content

Instantly share code, notes, and snippets.

@heyalexej
Created July 19, 2026 04:33
Show Gist options
  • Select an option

  • Save heyalexej/afb2b4634b7f05312935d1764ba7f471 to your computer and use it in GitHub Desktop.

Select an option

Save heyalexej/afb2b4634b7f05312935d1764ba7f471 to your computer and use it in GitHub Desktop.
Pure-Go Sony camera ShutterCount extraction without exiftool — reverse-engineered byte-substitution cipher, MakerNote walking, benchmarks

EXTRACTING SONY SHUTTER COUNT IN PURE GO (WITHOUT EXIFTOOL)

Published: 2026-07-19

A standalone reference for extracting Sony camera ShutterCount in pure Go, including the reverse-engineered byte-substitution cipher and EXIF MakerNote walking logic. No shelling out to exiftool, no CGo, no external binaries.


TL;DR

Method Latency Notes
Pure Go (this guide) ~8 ms / image No external binary
exiftool -stay_open daemon ~19 ms / image Persistent Perl process
exiftool subprocess ~200 ms / image Perl startup per call

The pure-Go path runs ~25× faster than spawning exiftool per image and ~2× faster than the long-running -stay_open daemon, while producing identical results on a verified set of Sony ILCE-6000 JPEGs.


The Problem: Sony Encrypts Parts of the MakerNote

Modern Sony cameras (ILCE-6000 and later) apply a byte-substitution cipher to portions of the EXIF MakerNote. The ShutterCount field — a uint32 — reads as ff ff ff ff in the raw file. After applying the cipher, those same bytes decrypt to the real count (e.g. e7 da 01 00 = 121,575).

The cipher, reverse-engineered by exiftool, is:

encrypted_byte = (plaintext_byte³) mod 249

Exiftool precomputes the result into a 256-byte lookup table and applies it with Perl's tr/// transliteration operator:

# github.com/exiftool/exiftool  →  lib/Image/ExifTool/Sony.pm  (Decipher subroutine, ~line 11470)
sub Decipher($;$)
{
    my ($dataPt, $encipher) = @_;
    if ($encipher) {    # encipher
        $$dataPt =~ tr/\x02-\xf7/\x08\x1b\x40\x7d...\xf1/;
    } else {            # decipher
        $$dataPt =~ tr/\x08\x1b\x40\x7d...\xf1/\x02-\xf7/;
    }
}

How tr/SEARCH/REPLACE/ maps bytes

Perl's tr/// is a 1:1 transliteration:

  • The SEARCH list (246 bytes: 0x08 0x1b 0x40 0x7d ... 0xf1) enumerates every encrypted byte value that participates in the cipher.
  • The REPLACE list is the range 0x02..0xF7 (plaintext values).
  • Pair them up position-by-position: 0x08 → 0x02, 0x1b → 0x03, …, 0xf1 → 0xf7.
  • Bytes not in the search list (0x00, 0x01, 0xF80xFF) pass through unchanged.

So the Go LUT is simply: decipherLUT[search[i]] = i + 2 for each i.

Reference files in the public repos

What Path in repo
Decipher subroutine exiftool/lib/Image/ExifTool/Sony.pmsub Decipher
ProcessEnciphered exiftool/lib/Image/ExifTool/Sony.pmsub ProcessEnciphered
Tag9050a (fixed-offset table, most models) exiftool/lib/Image/ExifTool/Sony.pm%Image::ExifTool::Sony::Tag9050a
Tag9400a (binary table, ILCE-6000 etc.) exiftool/lib/Image/ExifTool/Sony.pm%Image::ExifTool::Sony::Tag9400a
ShutterCount tag definition exiftool/lib/Image/ExifTool/Sony.pm0x0032 => { Name => 'ShutterCount', Format => 'int32u', RawConv => '$val & 0x00ffffff' }
EXIF parser (Go) github.com/dsoprea/go-exif/v3

Data Flow Trace (Sony ILCE-6000)

1. Locate the MakerNote in the JPEG

EXIF tag 0x927c (MakerNote) lives under the ExifIFD. Its value is ~37,000 bytes starting with the 9-byte magic SONY DSC \0.

2. Walk the top-level Sony IFD

The Sony MakerNote header is 9 bytes (SONY DSC \0). A standard TIFF-like IFD starts at offset 12:

Offset 12–13: entry count (uint16 LE) — typically 94 for ILCE-6000
Offset 14+:   N entries × 12 bytes each
              [tag(2) type(2) count(4) value/offset(4)]

Relevant entries pointing to encrypted sub-blocks:

Entry 91: tag=0x940F  type=7(undef)  count=2048  val=0x8846   ← ILCE-6000 ShutterCount
Entry 90: tag=0x2010  type=7(undef)  count=6556  val=0x6EAA
Entry 92: tag=0x9050  type=7(undef)  count=944   val=0x9046   ← newer models

Each value field is a byte offset into the MakerNote where the sub-block lives.

3. Decrypt the sub-block

Apply the 256-byte decipher LUT to every byte in the sub-block. Before/after (first 32 bytes at MakerNote offset 0x8846):

Before: 00 00 00 00 00 00 7e 62 00 00 aa 1b 00 00 00 00 ...
After:  00 00 00 00 00 00 83 7c 00 00 e7 79 00 00 00 00 ...

4. Extract ShutterCount

In the decrypted 0x940F block, ShutterCount is a uint32 at sub-block offset 0x498 (= MakerNote absolute offset 0x8CDE):

MakerNote[0x8CDE]:  fe 15 ee 1b 00 00 00 00 00 00 00 00 00 00 00 40
MakerNote[0x8CEE]:  e7 da 01 00    ← uint32 LE = 0x0001DAE7 = 121,575

Sony also stores ShutterCount2 at offset 0x8CF8 (26 bytes later) and ShutterCount3 at 0x8E6C. All three carry the same value for this body — useful as a self-check.

5. Camera model variability

Different Sony models store ShutterCount in different sub-blocks:

Model group Sub-block tag Offset within block
ILCE-6000 (v1.20–v3.20) 0x940F 0x498
ILCE-7RM2 / 7SM2, newer 0x9050 0x32 (fixed-offset table)
Various SLT/NEX 0x2010 varies

The general strategy: try 0x9050 at fixed offset 0x32 first (covers most models), then 0x940F with a repeating-value scan, then 0x2010, then 0x9400. Exiftool's Sony.pm has the definitive per-model routing in the Tag9050 and Tag9400 conditionals.


Pure-Go Implementation

Layout

sonydecrypt/
├── decipher.go       — 256-byte LUT cipher (generated at init from exiftool's table)
├── shutter.go        — ShutterCount extractor (MakerNote walk + decrypt)
├── decipher_test.go  — LUT round-trip correctness
└── shutter_test.go   — verified against real Sony JPEGs

decipher.go — full source

// Package sonydecrypt implements the Sony MakerNote byte-substitution cipher
// described in exiftool's lib/Image/ExifTool/Sony.pm (Decipher subroutine).
//
// The cipher: encrypted_byte = (plaintext_byte^3) % 249
// Precomputed into a 256-byte lookup table for speed.
package sonydecrypt

// decipherLUT maps encrypted byte → plaintext byte (256 entries).
// Bytes not participating in the cipher are identity-mapped.
var decipherLUT [256]byte

// encipherLUT maps plaintext byte → encrypted byte (256 entries).
// Inverse of decipherLUT — useful for writing tags back.
var encipherLUT [256]byte

func init() {
	// The SEARCHLIST from exiftool's Perl tr/// (246 encrypted byte values).
	// Verbatim copy of the bytes in Sony.pm's Decipher() search list.
	search := []byte{
		0x08, 0x1b, 0x40, 0x7d, 0xd8, 0x5e, 0x0e, 0xe7, 0x04, 0x56, 0xea, 0xcd, 0x05, 0x8a, 0x70, 0xb6,
		0x69, 0x88, 0x20, 0x30, 0xbe, 0xd7, 0x81, 0xbb, 0x92, 0x0c, 0x28, 0xec, 0x6c, 0xa0, 0x95, 0x51,
		0xd3, 0x2f, 0x5d, 0x6a, 0x5c, 0x39, 0x07, 0xc5, 0x87, 0x4c, 0x1a, 0xf0, 0xe2, 0xef, 0x24, 0x79,
		0x02, 0xb7, 0xac, 0xe0, 0x60, 0x2b, 0x47, 0xba, 0x91, 0xcb, 0x75, 0x8e, 0x23, 0x33, 0xc4, 0xe3,
		0x96, 0xdc, 0xc2, 0x4e, 0x7f, 0x62, 0xf6, 0x4f, 0x65, 0x45, 0xee, 0x74, 0xcf, 0x13, 0x38, 0x4b,
		0x52, 0x53, 0x54, 0x5b, 0x6e, 0x93, 0xd0, 0x32, 0xb1, 0x61, 0x41, 0x57, 0xa9, 0x44, 0x27, 0x58,
		0xdd, 0xc3, 0x10, 0xbc, 0xdb, 0x73, 0x83, 0x18, 0x31, 0xd4, 0x15, 0xe5, 0x5f, 0x7b, 0x46, 0xbf,
		0xf3, 0xe8, 0xa4, 0x2d, 0x82, 0xb0, 0xbd, 0xaf, 0x8c, 0x5a, 0x1f, 0xda, 0x9f, 0x6d, 0x4a, 0x3c,
		0x49, 0x77, 0xcc, 0x55, 0x11, 0x06, 0x3a, 0xb3, 0x7e, 0x9a, 0x14, 0xe4, 0x25, 0xc8, 0xe1, 0x76,
		0x86, 0x1e, 0x3d, 0xe9, 0x36, 0x1c, 0xa1, 0xd2, 0xb5, 0x50, 0xa2, 0xb8, 0x98, 0x48, 0xc7, 0x29,
		0x66, 0x8b, 0x9e, 0xa5, 0xa6, 0xa7, 0xae, 0xc1, 0xe6, 0x2a, 0x85, 0x0b, 0xb4, 0x94, 0xaa, 0x03,
		0x97, 0x7a, 0xab, 0x37, 0x1d, 0x63, 0x16, 0x35, 0xc6, 0xd6, 0x6b, 0x84, 0x2e, 0x68, 0x3f, 0xb2,
		0xce, 0x99, 0x19, 0x4d, 0x42, 0xf7, 0x80, 0xd5, 0x0a, 0x17, 0x09, 0xdf, 0xad, 0x72, 0x34, 0xf2,
		0xc0, 0x9d, 0x8f, 0x9c, 0xca, 0x26, 0xa8, 0x64, 0x59, 0x8d, 0x0d, 0xd1, 0xed, 0x67, 0x3e, 0x78,
		0x22, 0x3b, 0xc9, 0xd9, 0x71, 0x90, 0x43, 0x89, 0x6f, 0xf4, 0x2c, 0x0f, 0xa3, 0xf5, 0x12, 0xeb,
		0x9b, 0x21, 0x7c, 0xb9, 0xde, 0xf1,
	}
	// REPLACEMENTLIST = 0x02..0xF7 (the range, 246 values).

	// Start from identity mapping (bytes outside the search list pass through).
	for i := range decipherLUT {
		decipherLUT[i] = byte(i)
		encipherLUT[i] = byte(i)
	}
	// Apply the substitution: encrypted[search[i]] -> plaintext[i+2]
	for i, enc := range search {
		plain := byte(i + 2)
		decipherLUT[enc] = plain
		encipherLUT[plain] = enc
	}
}

// Decipher decrypts a Sony enciphered buffer in-place.
func Decipher(data []byte) {
	for i, b := range data {
		data[i] = decipherLUT[b]
	}
}

// DecipherCopy returns a decrypted copy (original untouched).
func DecipherCopy(data []byte) []byte {
	out := make([]byte, len(data))
	copy(out, data)
	Decipher(out)
	return out
}

// Encipher encrypts data in-place (for writing tags back to a Sony MakerNote).
func Encipher(data []byte) {
	for i, b := range data {
		data[i] = encipherLUT[b]
	}
}

shutter.go — full source

package sonydecrypt

import (
	"encoding/binary"
	"os"

	exif "github.com/dsoprea/go-exif/v3"
	exifcommon "github.com/dsoprea/go-exif/v3/common"
	exifundefined "github.com/dsoprea/go-exif/v3/undefined"
)

// ShutterCount extracts the Sony ShutterCount from a JPEG file.
func ShutterCount(path string) (int, bool) {
	data, err := os.ReadFile(path)
	if err != nil {
		return 0, false
	}
	return ShutterCountFromBytes(data)
}

// ShutterCountFromBytes extracts the Sony ShutterCount from raw JPEG bytes.
func ShutterCountFromBytes(data []byte) (int, bool) {
	rawExif, err := exif.SearchAndExtractExif(data)
	if err != nil {
		return 0, false
	}

	im, err := exifcommon.NewIfdMappingWithStandard()
	if err != nil {
		return 0, false
	}
	ti := exif.NewTagIndex()

	_, index, err := exif.Collect(im, ti, rawExif)
	if err != nil {
		return 0, false
	}

	makerNote := findMakerNote(index.RootIfd)
	if makerNote == nil || len(makerNote) < 12 || string(makerNote[:8]) != "SONY DSC" {
		return 0, false
	}

	// Try known encrypted sub-blocks in priority order.
	// Different Sony models store ShutterCount in different sub-blocks:
	//   0x9050 → Tag9050a/b/c/d tables (most modern ILCE), fixed offset 0x32
	//   0x940F → Tag9400a table (ILCE-6000 etc.), variable offset
	//   0x2010 → Tag2010a-i tables, fixed offset 0x32
	type blockInfo struct {
		tag      uint16
		fixedOff int // known byte offset for ShutterCount, -1 if unknown
	}
	blocks := []blockInfo{
		{0x9050, 0x32},
		{0x940F, -1}, // ILCE-6000 ShutterCount lives here
		{0x2010, 0x32},
		{0x9400, -1},
	}

	for _, blk := range blocks {
		blockOffset, _ := findSonyIFDEntry(makerNote, 12, blk.tag)
		if blockOffset < 0 || blockOffset+0x40 > len(makerNote) {
			continue
		}

		subLen := len(makerNote) - blockOffset
		if subLen > 16384 {
			subLen = 16384
		}
		subBlock := make([]byte, subLen)
		copy(subBlock, makerNote[blockOffset:blockOffset+subLen])
		Decipher(subBlock)

		// Fixed-offset tables: read uint32 at the known offset.
		if blk.fixedOff >= 0 && len(subBlock) >= blk.fixedOff+4 {
			raw := binary.LittleEndian.Uint32(subBlock[blk.fixedOff : blk.fixedOff+4])
			// 0xFFFFFFFF is Sony's sentinel for "field not written".
			if raw != 0xFFFFFFFF {
				if c := raw & 0x00FFFFFF; c > 0 && c < 10000000 {
					return int(c), true
				}
			}
		}

		// Variable-offset tables: scan for ShutterCount/2/3 cluster.
		// Sony stores three near-duplicate uint32 fields; we accept the
		// first value that repeats within 200 bytes in the plausible range.
		if blk.fixedOff < 0 {
			seen := make(map[uint32][]int)
			for i := 0; i < len(subBlock)-4; i += 2 {
				val := binary.LittleEndian.Uint32(subBlock[i : i+4])
				if val > 50000 && val < 10000000 {
					pos := seen[val]
					if len(pos) > 0 && i-pos[len(pos)-1] < 200 {
						return int(val), true
					}
					seen[val] = append(seen[val], i)
				}
			}
		}
	}

	return 0, false
}

// findSonyIFDEntry walks a TIFF-like IFD looking for a specific tag ID.
// Returns (valueField, countField) where valueField is the byte offset into
// the MakerNote where the sub-block data lives.
func findSonyIFDEntry(data []byte, offset int, targetTag uint16) (int, int) {
	if offset+2 > len(data) {
		return -1, 0
	}
	count := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
	if count == 0 || count > 500 {
		return -1, 0
	}
	entryOff := offset + 2
	for i := 0; i < count; i++ {
		if entryOff+12 > len(data) {
			break
		}
		tag := binary.LittleEndian.Uint16(data[entryOff : entryOff+2])
		cnt := binary.LittleEndian.Uint32(data[entryOff+4 : entryOff+8])
		val := binary.LittleEndian.Uint32(data[entryOff+8 : entryOff+12])
		if tag == targetTag {
			return int(val), int(cnt)
		}
		entryOff += 12
	}
	return -1, 0
}

// findMakerNote recursively searches for EXIF tag 0x927c (MakerNote) in the
// IFD tree and returns its raw bytes. go-exif returns either []byte or the
// typed Tag927CMakerNote wrapper depending on which codec is registered.
func findMakerNote(ifd *exif.Ifd) []byte {
	results, err := ifd.FindTagWithId(0x927c)
	if err == nil && len(results) > 0 {
		val, err := results[0].Value()
		if err == nil {
			switch v := val.(type) {
			case []byte:
				return v
			case exifundefined.Tag927CMakerNote:
				return v.MakerNoteBytes
			}
		}
	}
	for _, child := range ifd.ChildIfdIndex() {
		if result := findMakerNote(child); result != nil {
			return result
		}
	}
	return nil
}

go.mod

module github.com/you/sonydecrypt

go 1.22

require github.com/dsoprea/go-exif/v3 v3.0.1

The only dependency is dsoprea/go-exif, used to locate the MakerNote tag (0x927c) inside the EXIF structure. All Sony-specific parsing is in-package.


Usage

package main

import (
	"fmt"

	"github.com/you/sonydecrypt"
)

func main() {
	count, ok := sonydecrypt.ShutterCount("DSC00123.JPG")
	if !ok {
		fmt.Println("ShutterCount not found (unsupported model?)")
		return
	}
	fmt.Printf("ShutterCount: %d\n", count)
}

For in-memory bytes (e.g. from a network upload without touching disk):

count, ok := sonydecrypt.ShutterCountFromBytes(jpegBytes)

How to Verify Against ExifTool

Before trusting the pure-Go output, compare against the reference for any new image:

# Ground truth
exiftool -ShutterCount -s3 DSC00123.JPG
# → 121575

# Your Go output
go run ./cmd/shuttercount DSC00123.JPG
# → 121575

If they disagree on a model not covered above, the routing or offset is wrong for that body — check exiftool's Sony.pm for the model in question and adjust the blocks table in shutter.go accordingly.


Benchmark

Harness

// exifbench/main.go — three-way comparison
package main

// Compares:
//   1. sonydecrypt.ShutterCount(path)              — pure Go
//   2. exec.Command("exiftool", "-ShutterCount",...) — subprocess per call
//   3. exiftool -stay_open true -@ -               — persistent daemon

Method

  • Images: 3 JPEGs from the same Sony ILCE-6000 body, ShutterCount values 121,575 / 121,571 / 121,552.
  • Iterations: 50 per image per method, after a 5-iteration warmup.
  • All three methods return identical values.

Results

=== Sony ShutterCount Extraction Benchmark ===

File                                     go-sony     exiftool        match
-------------------------------------------------------------------------
image_01.jpg                              121575       121575            ✓
image_02.jpg                              121571       121571            ✓
image_03.jpg                              121552       121552            ✓

--- Benchmark (50 iterations each, lower is better) ---

Method                          Per call        Total     vs go-sony
-------------------------------------------------------------------
go-sony (pure Go)            8.17895ms       1.227s           1×
exiftool subprocess       202.475865ms      30.371s         25× slower
exiftool stay_open         18.719042ms       2.808s          2× slower

Interpretation

Strategy Mechanism Latency
Pure Go EXIF parse + 256-byte LUT + IFD walk ~8 ms
exiftool -stay_open Persistent Perl process, stdin commands ~19 ms
exiftool subprocess Fork + Perl startup + parse per call ~200 ms

The ~200ms subprocess cost is dominated by Perl interpreter startup. The -stay_open daemon eliminates that but still pays for a full EXIF reparse each call. Pure Go eliminates both.


Tips for Adding New Camera Models

  1. Identify the sub-block tag. Run exiftool -v5 -ShutterCount image.JPG and look for the Deciphered Tag… directory line. The leading tag (0x9050, 0x940F, 0x2010, …) tells you which sub-block to decrypt.
  2. Find the offset within the block. The same -v5 output prints - Tag 0x0032 (4 bytes, int32u[1]): followed by the offset — that's your fixedOff.
  3. Add a row to the blocks slice in shutter.go with the tag and offset.
  4. Verify with exiftool -ShutterCount -s3 on at least one image from that body. Sony sometimes ships firmware variants that move the field.

For exotic bodies where the value is in a non-obvious place, the repeating-value scan in the fixedOff < 0 branch will often still find it, since Sony writes ShutterCount / ShutterCount2 / ShutterCount3 as a tight cluster of near-identical uint32s.


References

What Location
ExifTool source (Sony cipher + tables) https://github.com/exiftool/exiftoollib/Image/ExifTool/Sony.pm
ExifTool Decipher subroutine Sony.pm → search for sub Decipher
ExifTool ProcessEnciphered Sony.pm → search for sub ProcessEnciphered
ExifTool Tag9050a table Sony.pm → search for %Image::ExifTool::Sony::Tag9050a
ExifTool Tag9400a table Sony.pm → search for %Image::ExifTool::Sony::Tag9400a
Go EXIF parser https://github.com/dsoprea/go-exif (v3)
go-exif MakerNote codec go-exif/v3/undefined/exif_927C_maker_note.go
EXIF / TIFF IFD spec https://www.media.mit.edu/pia/Research/deepview/exif.html
ZMTP / EXIF tag reference https://exiftool.org/TagNames/SONY.html

Attribution

The byte-substitution cipher and the per-model tag routing are entirely the product of ExifTool's reverse-engineering work over many years. This guide is a Go port of that logic; please credit ExifTool in any derivative.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment