DEF¶
Level: 101 → 201 · for anyone who has written a Windows DLL
One line: A .def file is text — EXPORTS, then one name per line with an optional @ordinal, NONAME, DATA or PRIVATE — decoded by the JVM's default charset, which has been UTF-8 since Java 18, so a Windows-1252 byte in a comment becomes U+FFFD and nothing else changes; and the loader needs the .def extension and at least one export before it looks.
What the loader reads¶
A module-definition file is the linker's input for what a DLL exports, documented under Module-Definition (.def) Files ↗. Ghidra reads it for the other direction: given a DLL that has lost its symbols, a .def supplies names and ordinals to label the export table with. It is the first of four text formats on the import list, and the one with the smallest grammar:
| line | what it means |
|---|---|
; anything |
a comment |
LIBRARY name |
the DLL's name — Ghidra reads the keyword and ignores the line, with a comment in the source asking why |
EXPORTS |
everything after this is an export |
Name |
an export by name |
Name @7 or Name @ 7 |
with an ordinal |
Name @7 NONAME |
by ordinal only |
Name=internal |
exported under one name, defined under another |
Name=other.func or Name=other.#7 |
forwarded to another module, by name or ordinal |
Name DATA, Name PRIVATE |
flags |
In Python¶
Verified output of module_definition_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. TEXT: A KEYWORD, THEN ONE EXPORT PER LINE
------------------------------------------------------------------------
name ordinal flags internal name other module
Foo - - -
Bar 2 - -
Baz 3 NONAME -
Qux - - Foo
Data1 - DATA -
Secret - PRIVATE -
Fwd - - Func other
Fwd2 - - - other.#5
The loader skips ';' comments and empty lines, ignores LIBRARY,
starts at EXPORTS, and tokenises each line after it: the name,
with =internal or =module.name or =module.#ordinal glued on; then
an @ordinal, or an @ followed by a separate number; then flags.
2. THE LINE IS SPLIT ON WHITESPACE, SO THESE ARE THE SAME EXPORT
------------------------------------------------------------------------
'Bar @2' -> name 'Bar' ordinal 2
'Bar @2' -> name 'Bar' ordinal 2
'Bar\t@2' -> name 'Bar' ordinal 2
'Bar @ 2' -> name 'Bar' ordinal 2
'Bar@2' -> name 'Bar@2' ordinal None
Except the last: with no whitespace the '@2' is part of the name
token, and the ordinal is never seen. Whitespace is the grammar.
3. THE CHARSET IS THE JVM'S DEFAULT, WHICH IS UTF-8 SINCE JAVA 18
------------------------------------------------------------------------
written as UTF-8 LIBRARY line reads 'café' exports ['Foo', 'Bar', 'Baz']...
written as Windows-1252 LIBRARY line reads 'caf�' exports ['Foo', 'Bar', 'Baz']...
Ghidra opens the file with new InputStreamReader(stream) and no
charset, so the JVM's default applies -- UTF-8 on every platform
since JEP 400, and Ghidra 12 runs on Java 21. A .def saved by an
old Visual Studio in the ANSI code page decodes its accented
comment and library name to U+FFFD, and every export is untouched,
because export names are ASCII. The failure is invisible exactly
where it does not matter.
4. LINE ENDINGS DO NOT MATTER, AND ONE THING DOES
------------------------------------------------------------------------
LF 8 exports
CRLF 8 exports
CR alone 8 exports
with a BOM 8 exports -- the first line no longer starts with ';', but nothing before EXPORTS is read anyway
BOM then EXPORTS as the first line 0 exports -- 'EXPORTS' is not at the start of the line any more
readLine() ends a line at LF, CRLF or a bare CR, so the three files
parse alike. A byte-order mark is not whitespace and startsWith
does not skip it: a file whose first line is EXPORTS, saved as
UTF-8 with BOM, has no exports at all to this reader.
Whitespace is the grammar¶
Section 2 is the format's one rule: a line is tokenised on whitespace, so Bar @2, Bar @2, a tab and Bar @ 2 are four spellings of one export, and Bar@2 is an export named Bar@2 with no ordinal. Ghidra's DefExportLine is a StringTokenizer over the line — the first token is the name, an @ anywhere after it starts the ordinal, and the flag words are compared as whole tokens. A line with fewer tokens than it needs is not an error; it is a name.
The charset nobody chose¶
Section 3 is why the page is here. DefLoader opens the file with new InputStreamReader(inputStream) and no charset argument, and Java fills in the platform's default charset — which, since JEP 400 ↗ in Java 18, is UTF-8 everywhere, and Ghidra 12 requires Java 21. So a .def written by Visual Studio in the machine's ANSI code page has any byte above 0x7F replaced with U+FFFD on the way in: Société in a comment becomes Soci�t�, the LIBRARY line likewise, and every export is unaffected, because export names are ASCII by construction. Mojibake that lands only where nothing reads it — which is the version of the bug that ships.
Before JEP 400 the default was the platform's — windows-1252 on a Western Windows, UTF-8 on Linux and macOS — so the same .def decoded three ways on three machines. That is the trap Locale and LC_CTYPE describes for the terminal, met in a JVM, and the reason JEP 400 exists.
What a line ending is, and what a BOM is not¶
Section 4: Java's readLine() ends a line at LF, at CRLF, or at a lone CR, so a .def saved on any system parses the same, and CRLF vs LF does not apply. A byte-order mark is a different matter. U+FEFF is not whitespace and startsWith("EXPORTS") does not skip it, so a file whose first line is EXPORTS, saved as UTF-8 with BOM, has no exports at all to this reader — and a comment line in front, which most .def files have, hides the problem, because nothing before EXPORTS is read. The same three bytes broke a shebang on The first two bytes.
What Ghidra checks¶
DefLoader ↗ accepts a file whose lower-cased name ends in .def and whose parse yields at least one export; it is a wrapper loader, meaning it adds to a program already open rather than creating one, and for each export it labels the address of Ordinal_N with the name. Its name is Module Definition (DEF). There is no magic, no signature, and no charset — the name and the grammar are the whole test.
If you are coming from Python or ABAP¶
Python. open(path) with no encoding= is the same mistake in a different language: the default is locale.getpreferredencoding() until UTF-8 mode, and Opening a file measures what that is on each platform. Pass encoding='utf-8', errors='replace' to get exactly Java's post-JEP-400 behaviour, or newline=None and str.split() for the tokenising. The program's parser is forty lines because the format is.
ABAP. (Not machine-checked — CI cannot run ABAP.) OPEN DATASET ... IN TEXT MODE ENCODING DEFAULT is the same default with the same history: UTF-8 on a Unicode system, and whatever the code page was before. A .def is an interface file like any other text file an ABAP job reads, and the two rules that transfer are to name the encoding at the OPEN and to check the first line for EF BB BF before comparing it with a keyword. Verify any code-page number against the system.
Try it¶
- Take any DLL's
.def— Visual Studio projects have them, anddumpbin /EXPORTSwill make one — and count the exports withgrep -c '^ *[A-Za-z_]' file.defagainst what the program's parser finds. - Add a comment with an accented word, save the file as Windows-1252 from a Windows editor, and open it in Python with
encoding='utf-8', errors='replace'. Find the�. - Save the same file as UTF-8 with BOM with
EXPORTSas the first line.xxd -l 4, then count the exports again. - Write
Foo@1on one line andFoo @1on the next, and see which one has an ordinal. - Rename the file
.txtand offer it to Ghidra. The loader is not in the list.
See also¶
- PE — the export table these names are applied to
- MAP — the other text file that labels a binary, with columns instead of keywords
- Opening a file — what a language's default encoding is, measured
- Locale and
LC_CTYPE— the platform default JEP 400 replaced - The first two bytes — a BOM in front of a keyword, on a shebang
- Module-Definition (.def) Files ↗ — the grammar, as Microsoft documents it