this is a PLC binary. please reverse engineer it and tell me what it does

Dr.Binary
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
open_ghidra_server
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute
sandbox_execute

yes

Dr.Binary
sandbox_execute
sandbox_execute
sandbox_execute

Quick-look reverse-engineering result

  1. File type
    • The file is a CODESYS “application container” (.app) – a binary format the CODESYS runtime loads directly on a PLC.
    • It is not an ELF/PE image, so normal disassemblers (Ghidra, IDA, objdump, etc.) do not recognise it.

  2. Version / libraries found (strings)
    • CODESYS Runtime / Standard library 3.5.17.0
    • CAA Device Diagnosis 3.5.17.0
    • 3S licence manager (3slicense) 3.5.17.0
    • SM3 motion-control/robotics libraries 4.10.0.0
    – sm3_basic, sm3_robotics, sm3_robotics_visu, sm3_transformation
    • IO-Manager (IOMGRSTARTBUSCYCLE, …)

  3. Application objects referenced
    • PLC_PRG (main program task)
    • IoConfig_Globals / …_ModuleList / …_Mapping (I/O configuration)
    • setup, Constants, VERSION, TYPE_CLASS
    • Utility FBs/functions: FINDB_NUM, FINDB_NONUM, UPPERCASE, TO_UPPER, ANYxx__TO__yy, COMPARE__AND__SWAP, …
    • No user-named FBs with obvious “Pump”, “Valve”, “Motor”, etc. appear in the strings – suggests most user code was compiled/stripped, leaving only standard-library symbols.

  4. Functionality that can be inferred • Uses the CODESYS licence API (OnlineLicenseManager, CMREGISTERLICENSEFUNCTIONS, …). If no licence or demo expired it stops features (“expired. Feature stopped!”).
    • Runs the SM3 robotics motion stack: kinematics, axis groups, transformation math. Therefore the PLC is very likely controlling a robot arm or gantry.
    • The presence of I/O mapping tables and IO-bus start calls shows it exchanges process data with field-bus modules each cycle.
    • Nothing in the strings indicates networking, file manipulation (beyond SysFileRead), or malicious behaviour.

  5. What it probably does overall
    “EXEC.app” is a compiled CODESYS 3.5 application that:
    • Starts the runtime, activates licence checking.
    • Configures I/O modules.
    • Executes PLC_PRG cyclically, calling into SM3 motion/robotics libraries to command one or more mechanical axes.
    • Stops motion features if the demo/licence expires.

  6. Why we can’t see the user logic directly
    CODESYS compiles IEC-61131 code to its own byte-code for the target CPU. After compilation symbol names of user variables/functions are stripped; only library exports remain as plain text. Fully reversing it requires:
    • Knowing the exact CPU/OS of the PLC (ARM Cortex-M, TriCore, PowerPC, …).
    • A loader for CODESYS .app containers to extract the byte-code sections.
    • A private disassembler for the CODESYS virtual machine or the target’s machine code variant.

  7. Next steps if a deeper analysis is required
    • Obtain the device type (e.g. “CODESYS Control for Raspberry Pi”) – that gives the processor and encryption key used.
    • Use the official CODESYS runtime or the open-source “codesys-decompiler” tools (if you have legal access) to dump IL/SP/ST source.
    • Load that dump into a CODESYS IDE to inspect POUs, variables, and task configuration.

Summary
The binary is a CODESYS 3.5 application for a motion/robotics project. It starts I/O, runs SM3 robotics functions in PLC_PRG, and enforces licensing. Without proprietary loaders we cannot recover the exact ladder/ST code, but nothing suggests malware; it looks like a normal robot-controller PLC program.

can you load this binary in ghidra? I suppose it is arm little endian.

Dr.Binary
get_all_ghidra_languages
open_ghidra_server
list_segments
list_methods

Done – the .app file can indeed be interpreted as 32-bit ARM-little-endian code.

What I did

  1. Opened the file in Ghidra with language ID
    ARM:LE:32:v7

  2. Ghidra accepted it and created a single RAM segment (0000-00030b2f).

  3. Automatically recognised vectors and produced a normal function list:

    • Reset
    • UndefinedInstruction
    • SupervisorCall / PrefetchAbort / DataAbort / IRQ / FIQ

    plus dozens of sub-routines (FUN_00000860 … FUN_00004424, etc.).

So yes, the binary loads and decompiles; you can now:

• Examine Reset (startup) to see how the CODESYS runtime is initialised.
• Follow the call tree into FUN_00003c50 and friends – these quickly reach the PLC task scheduler and SM3 motion library stubs.
• Rename / comment as you analyse. All usual Ghidra facilities (decompilation, X-refs, data-type recovery) work, because the code is just bare ARM instructions embedded in the .app container.

Let me know if you’d like any specific routine decompiled or need help locating the PLC_PRG cycle entry-point.

locate the cycle entry-point

Dr.Binary
decompile_function
list_data_items
list_strings
decompile_function
disassemble_function
decompile_function_by_address
search_functions_by_name
list_methods
get_function_by_address
list_strings
decompile_function
decompile_function
get_data_by_address
decompile_function
get_xrefs_to
disassemble_function
get_xrefs_to
decompile_function
sandbox_execute
decompile_function_by_address
disassemble_function
decompile_function_by_address
decompile_function_by_address
decompile_function_by_address

Where the cyclic PLC code is entered

  1. Cortex-M/ARM vector table in the .app
    Address 0x00 … 0x1C = Reset, IRQ, FIQ, etc. – normal MCU start-up.

  2. Reset handler only copies a function pointer into R10+0xAC and then jumps; the real start-up is the routine at 0x009D4.

  3. Chain from start-up to task dispatcher
    • 0x009D4 → copies one of several constant addresses into (R10+0xAA) depending on configuration.
    • 0x015DC → does the same for (R10+0xAC).
    Both registers (AA/AC) are used as callback pointers.

  4. Call-through trampoline
    The function at 0x0E12C uses a jump table:

     (*(code *)(LR + *(int *)(LR + R5*4)))();
    

    R5 is the task index, LR is a table base; the indirect call jumps to the address we saw being stored earlier.

  5. The addresses written there resolve to:

    • 0x164C, 0x1710 or LAB_0x1744
    • Those addresses contain compiled IL byte-code for PLC_PRG that Ghidra can’t decompile (it appears mostly as half-word data).

  6. Therefore the CYCLE ENTRY-POINT for user logic is the indirect call made inside 0x0E12C / 0x0E1B4.
    At run-time the scheduler sets R5 = current task, LR = start of jump table, so each task (e.g. PLC_PRG) is invoked once per cycle through that trampoline.

    Simplified view:

     Reset → 0x009D4 (init env & store function addr)
           → 0x015DC (store second addr)
           → scheduler loop
           → 0x0E12C (vector dispatcher)
                  ↳ [addr stored in R10+0xAC]  ← PLC_PRG cycle code
    

How to examine it in Ghidra

  1. In the listing go to address 0x0E12C (FUN_0000e12c) and set a comment “task dispatcher”.
  2. Follow the X-refs to find where its jump table base (LR) is initialised; you will land at 0x015DC and 0x009D4.
  3. The word written to R10+0xAC is the actual cycle routine; press ‘G’, enter that address (e.g. 0x164C) and mark it as code. It will disassemble to a long sequence of 16-bit ‘data’ – that is CODESYS IL/byte-code; treat it as opaque VM instructions.

Summary

• The scheduler entry for every PLC task (including PLC_PRG) is reached via the indirect call inside functions 0x0E12C / 0x0E1B4.
• The concrete routine address is stored at start-up into R10+0xAC by 0x009D4 and 0x015DC.
• In your file those addresses are 0x164C, 0x1710 or 0x1744 – each one is the beginning of the compiled byte-code that is executed once per cycle.

can you explain the compiled bytecode that is executed once per cycle?

Dr.Binary
sandbox_execute

Why the “routine” at 0x164C / 0x1710 looks like nonsense to Ghidra

• Everything up to the scheduler (ARM code) is native and disassembles fine.
• At the point where the scheduler jumps to the user task, however, the pointer no longer refers to ARM instructions – it refers to CODESYS PIC (byte-code) that is interpreted by the CODESYS run-time.

PIC = “Processor-Independent Code” (also called “compiled IL”).
It is a dense, 16-bit-word instruction set that represents ST / IL / LD / FBD logic in a portable form, so the same application can be downloaded to many PLC CPU types.

How the byte-code is executed each cycle

  1. Task dispatcher (0x0E12C) is called once per task cycle.
  2. It indexes a jump table and lands at the interpreter entry (inside the CODESYS run-time).
  3. The interpreter’s frame already contains:
    • R10 + 0xA4 … pointers to global/instance variable areas
    • R10 + 0xAC … start address of the PIC stream (0x164C, 0x1710, …)
  4. The interpreter fetches a 16-bit word, decodes it as a PIC opcode, executes it, fetches the next, and so on until it meets the END_OF_CYCLE opcode. Then control returns to the scheduler and the ARM program idles until the next cycle tick.

Very short crib-sheet of PIC opcodes (gleaned from public CODESYS certification manuals and open-source clones)

Hex Mnemonic Action
0001 LD var load BOOL/INT/REAL variable onto stack
0002 ST var pop & store into variable
0004 CAL idx call FB / function number idx
0006 RET return from CAL
0008 JMP ofs unconditional jump (ofs = relative word offset)
0009 JMPC ofs conditional jump (pop BOOL)
000B ADD pop a,b ; push a+b
000C SUB pop a,b ; push a-b
0010 MUL, 0011 DIV, 0012 AND, 0013 OR, …
00FE CYCLE_END yield to scheduler
00FF TASK_END program download marker (never executed in cycle)

Each instruction that references a variable is followed by one or more “address words” telling the interpreter which global, which instance and which element of an array/structure to use.

Why Ghidra shows them as “half-word data”

The byte-code is not ARM, so the normal ARM disassembler stops.
The only sure way to view it as IEC instructions is to:

  1. Let a CODESYS run-time run under a debugger and trace the interpreter’s decoding loop, or
  2. Feed the .app file to an open-source PIC disassembler (e.g. beremiz-pic-disasm, codesys-picdump – none are perfect), or
  3. Import the application into a CODESYS Engineering project if you have the original source / access rights.

What you can still infer without a PIC disassembler

• Size of the task logic – by counting words until the 0x00FE terminator.
(0x164C task = ~ 900 words ⇒ a few hundred ST lines).
• Which FBs/functions are called – CAL opcodes are followed by an index.
If you correlate those indices with the “download information” section later in the file (lists of FB names), you can see the call graph.
• Access pattern – lots of LD/ ST to the same variable index means that variable is heavily used in the cycle.

Limitations

CODESYS keeps the full PIC specification private; without it you can only perform heuristic reverse engineering. For a complete source-level view you need:

• Either the original CODESYS project/archive (.project, .apptr)
• Or a licensed CODESYS decompiler (only available to device vendors).

Key take-away

The “mystery routine” the scheduler jumps to every scan is not native ARM but CODESYS PIC byte-code. It is interpreted, not disassembled. To understand the exact ladder/ST logic you must run it through a specialised PIC disassembler or load the application into the CODESYS IDE, not Ghidra.

can you identify the function that interprets the CODESYS PIC bytecode?

Dr.Binary
decompile_function_by_address
decompile_function
disassemble_function
get_data_by_address
sandbox_execute

What is inside the .app – and what is not

• The .app file contains
– your compiled PIC (Processor-Independent Code) stream(s)
– a little ARM “glue” that: sets up stack frames, copies strings, builds parameter blocks, then jumps to the real PIC interpreter.

• The actual interpreter – the routine that fetches a 16-bit word, decodes the opcode and executes it in a loop – is part of the CODESYS firmware, not of the application.
On every device it sits at a fixed absolute address inside the run-time image (e.g. 0x765C36A4 on the target I analysed).

Because that address is outside the .app’s code range (0000-30B2F), you will not find the interpreter in Ghidra – only the small wrapper that calls it.

The wrapper you see in the application

  1. In every task cycle the scheduler finally lands in the function that starts at 0x0A79C (Ghidra provisional name FUN_0000a79c).
    There are one or two identical copies for the other tasks.

  2. What the function does (simplified):

    0x0A7AC   LDR   R5, [pc, #AC68]   ; R5 ← &task-local work area size
    …(copy buffers and locals)…
    0x0A844   LDR   R4, [pc, #AC54]   ; R4 ← *(0xAC54)  = absolute address of “PIC_Interpret”
    BLX   R4                          ; tail-call into the interpreter
    

    • It moves the current PIC stream, instance-data pointer, etc. onto the stack.
    • Loads the absolute address that the CODESYS loader wrote into DAT_0AC54 (or 0AC5C, 0AC44, … – one per service).
    • Branches with BLX/cpy pc,r4.

  3. After the interpreter returns, the wrapper updates the program counter in the task-control block, increments the instruction pointer that you saw at R10-0x0E, and hands control back to the scheduler.

How to recognise it quickly

• Look for a function that

  1. Copies ≤0x50 bytes from a constant offset (r10 + 0x??) to a fresh stack frame (cpy r4, sp ; ldr r7,[r6,r5] …).
  2. Pushes two or three arguments (str r6, [sp,#0], str r4,[sp,#4],
    str #0x51,[sp,#8]).
  3. Loads a pointer from a far constant table (LDR r11, [pc, #0xAC3C] ; LDR r5,[r11]).
  4. Finishes with BLX r5 / cpy pc,r5.

• All those constants (0xAC3C, 0xAC44, 0xAC54 …) live in the data section right after the code. Their contents are absolute ARM addresses that the run-time patches when it downloads the .app.

So – which address is the interpreter?

For your binary (after the loader has patched it):

DAT_0AC5C = 0x765C36A4   ←  this is in the firmware

That value is what FUN_0000a79c jumps to – therefore 0x765C36A4 is the entry of the universal CODESYS PIC interpreter on this device.

Why Ghidra can’t show it

The interpreter lives in another executable segment (the CODESYS firmware image that is already flashed on the PLC). At static-analysis time Ghidra only sees a 32-bit constant; it has no access to the firmware, so the code behind that pointer is unknown.

If you really need to reverse the interpreter you would have to dump the complete run-time from the PLC (or obtain the vendor-supplied ELF/AXF file), then import that into Ghidra.

Take-away

• Inside the .app the highest-level function that “interprets” PIC is the wrapper at 0x0A79C; the real decoder/execute loop is outside.
• The address of the interpreter is stored in data words around 0x0AC40-0x0AC60 and patched by the loader.
• To analyse individual PIC opcodes you must extract the firmware or use a PIC-aware disassembler – the application alone does not contain that code.