Step 246. EXIF/Metadata and Document Forensics — Reading the Fingerprints Engraved Inside Files
Level 3 — Real-World CTF & Advanced Offensive Skills | Difficulty ★★★☆☆ | Estimated time: 3–4 hours
Prerequisites: Step 245 complete. Python 3 (pillow, python-docx) is used. Every file is one we create and analyze ourselves.
⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- What you need: Python 3 (measured: 3.12.14, with pillow and python-docx). No internet connection needed.
- Caution: every photo and document handled today is newly made for practice. Never track GPS coordinates from someone else’s photo, and never run a macro document — with macros you "read" the code, you don’t "run" it.
A photo carries a second layer of information beyond the captured scene — engraved inside the file is what device took it, when, and where (EXIF). Office documents retain the author, the last modifier, and the name of the program that made them. In a case this becomes location evidence; in OSINT work it becomes an identity clue. Today you plant that hidden layer yourself, read it yourself, and learn why platforms erase it.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain what EXIF and document metadata are and why they become evidence
- Plant EXIF into an image with Python pillow and read it back
- Convert GPS degrees-minutes-seconds (DMS) coordinates into the decimal form you can drop on a map
- Understand that a docx is a ZIP bundle and extract author information from
docProps/core.xml - Explain, with a measured example, that metadata can lie
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 — pillow (images), python-docx (documents), the standard library’s zipfile |
| Today’s commands | Image.Exif() (make EXIF), img.getexif() / ex.get_ifd(0x8825) (read/GPS), ZipFile("doc.docx").read("docProps/core.xml") |
| Concepts needed | EXIF tag numbers, GPS DMS coordinates, OOXML (docx = ZIP), metadata’s limits of trust |
2-1. Metadata — Data About Data
Metadata is "descriptive information attached to the content, not the content itself." A photo’s content is pixels; its metadata is capture time, device, and coordinates. A document’s content is text; its metadata is author, edit history, and the program used.
The reason metadata matters in forensics is the same as Step 244’s artifacts — it’s not something the author consciously wrote but something a program engraved automatically, so it’s harder to dress up than the content (and thus often tells the truth), and authors frequently don’t know it exists at all (and thus it survives unerased).
2-2. EXIF — A Photo’s Birth Certificate
EXIF (Exchangeable image file format) is the standard metadata spec attached to JPEG/TIFF images. Each piece of information is distinguished by a tag number. The ones we use today:
| Tag (hex) | Name | Contents |
|---|---|---|
0x010F |
Make | Camera manufacturer |
0x0110 |
Model | Device model name |
0x0132 |
DateTime | Capture (or modification) time |
0x8825 |
GPSInfo | Link to the GPS coordinate bundle |
Smartphone photos often carry GPS coordinates here — a single photo confessing "where it was taken" of its own accord. That’s why investigations and press verification open a photo’s EXIF first.
2-3. GPS Coordinates — From Degrees-Minutes-Seconds to Decimal
EXIF stores GPS as pairs of three values — degrees, minutes, seconds (latitude, longitude). Example: (37, 33, 58.9) north. To convert to the decimal form map services use:
degrees + minutes/60 + seconds/3600 → 37 + 33/60 + 58.9/3600 = 37.5664
One line of conversion turns "coordinates in a photo" into "a point on a map." You’ll compute it yourself today.
2-4. A docx Is a ZIP — The Document’s X-Ray
.docx, .xlsx, .pptx files (collectively OOXML) look like single files but are actually bundles of multiple XML files compressed into a ZIP. So Python’s zipfile opens them as-is. Inside, docProps/core.xml holds the author, modifier, and times; docProps/app.xml holds the creating program’s information.
The danger of macro documents (.docm etc.) also comes from this structure — executable code can ride along inside a document, making them a classic malware-delivery vehicle. The response procedure is fixed: don’t open it (never execute); extract and read only the code. Tools like olevba safely pull out just the macro code. Today we organize the procedure at the concept level.
2-5. Metadata Lies — The Limits of Trust
Finally, the most important lesson: metadata is automatically recorded but manipulable. If the camera clock is wrong, the capture time is wrong too; tools can rewrite EXIF wholesale; and a document’s "creating program" field says whatever the tool that made the file writes. In today’s practice you’ll witness this lie with your own eyes. Conclusion: metadata is a clue, not a verdict — it only gains strength when cross-checked against other evidence.
3. Follow Along
3-1. Planting EXIF — Making a Photo with Evidence In It
First, make "a photo containing capture information" with our own hands. exif_lab.py:
from PIL import Image
from pathlib import Path
lab = Path("lab246"); lab.mkdir(exist_ok=True)
img = Image.new("RGB", (320, 240), (40, 90, 160)) # solid blue image
exif = Image.Exif()
exif[0x010F] = "ACME Corp" # manufacturer
exif[0x0110] = "PhotonCam X2" # model
exif[0x0132] = "2026:09:09 21:40:17" # capture time
exif[0x8825] = { # GPS (degrees/minutes/seconds)
1: "N", 2: (37, 33, 58.9), # latitude 37° 33' 58.9" N
3: "E", 4: (127, 1, 40.1), # longitude 127° 1' 40.1" E
}
img.save(lab / "photo.jpg", exif=exif)
print("saved")
Run it and lab246/photo.jpg appears (confirmed measured 2026-09-09). On the outside it’s just a blue picture, but inside, the device, time, and coordinates are engraved. People who have planted evidence read evidence well — right now we’ve experienced the position of "the creator of the investigation target."
3-2. Reading EXIF — Hearing the Photo’s Confession
Now return to the investigator’s seat and read:
from PIL import Image
img = Image.open("lab246/photo.jpg")
ex = img.getexif()
print("Make:", ex.get(0x010F))
print("Model:", ex.get(0x0110))
print("DateTime:", ex.get(0x0132))
Make: ACME Corp
Model: PhotonCam X2
DateTime: 2026:09:09 21:40:17
(Measured 2026-09-09.)
GPS sits one layer deeper — the 0x8825 tag is a link pointing to the coordinate bundle (the GPS IFD), so open it with get_ifd:
gps = ex.get_ifd(0x8825)
print("GPS raw:", dict(gps))
def dms_to_deg(dms):
d, m, s = float(dms[0]), float(dms[1]), float(dms[2])
return d + m / 60 + s / 3600
print(f"latitude {dms_to_deg(gps[2]):.4f} {gps[1]}, longitude {dms_to_deg(gps[4]):.4f} {gps[3]}")
GPS raw: {0: b'\x02', 1: 'N', 2: (37.0, 33.0, 58.9), 3: 'E', 4: (127.0, 1.0, 40.1)}
latitude 37.5664 N, longitude 127.0278 E
(Measured 2026-09-09.)
How to read the output: 2: (37.0, 33.0, 58.9) is the latitude’s degrees-minutes-seconds, 1: 'N' is the north marker. The conversion gives 37.5664, 127.0278 — dropped on a map, that’s near downtown Seoul. In a real case, the clue "this photo was taken near these coordinates" would now be secured. Putting these coordinates into a map service yourself is the last part of today’s flow (if you have internet).
3-3. Why Doesn’t It Show in Social-Media Photos? — Stripping
A natural question: "then can you pull coordinates from photos posted on social media too?" Most large platforms delete (strip) EXIF during upload — because accidents of users’ locations leaking kept repeating.
The check is simple: read the original and a copy downloaded from the platform with section 3-2’s code and compare. If tags present in the original are empty in the copy, it was stripped. The forensic lesson: "there is no metadata" cannot distinguish "it was never there" from "someone erased it" — Step 245’s "erased traces" principle applies here exactly the same.
3-4. Dissecting a docx — The Document as a ZIP
Now to documents. Make a docx with metadata planted:
import docx
from pathlib import Path
lab = Path("lab246")
d = docx.Document()
d.add_paragraph("Quarterly incident summary (sample document).")
cp = d.core_properties
cp.author = "lee.minsu" # original author
cp.last_modified_by = "park.jihye" # last modifier
cp.title = "Incident Summary"
cp.comments = "draft v3 - internal only"
d.save(lab / "report.docx")
Open the document as a ZIP. No need to change the extension — Python reads it:
from zipfile import ZipFile
with ZipFile("lab246/report.docx") as z:
for n in z.namelist()[:6]:
print(" ", n)
core = z.read("docProps/core.xml").decode("utf-8")
print(core)
[Content_Types].xml
_rels/.rels
docProps/core.xml
docProps/app.xml
word/document.xml
word/_rels/document.xml.rels
--- core.xml ---
...<dc:title>Incident Summary</dc:title>...<dc:creator>lee.minsu</dc:creator>...
<dc:description>draft v3 - internal only</dc:description>
<cp:lastModifiedBy>park.jihye</cp:lastModifiedBy><cp:revision>1</cp:revision>
<dcterms:created ...>2013-12-23T23:15:00Z</dcterms:created>
<dcterms:modified ...>2013-12-23T23:15:00Z</dcterms:modified>...
(Measured 2026-09-09. Namespace declarations omitted.)
How to read the output: <dc:creator>lee.minsu</dc:creator> retains the original author, and <cp:lastModifiedBy>park.jihye</cp:lastModifiedBy> the last person who touched it, exactly as-is. In real leaked-document analysis, there are cases where "author A, modifier B" turned out to be names from another organization, exposing the document’s origin.
3-5. Metadata’s Lie — Confirmed by Measurement
But look closely at the output above. This file was just made in 2026, yet created says 2013-12-23. The reason: that time is baked into the default template python-docx uses, and we never changed it. The lie doesn’t end there. Open app.xml:
with ZipFile("lab246/report.docx") as z:
print(z.read("docProps/app.xml").decode("utf-8")[:300])
<Properties ...>
<Template>Normal.dotm</Template>
<TotalTime>0</TotalTime>
<Pages>1</Pages>
...
<Application>Microsoft Macintosh Word
(Measured 2026-09-09.)
This file was made with Python on Windows, yet the "creating program" field says Microsoft Macintosh Word — it simply inherited the template’s default value.
This is the real-world evidence for section 2-5. Had you reasoned "the creating program is Word for Mac, so the author is a Mac user," you’d have reached a completely wrong conclusion. Metadata is testimony, and testimony is never admitted without cross-examination.
3-6. The Macro-Document Response Procedure (Concept Summary)
We won’t execute this today, but let’s set down the response procedure for macro documents, the other half of document forensics. The iron rules for when you actually receive a suspicious document:
- Never double-click it — the macro can run the moment it opens
- Move the file to an isolated analysis environment (a lab VM)
- Extract only the macro code with a tool like
olevba document.docmand read it as text - Follow the obfuscated strings and statically analyze "what is it actually trying to download and run"
- Practice for CTFs only with safe samples (ones made for practice)
The principle is the same as section 3-4 — a document is ultimately a file, and code can be read as text. The difference between reading and executing is the whole of safety.
4. Missions & Exercises
Mission — A Metadata Investigation Made by My Own Hands
- Modify section 3-1’s code to make your own EXIF photo (change the device name, time, coordinates)
- From a different Python script, open that photo and extract the three pieces of information — practice in "separating the maker from the investigator"
- Convert the GPS coordinates to decimal (section 3-2)
- Extract the three items — author, modifier, creating program — from section 3-4’s docx and organize them into
metadata-report.txt - On the last line, sort out "which of this metadata I can trust and which I can’t"
Exercises
Problem 1. Explain, from section 2-1’s perspective, why EXIF and document metadata "often tell the truth more than the content does."
Problem 2. Calculate by hand the conversion of the EXIF GPS value (37, 33, 58.9) N to decimal.
Problem 3. In some leaked document’s core.xml, the author was kim.cheolsu, and app.xml’s Application was Microsoft Macintosh Word. Can you conclude "this document was written by kim.cheolsu on a Mac"? Answer based on today’s measurements.
Problem 4. A photo uploaded to a platform had empty EXIF. Distinguish what you can know from this fact alone and what you cannot.
5. Model Answers & Completion Criteria
Mission Model Answer
An example creation script:
from PIL import Image
img = Image.new("RGB", (320, 240), (200, 60, 60))
exif = Image.Exif()
exif[0x010F] = "MyLab"
exif[0x0110] = "TestCam 3000"
exif[0x0132] = "2026:09:09 22:00:00"
exif[0x8825] = {1: "N", 2: (35, 10, 46.2), 3: "E", 4: (129, 4, 31.0)}
img.save("lab246/myphoto.jpg", exif=exif)
The investigation script is used as-is from section 3-2. Conversion result example: 35° 10′ 46.2" → 35 + 10/60 + 46.2/3600 = 35.1795 N.
metadata-report.txt example:
=== photo.jpg ===
Make: MyLab / Model: TestCam 3000 / DateTime: 2026:09:09 22:00:00
GPS: 35.1795 N, 129.0753 E (converted from DMS (35,10,46.2)/(129,4,31.0))
=== report.docx (docProps/core.xml, app.xml) ===
Author (dc:creator): lee.minsu
Last modifier (cp:lastModifiedBy): park.jihye
Creating program (Application): Microsoft Macintosh Word
=== Trust assessment ===
Trustworthy: values I planted myself (verifiable because I'm the maker)
Not usable alone: created time (template default 2013-12-23, measured),
Application (made by Python on Windows yet recorded as Mac Word, measured)
How to verify: if the investigation script’s output exactly matches the values planted in the creation script, extraction succeeded. For the docx part, open core.xml and app.xml yourself and compare by eye. The "trust assessment" section must be filled in for completion — extracting without assessing trust is copying, not investigating.
Exercise Answers
Answer 1. Metadata isn’t written consciously by the user but engraved automatically by a program, so (1) there’s less room for intent to embellish than in the content, and (2) many authors don’t know it exists, so it survives unerased. However, it can be manipulated with tools, so cross-checking is needed.
Answer 2. 37 + 33/60 + 58.9/3600 = 37 + 0.55 + 0.01636 ≈ 37.5664 → latitude 37.5664°N (matches the 2026-09-09 Python measurement).
Answer 3. No. In today’s measurement, a docx made with Python on Windows recorded its Application as "Microsoft Macintosh Word" — this field can carry a template default, so it cannot stand as sole evidence. The author field can also be changed freely with tools. Only "the string kim.cheolsu is recorded" is fact; anything beyond that must be cross-checked with other evidence (the file’s delivery path, internal styles, consistency of the timestamps).
Answer 4. What you can know: "this copy currently has no EXIF." What you cannot: whether the original never had any (some capture apps don’t plant it), whether the platform erased it, or whether someone removed it deliberately. Absence proves no cause, so the report records "metadata absent — cause unknown" and other clues must be sought.
Completion Criteria Checklist
- [ ] I can explain metadata as "descriptive information engraved automatically by a program"
- [ ] I know the roles of the EXIF tag numbers (
0x010F,0x0110,0x0132,0x8825) - [ ] I can plant EXIF with pillow and read it with
getexif()/get_ifd() - [ ] I can convert GPS degrees-minutes-seconds to decimal
- [ ] I can open a docx with zipfile and extract core.xml/app.xml
- [ ] I can explain, with a measured example, that metadata can be polluted by manipulation and defaults
- [ ] I know the procedure for macro documents: "don’t execute; extract and read only the code"
- [ ] Mission: completed metadata-report.txt
6. Common Pitfalls & Fixes
Wall 1. GPS doesn’t show with getexif()
Symptom: Make/Model come out, but no GPS entries.
Cause: 0x8825 (GPSInfo) isn’t a value but a link pointing to a sub-bundle (IFD), so it doesn’t appear directly in getexif()‘s result.
Fix: open one more layer with ex.get_ifd(0x8825) and the coordinates appear (confirmed measured 2026-09-09). EXIF is structured into several rooms (IFDs), like "photo info / detailed info / GPS."
Wall 2. You planted EXIF but it vanishes on save
Symptom: you saved as PNG and the EXIF won’t read.
Cause: EXIF is fundamentally a JPEG/TIFF spec. PNG stores metadata a different way (text chunks), so the exif= argument doesn’t go in as expected.
Fix: save as .jpg. If you must handle metadata in PNG, write text chunks with PngImagePlugin.PngInfo — same concept, different container.
Wall 3. The converted GPS lands in the middle of the ocean
Symptom: the converted coordinates land somewhere absurd on the map.
Cause: check two things — (1) you dropped the N/S, E/W direction markers (tags 1, 3) and ended up in the southern/western hemisphere, (2) you divided minutes by 100 instead of 60.
Fix: re-check the degrees + minutes/60 + seconds/3600 formula, and attach a minus to the result for S and W.
Wall 4. Opening a docx with zipfile throws an error
Symptom: zipfile.BadZipFile: File is not a zip file.
Cause: that file isn’t OOXML (docx) but the legacy binary format (doc), or it’s a different file merely named docx. A legacy .doc is an OLE compound document and needs a different tool (olefile etc.).
Fix: identify the file’s true identity first — as you learned in Step 239, judge by its signature (if it starts with PK, it’s in the ZIP family).
Wall 5. You trust metadata as-is and wreck the report
Symptom: you saw "creating program: Macintosh Word" and concluded "written by a Mac user."
Cause: as in section 3-5’s measurement, template defaults often survive as-is. Times, program names, and authors can all be manipulated or inherited.
Fix: for each metadata item, ask "is this value consistent with this file’s actual history?" If created is later than modified, or the program name doesn’t match the file’s internal structure, that’s a pollution signal.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Metadata | Automatically recorded descriptive info attached to content — often survives because authors don’t know it’s there |
| EXIF | The standard metadata of JPEG/TIFF — device, time, GPS, distinguished by tag numbers |
| GPS DMS | Degrees-minutes-seconds coordinates — converted to decimal with degrees+minutes/60+seconds/3600 |
| OOXML (docx) | A document bundling several XMLs into a ZIP — author in core.xml, program in app.xml |
| Stripping | Platforms deleting metadata on upload — a privacy-protection measure |
| Metadata’s limits | Automatically recorded but manipulable and template-pollutable — a clue, not a verdict |
Today’s Commands & Code
| Code | What it does |
|---|---|
Image.Exif() + exif[tag] = value |
Plant EXIF |
img.getexif() |
Read basic EXIF |
ex.get_ifd(0x8825) |
Open the GPS bundle |
degrees + minutes/60 + seconds/3600 |
Convert coordinates to decimal |
ZipFile("a.docx").read("docProps/core.xml") |
Extract document-author metadata |
ZipFile(...).read("docProps/app.xml") |
Extract creating-program info |
An Instinct More Important Than Commands
Today’s core is a symmetric structure: only those who have planted metadata can properly doubt metadata. We planted it ourselves, read it ourselves, and even witnessed with measurement the lie of template defaults. Having seen with your own eyes that the testimony "this file was made on a Mac" can be wrong, "never admit alone, always cross-check" must soak into your habits for any metadata from now on.
In practice, one line of exiftool replaces today’s Python code — exiftool photo.jpg shows EXIF and document metadata on one screen. The faster the tool, the more "how do I classify the printed values by trust level" becomes the analyst’s skill. Today you earned that classification standard by measurement.
Once every box is checked, Step 246 is complete. Click the checkbox in the sidebar to save your progress.