Half Exploiting an Xprinter XP-410B Over Bluetooth

posted: August 22, 2026

I had an Xprinter XP-410B sitting next to me, and I started looking at it as an embedded reversing target instead of just a printer.

The original goal was simple: get some kind of shell, reach its internal storage, pull firmware, or find a way to execute something inside the printer.

I did not get a shell.

What I did get was a useful halfway point: the Linux driver gave up its TSPL command generation almost immediately, the printer exposed a writable BLE GATT characteristic, raw TSPL worked over Bluetooth, the printer answered read-only queries, and its own FILES command exposed a persistent flash file store.

At that point it stopped being a printer setup and became an embedded reversing project.


Scope

This was performed against my own XP-410B, physically present during testing.

The host was Arch Linux with:

CUPS
Ghidra
objdump
nm
strings
bluetoothctl
btmon
Python
Bleak

One important distinction appeared during the work:

Feasycom module firmware:
9.2.2,20240405

Printer model:
XP-410B

The first value came from the Bluetooth module’s Device Information service. It is not proof that the printer’s main controller firmware is version 9.2.2.


Part 1: Getting the XP-410B into CUPS

The package already contained an Xprinter PPD:

xprinter/XP-410B.ppd _Xprinter XP-410B

The active PPD contained:

*Manufacturer: "_Xprinter"
*1284DeviceID:"MFG:Xprinter;CMD:TSPL2;MDL:XP-410B;CLS:PRINTER;"
*cupsFilter: "application/vnd.cups-raster 0 rastertosnailtspl-xprinter"

That last line was the interesting part because it pointed directly at the Linux filter:

/usr/lib/cups/filter/rastertosnailtspl-xprinter

CUPS pipeline

The driver became the first reversing target.


Part 2: The filter is basically a TSPL generator

The binary is an ELF64 executable and, luckily, it still contains useful C++ symbols.

Some of the symbols were:

TsplPrinter::TsplPrinter()
TsplPrinter::doPrintImage(...)
SnailPrinter::printByte(...)
SnailPrinter::printBytes(...)
SnailPrinter::printStr(...)
SnailPrinter::printFormat(...)
setSize(...)
setGap(...)
setRibbon(...)
setDirection(...)
setReference(...)
setOffset(...)
setSpeedAndDensity(...)
setCls()
setBitmapCmdHead(...)
setPrint(...)

I started in Ghidra and followed TsplPrinter::doPrintImage().

The decompiler showed the driver building a bitmap representation and then feeding each byte into SnailPrinter::printByte().

The relevant part looked like:

if (*(int *)(this + 0x71c) == 0) {
    setBitmapCmdHead(0,0,(int)local_40,(int)param_3,0);

    for (...) {
        SnailPrinter::printByte(
            (SnailPrinter *)this,
            *puVar5
        );
    }
}

SnailPrinter::printStr((SnailPrinter *)this,"\r\n");
setPrint(1,*(int *)(lVar6 + 0x154));

Ghidra bitmap path

That gives a simple mental model:

CUPS raster

image conversion

TSPL BITMAP command

raw bitmap bytes

PRINT

Part 3: Recovering the TSPL commands

Instead of reading every assembly instruction, I searched the binary’s strings:

strings -tx /usr/lib/cups/filter/rastertosnailtspl-xprinter |
grep -E 'SIZE|GAP|OFFSET|REFERENCE|RIBBON|DIRECTION|SPEED|DENSITY|BITMAP|PRINT|CLS'

The result was:

3437c SIZE %.1f mm, %.1f mm
343cf GAP 0, 0
3442d SET RIBBON OFF
34447 SET RIBBON ON
34463 DIRECTION %d,0
34474 REFERENCE 0,0
34484 OFFSET %.1f mm
34514 SPEED %s
3451f DENSITY %d
34532 BITMAP %d,%d,%d,%d,%d,
34549 PRINT %d,%d

Recovered TSPL strings

So the driver is very clearly generating TSPL-style commands.

The key helper functions all eventually call SnailPrinter::printFormat(...).

For example, setSize(float, float) loads the string at the .rodata address and passes the dimensions into printFormat().

setBitmapCmdHead() does the same with:

BITMAP %d,%d,%d,%d,%d,

and then the raw bitmap is emitted byte by byte.


Part 4: Following the output abstraction

One thing I expected to find was libusb, a Bluetooth library, or a serial implementation inside the filter itself.

There wasn’t one.

ldd showed normal CUPS/system dependencies but no dedicated libusb or Bluetooth library.

readelf showed imports for:

open
write
fopen
fwrite

And SnailPrinter::printByte() writes each byte through a FILE *.

The constructor also pushes stdout into the internal output stream list.

So the practical architecture looked like:

CUPS

rastertosnailtspl-xprinter

TSPL

stdout / FILE*

CUPS backend

printer transport

CUPS pipeline

That was useful because it meant the Linux filter was primarily a protocol translator rather than a direct Bluetooth implementation.


Part 5: Finding the Bluetooth command channel

The XP-410B exposed several BLE services. The most interesting one was:

000018f0-0000-1000-8000-00805f9b34fb

with:

00002af1-0000-1000-8000-00805f9b34fb
    write
    write-without-response

00002af0-0000-1000-8000-00805f9b34fb
    notify

BLE services

I used Bleak to connect and enumerate the services.

That gave me a usable pair:

WRITE_UUID  = "00002af1-0000-1000-8000-00805f9b34fb"
NOTIFY_UUID = "00002af0-0000-1000-8000-00805f9b34fb"

Part 6: Direct Bluetooth printing

The first proof-of-control was a tiny TSPL job:

SIZE 58 mm,40 mm
GAP 2 mm,0 mm
CLS
TEXT 50,50,"3",0,1,1,"HELLO FROM ARCH"
PRINT 1,1

Printed Paper “Hello From Arch”

The corresponding BLE capture contained the ASCII command directly:

53 49 5A 45 ...   SIZE 58 mm,40 mm
47 41 50 ...      GAP 2 mm,0 mm
43 4C 53          CLS
54 45 58 54 ...   TEXT ...
50 52 49 4E 54 ... PRINT 1,1

Raw TSPL write in btmon

The printer physically printed the label.

That was the point where I stopped looking at the Linux side and started talking to the printer itself.


Part 7: BLE is not the only Bluetooth interface

btmon also showed that the XP-410B exposes Bluetooth Classic.

The device advertised:

Serial Port (0x1101)

and SDP showed:

RFCOMM
Channel: 1

The Bluetooth Classic connection also showed:

Encryption: Disabled

during the discovery session.

So there are effectively two useful research surfaces:

BLE GATT

0x2AF1 write
0x2AF0 notify

Bluetooth Classic

SPP

RFCOMM channel 1

For the rest of the research I stayed on BLE because it was easier to script and it already exposed a useful notify channel.


Part 8: First interesting printer-side command — FILES

After getting direct TSPL execution working, I tried the printer’s FILES command.

Instead of just doing something invisible, the printer printed:

DRAM FILE: 0FILE
FLASH FILE: 2FILE

TSS24.VF2
TSS16.BF2

FILES result

That gave me a concrete lead.

The printer has resident storage, and the printer itself reports:

FLASH FILE: 2FILE

The two visible names are:

TSS24.VF2
TSS16.BF2

These are very likely font/resource files, not the entire firmware image.

Still, this is exactly the kind of thing I was hoping to find: a command language that knows about persistent storage.

Files Printed


Part 9: Turning the printer into a tiny query API

The same BLE write/notify pair worked for read-only queries.

~!F

The printer returned:

TSS24.BF2
TSS16.BF2

Raw notification:

54535332342e4246320d
54535331362e4246320d
1a

~!A

The printer returned:

DRAM:128
FLASH:2631

Raw notification:

4452414d3a3132380d
464c4153483a323633310d

~!T

The printer returned:

XP-410B

~!V

The printer returned:

80

Printer query results

Repeated ~!F queries produced the same resident files every time.

So at this point there was a small, reproducible read-only interface:

BLE write

TSPL query

XP-410B

BLE notify

That was the most useful protocol-level result from the session.


Part 10: A minimal direct-BLE research client

Once the UUIDs were identified, the printer could be queried without CUPS:

import asyncio
from bleak import BleakClient

ADDRESS = "DD:0D:30:EF:BF:23"

WRITE_UUID = "00002af1-0000-1000-8000-00805f9b34fb"
NOTIFY_UUID = "00002af0-0000-1000-8000-00805f9b34fb"

COMMAND = b"~!F\r\n"

async def main():
    async with BleakClient(ADDRESS, timeout=15) as client:

        def notify(_, data):
            print("NOTIFY:", data.hex(), repr(bytes(data)))

        await client.start_notify(NOTIFY_UUID, notify)

        await client.write_gatt_char(
            WRITE_UUID,
            COMMAND,
            response=False
        )

        await asyncio.sleep(2)

asyncio.run(main())

The same transport was enough for a direct print too:

COMMAND = b"""SIZE 58 mm,40 mm
GAP 2 mm,0 mm
CLS
TEXT 50,50,"3",0,1,1,"HELLO FROM ARCH"
PRINT 1,1
"""

So the path was:

Python

BLE GATT

TSPL

XP-410B

No Wi-Fi. No socket://localhost:9100. No CUPS required.


Part 12: The interesting unfinished question — can the resident files be read back?

The biggest remaining question became:

Can TSS24.BF2 / TSS16.BF2 actually be exported from the printer?

FILES proves that the firmware has a resident file store, but a listing is not the same thing as file extraction.

The next stage would be to study the printer’s resident-file commands and determine whether there is a readback primitive that can safely return binary data.

The safer research strategy is:

1. enumerate read-only commands
2. understand the file API
3. prove readback on a disposable test object
4. only then touch existing resident resources

I did not jump into destructive commands such as deleting files or running arbitrary resident programs during this session.

That leaves the most interesting part for another pass.


Part 11: Why this was still a good target

A cheap printer sounds boring until you look at it from the protocol boundary.

There was:

a Linux driver

a text-based printer language

BLE GATT

Bluetooth Classic SPP

persistent printer storage

device-side command processing

That gives several layers to reverse:

Layer 1 — Linux
CUPS
PPD
raster filter

Layer 2 — Protocol
TSPL / TSPL2 commands

Layer 3 — Transport
BLE / GATT
Bluetooth Classic / RFCOMM

Layer 4 — Embedded device
command parser
status responses
file storage

Layer 5 — Firmware
flash layout
bootloader
MCU code
update mechanism

The Linux driver and BLE protocol together made the first four layers surprisingly accessible.


Conclusion

What started as:

"How do I get this stupid printer working on Arch?"

turned into:

CUPS

rastertosnailtspl-xprinter

TSPL reverse engineering

BLE GATT discovery

direct TSPL injection

physical printer control

read-only device queries

flash file enumeration

The most interesting result was this:

DRAM FILE: 0FILE
FLASH FILE: 2FILE

TSS24.VF2
TSS16.VF2

That is the point where the printer stops looking like a dumb output peripheral and starts looking like an embedded system with an actual storage model and command interface.

There is no shell yet.

But there is now a working map of the road leading toward one.


Final status

[CONFIRMED]

XP-410B detected

CUPS driver identified

TSPL generator reversed

BLE GATT writable characteristic identified

TSPL transmitted directly

HELLO FROM ARCH printed

Printer responses received

Resident flash files enumerated

[NOT CONFIRMED]

Firmware extraction
Arbitrary code execution
RCE

The printer was not rooted.

It was, however, partially reverse engineered. since its not online printer. its not possible to get a shell. but possible to get firmware files from physical RE.

THE F ING POINT IS NOW I CAN USE THE PRINTER WITHOUT NEEDING TO INSTALL ANY DRIVER SHIT IN ANY DEVICE IDC ABOUT THE SHELL SINCE IT DOESNT HAVE WIFI CHIP IN IT SO AM HAPPY WITH THIS.