Skip to content
Klarion
Documentation

Written against the build you can download today.

Scripting

Anything the interface can do to an analysis, a script can do a thousand times without you watching: rename by pattern, hunt across every cross-reference, or produce exactly the report you need. Klarion embeds Lua for that, and imports into your own Python for when the pipeline is somewhere else already.

Requires Klarion Pro or Team

Two languages, one API

Lua is built into every copy of Klarion. It runs in the application, it runs headless from the command line, and it is confined: a Lua script cannot touch your filesystem or your network. Nothing to install, and safe to run against a binary you do not trust.

Python works the other way round. Rather than Klarion hosting an interpreter, your interpreter imports Klarion, so a notebook, a CI job or a script you already have can reach the engine with one more import. It is an ordinary library with your privileges, and is not confined.

The two bind the same forty-three operations under the same names, returning the same records with the same fields. The reference at the bottom of this page is both of them. Choose on where your script lives, not on what it can ask.

In the application

Press Ctrl+L to open the Scripts tab, write a script, and press Ctrl+Return to run it against the binary already open in front of you. Output appears underneath as it is produced.

The editor has line numbers, because Lua reports errors as script:12: and counting lines by hand is the thing you would otherwise be doing most. Open and Save work on ordinary .lua files, so a script you keep is a file you can put in version control rather than something trapped inside a project.

Scripts run on a background thread, so a slow one does not freeze the window, and Stop cancels a script that is taking longer than you meant it to. Anything a script renames or comments shows up immediately in the disassembly. It is the same analysis, not a copy of it.

From the command line

The same scripts run headless, which is what you want for a batch of binaries or a CI job:

$ klarion-cli script app.exe find-entrypoints.lua

Whatever the script prints appears as it runs. Changes it makes, names and comments, are discarded when the command finishes unless you ask for them to be kept:

$ klarion-cli script app.exe rename-by-string.lua --save

--save writes a .kldb project beside the binary: the same file the application opens, and the same one the next script reads. Run one script to rename and a second to report, and the second sees the first’s work.

The engine is also reachable through the MCP server, which is how an AI agent drives the same API.

Your first script

In Lua the API hangs off a global table called klarion. This one finds every function nothing calls, which on a stripped binary is a short and interesting list:

find-entrypoints.lua
-- Every function that nothing calls. Usually entry points,-- exported callbacks, or code the analysis has not linked up yet.for _, addr in ipairs(klarion.functionAddresses()) do  if #klarion.callersOf(addr) == 0 then    print(string.format("0x%x  %s", addr, klarion.getName(addr)))  endend

Something worth running twice

The loop that pays for itself: find a string, find who references it, and name the function after what it does. On a binary with a few thousand functions this turns a wall of sub_401000 into something you can read.

rename-by-string.lua
-- Name the functions that use a recognisable string, so the-- listing stops being a wall of sub_401000.for _, s in ipairs(klarion.strings()) do  if s.referenced and #s.text > 6 then    for _, ref in ipairs(klarion.xrefsTo(s.address)) do      local fn = klarion.functionContaining(ref.from)      if fn and klarion.getExplicitName(fn.entry) == "" then        local hint = s.text:gsub("%W+", "_"):sub(1, 24)        klarion.setName(fn.entry, "uses_" .. hint)        klarion.setComment(fn.entry, 'references: "' .. s.text .. '"', "function")      end    end  endend

Note the getExplicitName(...) == "" guard: it only renames functions nobody has named yet, so running it again after your own work does not overwrite it.

From your own Python

Klarion also ships as an importable module. There is no console to move into and no interpreter for Klarion to find: your Python loads the engine the way it loads anything else.

import klarion with klarion.open("sample.exe") as program:    print(program.binary.format, program.architecture.name)    for fn in program.functions:        if not program.callers_of(fn.entry):            print(hex(fn.entry), fn.name)

klarion.open(path) loads a binary and runs the analysis pipeline over it, returning a Program. Pass analyse=False to load without analysing, which is fast and gives you sections, symbols, imports and bytes but no recovered functions; format= forces a loader instead of detecting one, and arch= forces an architecture, which is also how you pick the slice of a universal Mach-O.

Operations that take no arguments read as properties (program.functions, program.statistics); everything else is a method (program.get_name(addr), program.disassemble(addr, 10)). That is the one difference from the reference below, which is written in the calling form Lua uses. Both spellings of every name are bound to the same function, so program.function_count and program.functionCount are the same thing, and so are fn.block_count and fn.blockCount.

Records are dictionaries whose fields are also attributes, so fn["entry"] and fn.entry both work — the first because a record is a dict and every tool that takes one keeps working, the second because typing quotes ten thousand times is not analysis.

Installing it

The installer puts klarion.pyd in a python folder beside the application rather than into one of your interpreters. Which interpreter you meant is your decision, not an installer’s, so point Python at it:

$ set PYTHONPATH=C:\Program Files\Klarion\python

It is a CPython extension built against Python 3.13 for 64-bit Windows, and will not import into a different minor version. klarion.__version__ and klarion.build report which build you have.

What it is actually for

A directory of samples, a question, and no interface in the way:

# The same question asked of a directory rather than a binary:# which of these samples imports something worth a second look?import globimport klarion WATCH = {"CreateRemoteThread", "VirtualAllocEx", "WriteProcessMemory"} for path in glob.glob("samples/*.exe"):    with klarion.open(path, analyse=False) as program:        hits = {            symbol.name            for module in program.imported_modules            for symbol in module.symbols            if symbol.name in WATCH        }        if hits:            print(path, sorted(hits))

Headless use is licensed, the same feature the command line and the MCP server sit behind. klarion.open() raises klarion.LicenceError without one, deliberately not a subclass of klarion.Error so that a script catching analysis failures cannot swallow it, and carrying exit_code == 3 to match klarion-cli. Ask klarion.licensed() or klarion.edition() first if you would rather not find out by exception.

What a script can and cannot do

Lua is confined. Its os and io libraries are never opened, so a script cannot read your filesystem, start a process or open a socket. Running one against an untrusted binary does not hand it your machine. Everything a Lua script can do to the outside world goes through the API on this page, which is bounded and auditable. The available libraries are string, table, math, utf8 and coroutine. A script that loops forever is stopped rather than hanging the application.

Python is not confined, and could not be. It is a C extension loaded into an interpreter you already control, beside your own os, subprocess and socket, with your privileges — exactly like every other library you import. Klarion’s sandboxing claim is about the Lua engine and stops there. The module says so itself: klarion.isolation() returns "host-privileges". Treat a Klarion Python script the way you would treat any script, and run the ones you would have run without Klarion.

Neither language changes the binary on disk. Names, comments and byte patches live in the analysis, and reach a file only when something writes one out.

Reference

Both languages bind every function below. In Lua they are on the klarion table; in Python they are on the Program, with the no-argument ones spelled as properties and every name available in snake_case as well. Addresses are plain integers, so 0x401000 works as you would expect.

Failure is reported the way each language reports failure: Lua returns ok, err, Python raises klarion.Error. An operation that finds nothing returns nil in Lua and None in Python.

Functions

functionCount()
How many functions analysis recovered.
functionAddresses()
An array of every function's entry address.
functions()
An array of function records. Heavier than functionAddresses; use it when you want the fields rather than the addresses.
functionAt(address)
The function whose entry is exactly this address, or nothing.
functionContaining(address)
The function this address falls inside, or nothing. This is the one you usually want.
blocks(address)
The basic blocks of the function at this address, each with its instruction addresses, successors and predecessors. The control-flow graph, as data.

Names

Names are keyed by address, not by function. The same call renames a function entry, a data item or a plain code label, which is what an analyst actually does.

getName(address)
The name shown for this address, whether you set it or analysis generated it.
getExplicitName(address)
Only a name somebody set. Empty when the name is auto-generated, which is how you tell sub_401000 from a real one.
setName(address, name)
Renames. Fails, rather than silently doing nothing, if the name is malformed or taken.
clearName(address)
Drops an explicit name, so the generated one comes back.

Comments

Placement is "trailing" (default), "preceding", or "function" for the one that appears in the function header.

getComment(address, placement?)
The comment text, or nothing.
comments(address)
Every comment at this address, across all placements.
setComment(address, text, placement?)
Writes a comment.
clearComment(address, placement?)
Removes one.

Cross-references

This is where most real scripts live. Work backwards from a string or an import to the code that uses it.

xrefsTo(address)
Everything that refers to this address.
xrefsFrom(address)
Everything this address refers to.
callersOf(entry)
The entry addresses of the functions that call this one, deduplicated: one entry per calling function, not one per call site. Takes a function's entry address.
calleesOf(entry)
The addresses this function calls, deduplicated, gathered across every basic block of its body rather than from the entry instruction alone. Takes a function's entry address; anything else returns nothing.

Strings

strings()
Every recovered string, with address, encoding and whether anything references it.
stringAt(address)
The string starting at this address, or nothing.

The memory map and its symbols

Segments are what the loader mapped; sections are what the file named. They are not the same thing, and on a stripped binary only one of them exists.

segments()
The memory map: start, end, size, permissions.
segmentAt(address)
The segment containing this address, or nothing.
sections()
Named sections, with the segment each belongs to.
sectionAt(address)
The section containing this address, or nothing.
symbols()
Every symbol the loader recovered, including anything read from a PDB or from DWARF.
symbolAt(address)
The symbol at this address, or nothing.
findSymbol(name)
The symbol with this name, or nothing. The way in when you know what you are looking for but not where it is.
importedModules()
Imports grouped by module, each with its symbols and ordinals.
entryPoints()
The entry points the image declares. Where an analysis of an unfamiliar binary starts.
imageBase()
The image base address.

Instructions

Decoding is on demand and cached, so walking every instruction of a large binary is a reasonable thing to write.

readBytes(address, length)
Raw bytes, reading through any patch the script has made.
decode(address)
One instruction: mnemonic, rendered text, length, bytes, category, branch kind and target, and the operands with their registers and displacements.
disassemble(address, count?)
A run of instructions, one after another. Count defaults to one.
assemble(text, address)
Assembles one instruction and returns the bytes, without writing anything. The address is required because a branch encodes a displacement from where it sits.

Patching

Patches apply to the analysis in memory. The listing re-decodes through them, so the effect of an edit is visible immediately rather than at save time.

patchBytes(address, bytes)
Overwrites bytes.
patchAssembly(address, text)
Assembles one instruction and writes it, returning how many bytes it took.
patches()
Every byte edit so far, each with the original bytes beside the patched ones.
clearPatches()
Reverts every edit.

About the binary

binary()
Facts about the loaded file: path, format name, image base, address range, file size, and whether anything has been patched.
architecture()
The resolved architecture: name, bitness, pointer size, byte order, longest instruction.
statistics()
Aggregate counters: functions, instructions, strings, cross-references. A cheap way to end a batch script with a line worth reading.

Position

getCursor()
Where the cursor is.
setCursor(address)
Moves it.

In Lua the table is also reachable as ph, which is what it was called before it had documentation. Both names are the same table. New scripts should use klarion.

Something missing?

This API is deliberately small, and it grows in the direction people push it. If a script you want to write is not possible with what is here, that is worth an email. It is usually a binding we have not needed ourselves yet.