The Cerbero Shell Language

Introduction

The Cerbero Shell is a small, safe command language woven throughout Cerbero Suite and Cerbero Engine. You can use it in the dedicated shell view, in workspaces such as the hex editor, from the command line with the cshell tool, and — through the Pro.Shell module — in your own plugins and standalone scripts.

It fills two roles. Foremost, it is a comfortable everyday language for the arithmetic and bit-twiddling that reverse engineering is full of. Integers are always shown in both decimal and hexadecimal, so there is no need to wrap a value in hex() the way you would in Python, and bitwise operations, byte handling and encoding or decoding are all first-class:

4096              # shows as 4096 (0x1000) -- no hex() needed
0xff & 0x0f       # 15 (0xf)
(1 << 20) - 1     # 1048575 (0xfffff)

Second, when an object is being analyzed, the shell also exposes a registry of read-only functions — grouped into domains — for inspecting it.

Which domains are present depends on the context. The file-format domains and the obj, scan and carbon domains appear only when there is a matching object, scan or disassembly context; in a bare hex-editor workspace they are simply absent, while the core language — numbers, strings, bytes, math, encoding, regular expressions (rx), json and help — is always available.

The language is deliberately restricted: it never executes or evaluates Python code, and every function is read-only unless it explicitly asks for consent. That makes it safe to expose even to an automated agent, which can compute and inspect but cannot run arbitrary code or modify anything without approval.

A command is a single line whose result is displayed; there is no statement separator and no multi-line control flow.

Expressions and Types

The language evaluates expressions over integers, floats, strings, bytes, booleans, null, lists and dicts.

Integers may be written in decimal, hexadecimal, binary or octal and are displayed as decimal (hex) — negative values as decimal (-hex). Floats use the usual notation:

0x1000 + 512          # 4608 (0x1200)
0b1010 | 0o17         # 15 (0xf)
-255                  # -255 (-0xff)
3.14

Strings come in four forms. Escaped strings support the \n, \r, \t and \xNN sequences, \" for a quote and a doubled backslash for a literal backslash — any other escape is an error. Raw strings keep every backslash literal (except \", which inserts a quote), which makes them ideal for regex patterns. Triple-quoted strings span multiple lines — in the REPL an unterminated one continues on the ... prompt — with escapes still processed, and raw triple-quoted strings combine both behaviors:

"MZ\x90\x00"          # escaped string
r"\d+\.\d+"           # raw: backslashes stay literal
"""line one
line two"""           # multi-line, escapes processed
r"""multi-line and
raw: \d+ stays \d+"""

Bytes literals b"..." support the same escapes as escaped strings but produce bytes values; raw bytes literals (br"...") are not supported:

b"\x4d\x5a"           # b"MZ"
b"MZ" + b"\x90" * 2   # b"MZ\x90\x90"

Booleans are written true and false and the null value is null; comparisons produce booleans, and the logical operators accept only booleans.

Lists and dicts are written with the usual literals and display as indented entries:

{"e_magic": 0x5a4d, "e_lfanew": 0xf8}

e_magic: 23117 (0x5a4d)
e_lfanew: 248 (0xf8)

Elements are reached with index and slice access:

b"\x4d\x5a\x90\x00"[0]        # 77 (0x4d)
"kernel32.dll"[0:8]           # "kernel32"
entry["offset"]               # dict member

Index access is strict — a missing key or out-of-range index is an error; get(container, key, default) is the lenient form.

Variables

Values are stored in variables with = and updated with compound assignment (+=, -=, *=, |=, &=, ^=, <<=, >>=, …):

base = 0x400000
rva = 0x1a2b0
base + rva            # 4301488 (0x41a2b0)
mask = 0xffff
mask <<= 16           # mask is now 0xffff0000

Variable assignment can be disabled entirely (the enableVariables method of Pro.Shell) to lock the language down for agentic callers so they can only invoke functions.

Operators

The usual arithmetic (+ - * / // % **), comparison (== != < > <= >=), logical (and or not) and bitwise (& | ^ ~ << >>) operators are supported. Logical operators act only on booleans and bitwise operators only on integers; + also concatenates strings, bytes and lists, and * repeats strings and bytes:

(0x41a2b0 - 0x400000) >> 12       # page index: 26 (0x1a)
size % 0x200 == 0                 # sector-aligned?
b"\x90" * 16                      # NOP sled
~0x0f & 0xff                      # 240 (0xf0)

From lowest to highest precedence: -> (pipe), or, and, not, comparisons, |, ^, &, shifts, + -, * / // %, unary - ~, ** (right-associative), and postfix index/slice.

The Pipe Operator

The pipe operator -> feeds the value on its left in as the first positional argument of the call on its right. Additional arguments on the right shift over by one. It is left-associative, so chains read left to right:

strings.list -> len                   ==  len(strings.list)
scan.hashes -> grep "sha"             ==  grep(scan.hashes, "sha")
strings.list -> grep "http" -> len    # how many extracted strings mention http?

Because -> has the lowest precedence, the whole left-hand expression is evaluated first: 1 + 2 -> bin is bin(1 + 2).

Functions and Domains

Functions are identified by name. A qualified name has the form domain.function (for example scan.hashes or strings.filter). The qualified name is looked up in a registry — it is not object access; the language has no object model.

Calls accept two styles, both supporting positional and named (name:value) arguments:

strings.list 0 100                    # shell style (whitespace-separated)
strings.list(0, 100)                  # parenthesized style
strings.filter "amazon" cs:true       # named argument, shell style
strings.filter("amazon", cs:true)     # named argument, paren style

Named arguments accept literals, variables, list/dict literals and parenthesized expressions (offset:(base+1)).

A function that takes no arguments is called by its bare name — no () needed — so constant-like functions read as if they were variables:

strings.count                 # a zero-argument call
rx.email                      # returns the e-mail regex pattern
scan.risk                     # 55 (0x37)

This is used extensively by the rx domain, which provides ready-made patterns (rx.url, rx.email, rx.ipv4, rx.ipv6, rx.mac, rx.path, rx.winpath, rx.date, rx.time, rx.hexstr, rx.base64) that are passed straight to search functions as if they were constants.

Filtering with grep

grep(value, pattern, rx:false, cs:false, ww:false) is the workhorse for narrowing results. Its behavior adapts to the value:

  • string — keeps the lines that match (useful on help or other raw text);

  • dict — if a key matches, that entry is kept (key projection); otherwise it recurses into the values and keeps the surrounding context of any match;

  • list — recurses into the elements and keeps the matching ones.

Set rx:true to treat the pattern as a regular expression, cs:true for case sensitivity and ww:true for whole words.

{"address": 1, "size": 2, "addr": 3} -> grep "addr"

address: 1 (0x1)
addr: 3 (0x3)
scan.hashes -> grep "sha256"

sha256: "9c3b60db19307d8a7b61a3ccced86f5d4515980431d75b8d42f85f6005d1df7d"
strings.list -> grep rx.ipv4 rx:true      # entries containing an IPv4 address
help strings -> grep "filter"             # find the right function to call

Working with Strings

The strings domain is a complete workflow: scan once, then count, filter and page through the results. It is available both with a loaded object and on raw bytes.

strings.scan                       # scan the current object; returns the count
strings.count                      # count (reflects the active filter)
strings.filter "http"              # refilter from the full set; new count
strings.list 0 20                  # a page of entries
strings.entry 0                    # one entry in detail
strings.reset_filter               # back to the full result set

The explicit strings.scan is optional: any other strings function scans on first use, so a filter can be the very first command. Combined with the rx pattern constants, hunting for indicators is a one-liner:

strings.filter rx.email rx:true    # scans if needed; keeps only e-mail addresses
strings.list                       # the matching entries
strings.entry 0 -> get "text"      # the first address itself

Each entry is a dict — {offset, size, text, encoding, lang} plus a location when available — so the results compose with the rest of the language.

Regular Expressions

rx.find(value, pattern, start:null) returns the first match — {pos, len, match}, with a groups list when the pattern has capture groups — or null; rx.findall returns every match. Both accept strings, bytes and lists, and combine naturally with the pattern constants:

rx.findall "mail a@b.com or c@d.org" rx.email

[0]:
  pos: 5 (0x5)
  len: 7 (0x7)
  match: "a@b.com"
[1]:
  pos: 16 (0x10)
  len: 7 (0x7)
  match: "c@d.org"

By default patterns are checked for ReDoS safety and pathological ones (such as (a+)+b) are rejected; config.safe_rx reports whether the check is active.

Getting Help

Typing help with no argument lists the available topics — the built-in helpers, each domain, and any user-registered functions — each with a one-line description:

Type 'help <topic>' for details. Available topics:

  builtins  - core helpers: type conversion, encoding, math, collections, formatting
  pe        - PE headers, sections, data directories, imports/exports, resources
  scan      - scan-engine results: identity, risk, hashes, metadata, tree position

help <name> prints the detailed help for a function (its parameters, defaults and return shape); help <domain> lists the functions in a domain.

Every topic form has two shorter equivalents. The pipe form <name> -> help and the ? shortcut both mean the same as help <name>:

strings.filter?            # == help strings.filter  (suffix form)
?strings.filter            # == help strings.filter  (prefix form)
?                          # == help                 (topic listing)
strings.filter -> help     # == help strings.filter  (pipe form)
strings.filter -> ?        # == help strings.filter  (pipe form, '?' target)

In all of these the name is treated as a topic and is not evaluated — so strings.scan? shows the help without starting a scan. A non-name such as 5? is an error. Help output is text, so it can be piped: help builtins -> grep "b64".

Display

Values are shown in a canonical form: integers with their hex, strings quoted with escapes, and lists and dicts as indented, keyed entries. Two functions, help and print, instead emit raw text — shown unquoted with real line breaks. Raw text is still an ordinary string (its type is "string"), grep preserves its raw display when filtering it, and str() converts it back to a normal quoted string. Control characters other than newline and tab are escaped even in raw output, so text extracted from an analyzed binary cannot inject terminal escape sequences.

print is also the way to render any string raw — for example dotnet.disasm(token) -> print to read disassembly as text.

Limits

Every command runs under limits that keep results bounded — important when the caller is an agent or a script. The active values are reported by the config domain:

config.max_bytes      # largest string/bytes result   (default 8 MiB)
config.max_int        # largest integer, in bits      (default 16384)
config.max_items      # most entries in one listing   (default 100000)
config.max_depth      # deepest value nesting         (default 256)
config.max_line       # longest input line            (default 1 MiB)
config.safe_rx        # ReDoS pattern check active?
config.vars           # variable assignment enabled?
config.verbose        # diagnostic warnings enabled?

A result that would exceed a limit is an error rather than a truncation — listing functions take start / count arguments to paginate instead. The limits are adjusted from the embedding API (Pro.Shell), not from the language itself.

The cshell Command-Line Tool

The shell is also available from the command line as the cshell tool, which starts a REPL. Launched without arguments it offers the core language; with -s it scans a file first and wires the shell to it, exposing the matching domains:

cshell                          # core language only
cshell -s malware.doc           # scan a file and expose its domains
cshell -s dump.bin -format pe   # force the format instead of auto-detecting it
cshell -novars                  # disable variable assignment

In the REPL each line is a command (> prompt); an unterminated triple-quoted string continues on the next line (... prompt). On a terminal the REPL provides line editing, history and completion; Ctrl+C discards the current input and an end-of-file (Ctrl+D, or Ctrl+Z followed by Enter on Windows) exits.

Embedding and Extending

To drive the shell from a plugin or script — wiring it to a scanned object, running commands, registering your own functions or adding functions through the shell.cfg configuration file — see the Pro.Shell module.