Windows hides file extensions by default. So an executable named invoice.pdf.exe shows up in Explorer as invoice.pdf. Give it a PDF icon and there is nothing left to tell it apart by looking.
An extension, though, is only a claim. A PDF always starts with %PDF-. A Windows executable always starts with MZ. A file can call itself anything it likes, but its first bytes are the contents themselves, and they cannot lie.
This post is about a Windows app I built that reads those first bytes to determine a file's real type, compares it against the claim (the extension), and answers the question "is this safe to open?". I'll cover how the signature matching works, how it recovers the type of a file whose extension has been removed, how it guesses the programming language of a source file, and the hole I found in the "safe to open" button right before release.
TL;DR
- The extension is a claim; the first bytes are the contents. Compare them and disguises fall out mechanically
- Keep "mismatch" and "danger" separate. The line is "would opening it execute something?"
- For text with the extension gone, score features × weight × cap to recover the language (47% → 100%)
- "The verdict is right" and "it's safe to open" are different questions. I found one hole in the open button, and one in signature ordering while writing this post. Both are fixed
What I built
Drop a file on the window and you get a card per file: the verdict, whether the claim matches the contents, and how to open it. Drop a whole folder and it checks everything inside, sorted with the most dangerous first.
The screenshot shows four of the sample files that ship with the app. invoice.pdf.exe is flagged as a double-extension disguise, vacation_photo.jpg is flagged because its contents are an executable, and payroll and stats, which have no extension at all, are identified from their contents as COBOL and MATLAB source.
The stack:
- Language: Python
- GUI: tkinter (plus tkinterdnd2 for drag and drop)
- Detection engine: standard library only (
struct,zipfile,json,re,ctypes) - Pillow, but only for the image preview
The engine has no third-party dependencies on purpose. Partly to keep the footprint small, but mostly because I wanted to be able to explain exactly what it checks by pointing at one file. file_kantei.py holds both the GUI and the engine, and the engine functions don't touch the GUI, so tests call them directly.
It runs fully offline and only ever reads files. Nothing about a file (contents or name) is sent anywhere, and checking a file does not count as opening it.
Why not libmagic /
file? libmagic answers "what is this file". The point of this tool is what comes after that: compare the claim with the contents, decide how dangerous the mismatch is, decide whether it's safe to open. Identification is just the front door. Shipping libmagic on Windows also means bundling a DLL and maintaining a magic database, a few dozen formats fit comfortably in the standard library, and I wanted to be able to explain every verdict by pointing at one file.
The source isn't published; the app is distributed through the Microsoft Store only. The snippets in this post are lifted from the actual engine.
The big picture: three stages
Each file goes through:
- Identify the contents. Match the leading bytes against a signature table. If nothing matches, check whether it is text.
- Compare with the claim. Does the extension expect the format we found? If not, it's a "mismatch". If an executable is dressed up as a document, it's "danger".
- Identity check for executables. For exe/dll: verify the digital signature, read the company/product name it claims, and look for build-tool traces.
The verdict is one of five:
| Verdict | Meaning | Example |
|---|---|---|
| ✅ Match (OK) | Claim and contents agree |
photo.jpg that is a JPEG |
| ⚠ Match (handle with care) | They agree, but the type runs when opened |
setup.exe that is an exe; a macro-enabled .xlsm
|
| ⚠ Mismatch | Claim and contents differ, but not dangerous |
image.png that is actually a JPEG; a .docx with macros |
| 🚨 Danger (do not open) | An executable pretending to be a document or image |
vacation_photo.jpg that is an exe; invoice.pdf.exe
|
| ❓ Unidentified | No signature matched and the extension is unknown | Proprietary formats |
Keeping "mismatch" and "danger" separate is the whole point. A .png that is really a JPEG is a harmless, everyday mismatch (it happens all the time when saving from the web). Call that dangerous and nobody will trust the tool. The word "danger" is reserved for one case: the contents are executable (exe/dll/ELF/Flash) and the claim is not.
1. Identifying the contents: signature matching
Read the first 64 KB
The tool never reads the whole file. It reads the first 64 KB and decides from that, so a multi-gigabyte video takes no longer than a text file.
Most signatures are fixed bytes at offset 0, so it is a table walked top to bottom:
_SIGNATURES = [
(b"\x89PNG\r\n\x1a\n", "png"),
(b"\xff\xd8\xff", "jpeg"),
(b"GIF87a", "gif"), (b"GIF89a", "gif"),
(b"PK\x03\x04", "zip"),
(b"Rar!\x1a\x07", "rar"),
(b"7z\xbc\xaf\x27\x1c", "7z"),
(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", "ole"), # legacy Office, msi, msg
(b"MZ", "exe"),
(b"\x7fELF", "elf"),
(b"SQLite format 3\x00", "sqlite"),
(b"\x4c\x00\x00\x00\x01\x14\x02\x00", "lnk"),
...
]
A handful of formats are not "fixed bytes at offset 0", and those are handled before the table:
# RIFF container: same 4 bytes, and bytes 8-12 tell WAV / AVI / WebP apart
if head[:4] == b"RIFF":
kind = head[8:12] # b"WAVE" / b"AVI " / b"WEBP"
# ISO Base Media: "ftyp" at offset 4, then the brand: MP4 / MOV / HEIC / AVIF / M4A
if head[4:8] == b"ftyp":
brand = head[8:12] # b"qt " -> mov, b"heic" -> heic, b"avif" -> avif ...
# Executables (MZ + a valid PE header) are settled before any "search past offset 0" check.
# Put this after the PDF scan below and an exe with "%PDF-" in its DOS stub becomes a PDF.
if head[:2] == b"MZ" and _detect_pe_kind(head):
return _detect_pe_kind(head)
# PDF: the spec allows the header anywhere in the first 1024 bytes (files with a preamble exist)
if b"%PDF-" in head[:1024]:
return "pdf"
# TAR: "ustar" lives at offset 257
if head[257:262] == b"ustar":
return "tar"
# ISO: "CD001" lives at offset 0x8001
if head[0x8001:0x8006] == b"CD001":
return "iso"
Digging into containers: telling docx / xlsx / apk apart inside a zip
Everything that starts with PK\x03\x04 is a zip, but the user doesn't want to hear "it's a zip". They want "it's a Word document". docx, xlsx, pptx, jar, apk, msix, epub and odt are all zips, so the tool looks at the entry names inside:
def _detect_zip_kind(path):
with zipfile.ZipFile(path) as zf:
names = zf.namelist()
has_macro = any(n.endswith("vbaProject.bin") for n in names)
if any(n.startswith("word/") for n in names): return "docx", has_macro
if any(n.startswith("xl/") for n in names): return "xlsx", has_macro
if any(n.startswith("ppt/") for n in names): return "pptx", has_macro
if "AndroidManifest.xml" in names: return "apk", has_macro
if "AppxManifest.xml" in names: return "msix", has_macro
if "mimetype" in names:
mime = zf.read("mimetype")[:100] # epub / odt / ods / odp
...
if "META-INF/MANIFEST.MF" in names: return "jar", has_macro
return "zip", has_macro
As a side effect, the presence of vbaProject.bin tells us whether the document contains macros. That feeds one extra rule: a .docx / .xlsx / .pptx (extensions that mean no macros) with a macro project inside is raised to "mismatch". Office saves macro-enabled files as .docm / .xlsm, so macros in an .xlsx are not something Office produces on its own.
Legacy Office and Outlook .msg: OLE compound files, split by stream name
.doc, .xls, .ppt, .msi and .msg all share one signature (OLE compound file). The stream names inside are stored as UTF-16LE, so the tool searches the first 4 MB for them:
if "WordDocument".encode("utf-16-le") in data: return "doc"
if "Workbook".encode("utf-16-le") in data: return "xls"
if "PowerPoint Document".encode("utf-16-le") in data: return "ppt"
if b"__substg1.0_" in data: return "msg" # Outlook message
exe vs dll: one bit in the PE header
For files starting with MZ, the offset at 0x3C points to PE\0\0, and bit 0x2000 of the Characteristics field that follows says whether it is a DLL:
def _detect_pe_kind(head):
e_lfanew = struct.unpack_from("<I", head, 0x3C)[0]
if head[e_lfanew:e_lfanew + 4] == b"PE\x00\x00":
characteristics = struct.unpack_from("<H", head, e_lfanew + 22)[0]
return "dll" if characteristics & 0x2000 else "exe"
return None
The None case exists because of a false positive I'll get to in a moment.
Is it text? NUL position and control-character ratio
If no signature matches, the tool checks whether the file is text:
def _sniff_text(head):
if head.startswith(b"\xef\xbb\xbf"): encodings = [("utf-8-sig", "UTF-8 (with BOM)")]
elif head.startswith(b"\xff\xfe"): encodings = [("utf-16-le", "UTF-16")]
elif head.startswith(b"\xfe\xff"): encodings = [("utf-16-be", "UTF-16")]
else:
nul = head.find(b"\x00")
if 0 <= nul < 4096: # an early NUL means binary
return None
if nul >= 4096: # a late NUL: judge what comes before it
head = head[:nul]
encodings = [("utf-8", "UTF-8"), ("cp932", "Shift_JIS (Japanese)")]
for enc, label in encodings:
text = head.decode(enc) # a multibyte char cut off at the end still counts
ctrl = sum(1 for ch in text if ord(ch) < 32 and ch not in "\t\r\n")
if ctrl / len(text) > 0.05: # more than 5% control characters: binary
return None
return (text, label)
return None
UTF-8 first, then CP932 (Shift_JIS). I'm in Japan, and a huge number of business CSV files here are still CP932, so that fallback is not optional for me. Swap in your own legacy encoding.
False positives found by sweeping 2,400 real files
Before release I ran roughly 2,400 real files from my own drives through it (PDFs, Office documents, source trees and build output of my own apps, a whole website) and fixed things until there were zero false warnings. Three came up:
An English text file starting with "BM" was identified as a BMP image. The BMP signature is just two bytes, BM, and an English text beginning with "BMW..." matches it. The BMP header has a reserved field at bytes 6-10 that must be zero, so the tool now checks that too.
A memo starting with "MZ" was identified as an executable. Same two-byte problem. If a file starts with MZ but has no PE header and decodes cleanly as text, it is treated as text. That is why _detect_pe_kind returns None.
Legitimate text with a NUL deep inside was identified as binary. Another tool of mine writes HTML reports that embed raw bytes it detected. "Any NUL means binary" rejected those. Now only a NUL within the first 4 KB counts as evidence of binary; a later NUL just truncates what gets examined.
All three are the same lesson: short signatures collide, and real files don't follow the spec.
And one more, found while writing this post. The %PDF- check above searches anywhere in the first 1024 bytes. In the original implementation it ran before the signature table. That means an executable with %PDF- written into its DOS stub (the do-whatever-you-like region from offset 0x40) was identified as a PDF, and named invoice.pdf it came back as "Match (OK)". I noticed it re-reading the code for this post and reproduced it. The fix is one line: if the file is MZ with a valid PE header, it is an executable before any "search past offset 0" rule gets a say. The fixed-offset signatures (RIFF, ftyp, ICO, BMP) can't coexist with MZ, so ordering only matters for the rules that search. Sweeping about 6,000 real files (about 1,000 of them executables) changed the verdict on zero legitimate files.
2. Comparing with the claim
Once the contents are known, the tool looks the extension up in a dictionary (about 180 entries) of what each extension expects:
EXT_DICT = {
"jpg": {"expect": {"jpeg"}, "desc": "JPEG image (photo)"},
"tgz": {"expect": {"gz"}, "desc": "Compressed archive"}, # aliases live in the set
"ai": {"expect": {"pdf"}, "desc": "Illustrator file"}, # contents are PDF
"py": {"expect": {"text"}, "desc": "Python program", "script": True},
...
}
The rules are evaluated in this order, and the first one that fires wins:
double_ext = (len(parts) >= 3 and ext in EXECUTABLE_EXTS and parts[-2] in DOCLIKE_EXTS)
exec_content = content_id in ("exe", "dll", "elf", "swf")
if double_ext: # invoice.pdf.exe
level = "danger"
elif content_id in ext_info["expect"]: # as claimed
level = "ok"
if ext in EXECUTABLE_EXTS or ext_info.get("script"): level = "caution"
if has_macro: level = "caution"
if ext in ("docx", "xlsx", "pptx") and has_macro: level = "warn"
elif exec_content and ext not in EXECUTABLE_EXTS: # executable dressed as a document
level = "danger"
else: # any other mismatch
level = "warn"
Why the double extension is checked first. In invoice.pdf.exe, the extension .exe and the exe contents match. Run the match rule first and it ends as "match (handle with care)". Even though claim and contents agree, the shape document-like extension + executable extension is the classic disguise, so it is declared dangerous before the match rule ever runs.
Why only "executable dressed as a document" is dangerous. As above, a .png holding a JPEG is harmless. But an executable calling itself .jpg, .pdf or .docx has no legitimate reason to exist. Double-clicking it won't run it, but it's the intermediate form of a well-known trick: smuggle it past scanners as a "picture", then rename it and run it later. The line between "mismatch" and "danger" is whether opening it would execute something.
3. When the extension is gone: classifying text
For binary formats, the signature works whether or not there is an extension. Text is the problem. A Python source file with .py removed and a memo with .txt removed are both "UTF-8 text".
So I built a text classifier.
Feature × weight × cap
For each language, a list of regexes for "things this language looks like", each with a weight and a cap on how many times it may count:
TEXT_KINDS = {
"py": ("py", "Python program", [
(r"^#!.*python", 10, 1),
(r"^\s*def \w+\(.*\)\s*(->.*)?:\s*$", 4, 2),
(r"^\s*(import|from) [\w.]+", 3, 2),
(r"^\s*if __name__ == ", 5, 1),
(r'\A(#[^\n]*\n)*\s*"""', 6, 1), # leading comments then a docstring
...]),
"go": ("go", "Go program", [
(r"^\s*package \w+\s*$", 4, 1), (r"^\s*func \w+\(", 4, 2),
(r"\bfmt\.\w+\(", 4, 1), (r":=", 2, 1), ...]),
...
}
Scoring is just "occurrences (up to the cap) × weight", summed. The cap is there so that a hundred print( calls don't become a hundred points. Let one feature dominate and a file in a different language that happens to use that token a lot will win.
def classify_text(text):
head = text[:24000]
raw = {}
for k, (ext, name, feats, base) in _COMPILED_KINDS.items():
sc = 0
for rx, w, cap in feats:
n = min(sum(1 for _ in rx.finditer(head)), cap)
sc += w * n
raw[k] = sc
...
scores.sort(reverse=True)
best, kind = scores[0]
second = scores[1][0] if len(scores) > 1 else 0
if best >= 7 and best - second >= 3:
return kind, "high"
if best >= 4 and best > second:
return kind, "mid"
...
Confidence has two levels. High (7+ points, and 3 clear of second place) lets the tool say "Original extension: .py. Add '.py' to the end of the name and it opens as usual." Mid (4+ points, sole leader) only gets "the contents look like a Python program", with no firm claim.
The window is the first 24,000 characters. Shorter windows missed files that begin with a long license header or docstring: the features never made it into the window and nothing scored.
Derived languages: TypeScript sits on top of JavaScript
A TypeScript file has every JavaScript feature (const, =>, console.log). Score JS and TS independently and a TS file ends up "JS 9, TS 9": a tie, no sole leader, no verdict at all.
So there is a notion of a derived kind. TS derives from JS; so does Google Apps Script. C++ derives from C, SCSS from CSS.
"ts": ("ts", "TypeScript program", [
(r"\w+\s*:\s*(string|number|boolean|any|void|unknown)(\[\])?\s*[,;)=|{]", 4, 2),
(r"^\s*(export\s+)?interface \w+\s*\{", 4, 1),
...], "js"), # <- fourth element: the base kind
A derived kind is only a candidate if at least one of its own features fires, and its score is then "base score + own score". One type annotation or interface and TS beats JS for certain; none, and TS isn't even in the race, so JS wins cleanly. If two derived kinds tie on the same base (GAS and TS both on JS), the tool returns the shared base, JS, at mid confidence.
Benchmark: 47% → 100%
Accuracy is measured by taking real files from my drives, copying them with the extension stripped, running the tool, and checking whether it recovers the original extension. 254 files, 28 kinds.
| Stage | Accuracy | What changed |
|---|---|---|
| First version | 47.1% | — |
| Derived kinds | 84.6% | JS/TS/GAS ties were erasing the verdict |
| Narrower TS features | 93.3% | A comment saying "type: string" counted as a type annotation. Now only identifier: type followed by a delimiter counts |
| YAML fix | 97.2% | Lines of Japanese prose like "Usage: ..." were counted as YAML keys. Keys must be ASCII now |
| Markdown fix | 100% | A plain memo with bullet points was classified as Markdown. Bullets are capped, and only things you only write in Markdown (front matter, wiki links) count as evidence |
The last two fixes are about not calling an ordinary note "some language". A missed detection costs less trust than a false one. Files whose correct answer is "this was just plain text" are part of the benchmark too.
Never put heavy weight on a generic feature
A trap I fell into when expanding to about 40 languages. I gave MATLAB's function name( five points, and the existing JavaScript tests broke, because JavaScript has function name( as well.
"matlab": ("m", "MATLAB/Octave program", [
# Only "function [out] = name(" is strong; a bare "function name(" looks like JS, so it's weak
(r"^\s*function \[?[\w, ]+\]?\s*=\s*\w+\(", 5, 1),
(r"^\s*function \w+\(", 1, 1),
(r"^\s*(clear all|clc|close all)\b", 6, 1),
...]),
Shapes shared across languages (function, a trailing ;, end) get one point. Only shapes unique to that language (function [out] = name(, clear all) get heavy weight. This is why every new language means re-running the tests for all languages.
A middle answer: "a program, language unknown"
A language that isn't in the table (or a short script with few features) scores nothing anywhere and falls through to "plain text". But a human glancing at if (...) {, return, trailing ; and // comments would say "that's code of some kind".
So there is a kind called code made of nothing but language-agnostic features. It never competes normally; only when nothing else reaches a verdict, and it has 10+ points, does the tool answer "a program (language not identified)". No language name, but enough of a warning that "adding an extension and double-clicking might execute this".
4. Identity check for executables: Authenticode, offline
When the contents are exe/dll, the tool calls the Windows APIs through ctypes and checks three things.
Digital signature. WinVerifyTrust. The important part is that it never goes online for revocation checks:
data = TRUST_DATA(
...,
2, # WTD_UI_NONE (no dialogs)
0, # no revocation checks
1, # WTD_CHOICE_FILE
...,
0x1000, # WTD_CACHE_ONLY_URL_RETRIEVAL (no network)
...)
rc = wintrust.WinVerifyTrust(None, byref(action), byref(data)) & 0xFFFFFFFF
The return code is collapsed into five states for the user:
| Return code | Shown as |
|---|---|
| 0 | Signed (valid) + signer name |
TRUST_E_NOSIGNATURE etc. |
No embedded signature → if the file is in the OS catalog (CryptCATAdmin*), "Windows catalog signature"; otherwise "unsigned" |
TRUST_E_BAD_DIGEST |
Signature does not match the contents (possible tampering) → verdict raised to "mismatch" |
CERT_E_EXPIRED |
Expired |
| anything else | Untrusted publisher |
"Unsigned" is not dangerous. My own apps are unsigned. But "signed, and the contents don't match the signature" means a legitimate file was modified afterwards, and that is the one state that raises the verdict.
The claim. GetFileVersionInfoW gives the company name, product name and version. For an unsigned file that is self-reported, and the tool labels it "(self-reported)". An unsigned exe claiming to be from Microsoft is easy to spot once it's phrased that way.
The build. From the PE header: 64/32-bit, GUI or console subsystem, .NET or not (data directory 14, the COM descriptor). From markers in the first and last 2 MB: PyInstaller, Inno Setup, NSIS. "A Python (PyInstaller) GUI app, 64-bit, unsigned" is a useful thing to know when you ask the sender what they sent you.
5. The hole in the "safe to open" button
Files with a "Match (OK)" verdict get a "📂 Open (safe)" button that hands them to the usual application. For a file with no extension, the tool makes a copy in a temp folder with the estimated extension added, and opens that (the original is never touched).
Right before release, while clicking through the sample folder, I noticed: strip the extension from a batch file, check it, and you get "Batch file (estimated .bat), Match (OK)", complete with an "Open (safe)" button. Click it, and a copy named *.bat goes to os.startfile. Which runs it.
The verdict was correct. There's no claim, so there's nothing to mismatch. But "open" is a different question from "what is it", and it has to be decided by what happens when you open it:
def _is_program_like(ext, text_kind):
words = ("program", "script", "executable")
if ext in EXECUTABLE_EXTS:
return True
info = EXT_DICT.get(ext or "")
if info and (info.get("script") or any(w in info["desc"] for w in words)):
return True
kind = TEXT_KINDS.get(text_kind or "")
if kind and any(w in kind[1] for w in words):
return True
return False
def can_open_safely(r):
if r.get("level") != "ok":
return False
ext = r.get("ext") or (r.get("guess_ext") or "") # no extension: check the estimated one
if not ext:
return False # no estimate either (Makefile etc.): nothing to open it with
return not _is_program_like(ext, r.get("text_kind"))
Rather than maintaining a hand-written list of executable extensions, the check scans the dictionary's own descriptions for the words "program", "script" and "executable". A hand-written list will be forgotten the next time a language is added. When there's no extension, the estimated one is checked, and the text classification result is checked as well.
No automated test caught this. There were tests for "is the verdict right?", but none for "what happens when the button is pressed?". It was found the boring way: build a sample folder, and press every button yourself before shipping.
Limits: this is not an antivirus
What it can't do:
It doesn't judge intent. It can't tell you whether an unsigned exe is safe or malicious. It doesn't read what a macro does. It doesn't look at JavaScript inside a PDF. What it knows is "what is this", "does the claim match", and "who signed it". It is not a replacement for antivirus software; it sits in front of it, answering "should I be opening this at all?".
It doesn't look inside zips. A zip is "a zip (a Word document if the entries say so)". Files inside it aren't checked until you extract them, and for an encrypted zip only the entry names are visible.
Formats without a signature can't be identified. Proprietary formats come back as "unidentified". For those there is a "🌐 search the web" button that opens your default browser with a search for the extension (only the extension string is passed; never the file's contents or name).
Polyglots. A file crafted to satisfy two signatures at once (a valid PDF that is also a valid ZIP, for example) is reported as whichever format is checked first. The tool targets commodity disguises, not files built specifically to evade it.
Text classification is a guess. Short scripts, mixed languages and sparse code will miss. That's why confidence has two levels and the mid level never asserts.
The regression suite is 66 engine tests, 50 text-classification tests, 79 language tests, 35 media-info tests, 35 help tests, 19 English-UI tests and 15 GUI tests, and all of it runs every time a language is added.
Takeaways
- The extension is a claim; the first bytes are the contents. Compare the two and disguises fall out mechanically
- Keep "mismatch" and "danger" separate. The line is "would opening it execute something?"
- A double extension is dangerous even though claim and contents agree. Check it before the match rule
- Short signatures (
BM,MZ) collide. Sweep thousands of real files and fix until there are zero false warnings - For text with the extension gone: score features × weight × cap. Put derived languages on top of their base. Never give a generic shape heavy weight. Don't call an ordinary note "some language"
- "The verdict is right" and "it's safe to open" are different questions. Decide the open button by what happens when it's pressed
The tool is on the Microsoft Store. Everything it checks is free, and it runs fully offline.
https://apps.microsoft.com/detail/9PKG5KT1WXR8?hl=en-us&gl=US
If there's a format it doesn't recognize or a language you'd like added, leave a comment. New signatures and features are welcome.
About the author
Okinawa Software Lab. I lead in-house digital transformation at a small company in Okinawa, Japan. I build the tools we need ourselves, and I publish Windows apps on the Microsoft Store that follow the same principle: everything happens on your own PC.
- Website: https://okinawasoftwarelab.com/en/