Skip to content

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

  1. Take any DLL's .def — Visual Studio projects have them, and dumpbin /EXPORTS will make one — and count the exports with grep -c '^ *[A-Za-z_]' file.def against what the program's parser finds.
  2. 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 .
  3. Save the same file as UTF-8 with BOM with EXPORTS as the first line. xxd -l 4, then count the exports again.
  4. Write Foo@1 on one line and Foo @1 on the next, and see which one has an ordinal.
  5. Rename the file .txt and offer it to Ghidra. The loader is not in the list.

See also