CrystalRE is an IDA Pro plugin designed to enhance reverse engineering of binaries compiled from the Crystal programming language. Crystal uses a sophisticated symbol mangling scheme and custom runtime structures that IDA doesn't handle well on its own. This plugin automatically demangles symbols, applies type information, and identifies runtime structures to make Crystal binaries easier to analyze.
Symbol Demangling
Crystal encodes rich type information in symbol names, but in a way that IDA doesn't like. This plugin automatically extracts and displays this information in a readable format.
Using the standalone parser in symbols.py, we can see how these are parsed (a few examples):
RVA: 0xaaae0
Type: FUNCTION
Original: *Array(Pointer(Void))@Array(T)::new:Array(Pointer(Void))
Parsed data: {'self_type': 'Array(Pointer(Void))', 'name': 'new', 'return_type': 'Array(Pointer(Void))', 'metaclass': 'Array(T)', 'class_method?': True}
RVA: 0x12a310
Type: FUNCTION
Original: *Pointer(Tuple(UInt64, UInt64, String))@Object::==<Pointer(Tuple(UInt64, UInt64, String)).class>:Bool
Parsed data: {'self_type': 'Pointer(Tuple(UInt64, UInt64, String))', 'name': '==', 'args': ['Pointer(Tuple(UInt64, UInt64, String)).class'], 'return_type': 'Bool', 'metaclass': 'Object', 'class_method?': True}
RVA: 0xef4b0
Type: FUNCTION
Original: *Array(UInt64)@Array(T)::new<Int32>:Array(UInt64)
Parsed data: {'self_type': 'Array(UInt64)', 'name': 'new', 'args': ['Int32'], 'return_type': 'Array(UInt64)', 'metaclass': 'Array(T)', 'class_method?': True}
Automatic Function Signatures
Using the parsed symbol information, the plugin automatically sets correct function signatures including:
- Accurate argument counts
- Type information where available
- Special return semantics (like NoReturn for functions that don't return)
String Detection
Crystal's String type has a specific and predictable runtime layout. The plugin scans the binary to identify and label String objects, making them easy to reference and understand.
Set __crystal Calling Convention Action
__crystal is a custom calling convention the plugin registers. x86_64 compiled binaries use the normal __fastcall convention, but structs that are passed by value typically have the struct's contents "inlined" in the function parameters. Take this function for example:
def do_thing(a : Int32, b : Slice(UInt32)) : Nil
endSlice(T) structs are defined like this
struct Slice(T) {
Int32 size; // offset 0
Bool read_only; // offset 4
T* pointer; // offset 8
};Since this struct is <= 32 bytes in size it'll get passed by value. Notice how size and read_only fit in 8 bytes, so one might think that they can be passed together in a single register (IDA assumes this too!), but that is not the case. Instead, the compiler essentially turns the function into this
# NOTE: the symbol still shows Slice(Int32), this is just to showcase how each member gets split up
def do_thing(a : Int32, b.size : Int32, b.read_only : Bool, b.pointer : Pointer(Int32))
endSo if you set the Slice(Int32) type as the second arg in IDA, it assumes that size and read_only get passed in the same register which breaks decompilation outputs. One way to circumvent this is by using __usercall and manually specifying the registers used in the slice param, but that's a lot of manual work and the function prototype ends up looking super ugly.
The solution? Creating a custom calling convention. When you set the __crystal calling convention on a function, it'll emulate how __fastcall works, but if it ever encounters a user defined type (udt), it'll extract all the members and let them have their own registers/stack space.
To use it, right click on the function definition and select "Set __crystal calling convention" or click the function definition and press Shift + C
Set Crystal Types
Crystal has type names that conflict heavily with the default type parser IDA provides, so to set crystal types you can right click a variable and select Set Crystal type... and input a type. You can also click on a variable and use Shift + Y to do the same thing.
Note that the type doesn't have to exist in the database for it to be created. If you input something that doesn't exist yet and it's a valid crystal type, it'll automatically create the type then set it.
| Before | After |
![]() |
![]() |
| Before | After |
![]() |
![]() |
| String contents appear as inline comments in decompiled code and function names are much nicer to read | |
| Before | After |
![]() |
![]() |
| String objects are automatically detected, typed, and labeled | |
- IDA Pro 9.0 or later with Hex-Rays Decompiler
- Python 3 support enabled in IDA
-
Install IDA's hcli
-
Install plugin
hcli plugin install CrystalRE-
Install dependencies:
# Make sure to use the same python installation that IDA uses python3 -m pip install pyelftools regex -
Copy the plugin files to your IDA plugins directory:
# On Linux/macOS cp crystalre.py ~/.idapro/plugins/ cp -r crystalre ~/.idapro/plugins/ # On Windows copy crystalre.py %APPDATA%\Hex-Rays\IDA Pro\plugins\ xcopy /E crystalre %APPDATA%\Hex-Rays\IDA Pro\plugins\crystalre\
-
Restart IDA Pro
The plugin operates automatically on first load. It:
- Validates the binary is a Crystal executable
- Parses and applies function symbols
- Scans for and labels String objects
- Installs decompiler enhancements
The plugin stores its state in the IDA database, so it won't re-run on subsequent loads. To reinitialize, delete the IDB file and reopen the binary.
- If a binary has dwarf symbols and you load it with default options, every unhandled function will be of type
int __cdecl ()and it ruins decompilations. This is an issue with crystal itself and not the plugin. The only way to fix this is to uncheckApply calling conventionsandFunction prototypes are definitivewhen the DWARF info pop-up appears. - If a
Proccontains a closure, the crystal codegen will inject the closure as the first arg. There's no way of knowing if this closure parameter has been injected from the symbol name alone, so we just assume all procs don't have closures. If you're analyzing a proc and the decomp look messed up, it's probably because the closure is the real first arg. - If a function has a struct return and that struct is greater than 32 bytes in size, a return pointer gets inserted as the first parameter into the function. This parameter doesn't appear in the function symbol, so it's very hard to detect if it's inserted or not. To avoid false positives, it is never checked for, so make sure to be weary for those.
- If a function is large enough and uses enough complex types, you'll likely encounter issues with var reuse. This makes decomps look very ugly and unfortunately I don't know any good ways to split vars to prevent this ):
- Works best if symbols are included (there's no strip option when building, so most binaries include symbols)
- Optimized for x86-64 Linux binaries (32-bit binaries likely wont work well)
Author: Nico Posada







