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.
| 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.
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/;
}
}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,0xF8–0xFF) pass through unchanged.
So the Go LUT is simply: decipherLUT[search[i]] = i + 2 for each i.
| What | Path in repo |
|---|---|
| Decipher subroutine | exiftool/lib/Image/ExifTool/Sony.pm → sub Decipher |
| ProcessEnciphered | exiftool/lib/Image/ExifTool/Sony.pm → sub 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.pm → 0x0032 => { Name => 'ShutterCount', Format => 'int32u', RawConv => '$val & 0x00ffffff' } |
| EXIF parser (Go) | github.com/dsoprea/go-exif/v3 |
EXIF tag 0x927c (MakerNote) lives under the ExifIFD. Its value is ~37,000
bytes starting with the 9-byte magic SONY DSC \0.
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.
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 ...
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.
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.
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
// 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]
}
}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
}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.
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)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
# → 121575If 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.
// 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- 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.
=== 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
| 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.
- Identify the sub-block tag. Run
exiftool -v5 -ShutterCount image.JPGand look for theDeciphered Tag… directoryline. The leading tag (0x9050,0x940F,0x2010, …) tells you which sub-block to decrypt. - Find the offset within the block. The same
-v5output prints- Tag 0x0032 (4 bytes, int32u[1]):followed by the offset — that's yourfixedOff. - Add a row to the
blocksslice inshutter.gowith the tag and offset. - Verify with
exiftool -ShutterCount -s3on 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.
| What | Location |
|---|---|
| ExifTool source (Sony cipher + tables) | https://github.com/exiftool/exiftool — lib/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 |
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.