← THE INDEX  ·  VULN RESEARCH

CYCLOPS

Autonomous source-to-exploit research. One rule: nothing gets reported unless a deterministic test could reproduce it.

CYCLOPS

Authorized research only. All targets are open-source projects audited in local sandboxes or faithfully-reproduced replicas. No live exploitation of third-party production instances was performed. Full tooling source is kept private; this page describes the methodology and representative findings (sanitized).

What it is

CYCLOPS is an autonomous security research platform I built to do systematic source-to-exploit audits of open-source software. The system maps a target's attack surface, generates hypotheses about authorization, authentication, and memory-safety defects, drafts proofs-of-concept, and only advances a finding when a deterministic test confirms it: sanitizer output, a live SQL row, or an executed shell command. That last step is the entire design constraint. A finding that can't be reproduced doesn't get reported.

Over a concentrated research sprint across 20+ targets, the platform produced 17 reproduced findings spanning web application access-control, SSRF, path traversal, CLI argument injection, memory-safety, and authentication bypass across Node, Python, Go, and C++ codebases. It also caught its own errors: one SMD loader candidate was withdrawn after a dynamic test proved the claimed code path was unreachable.

The reproduce-before-report discipline

The hardest part of automated security research isn't generating hypotheses. It's not believing the wrong ones. CYCLOPS enforces a three-tier proof taxonomy:

  • DYNAMIC: an exploit ran. ASan/valgrind fired, a real command executed, live rows leaked. This is the only tier that gets a full claim.
  • CODE-VERIFIED: the defect is confirmed line-by-line against verbatim source; the exploit technique is standard and deterministic; impact path traced to completion. Usable, but labeled as analysis-derived in any report.
  • WITHDRAWN: anything a dynamic test refutes. The SMD sibling of an Assimp finding looked confirmed by static analysis and an adversarial review, but the mandatory ASan run imported the file cleanly and found nothing. No report.

An independent adversarial audit of 25 findings caught 6 severity over-statements (memory-safety issues labeled HIGH when demonstrated impact was read-only DoS) and one under-statement (a path traversal that was unauthenticated by default, not conditional). The audit also confirmed that a NoSQL injection I had pinged as account takeover didn't actually complete: the JWT mint function was miswired and returned a 500. The ping was retracted. That kind of self-correction is the whole point.

Proof taxonomy in numbers

62 findings reviewed across 25+ targets. Of those:

  • 17 DYNAMIC (reproduced): executed PoC, sanitizer output, or REPLICA_CONFIRMED. These are the only findings presented with full claims.
  • 37 ANALYSIS-DERIVED (code-verified): defect confirmed line-by-line against verbatim source; impact path traced to completion; standard exploitation technique. Presentable, but always labeled.
  • 1 WITHDRAWN (false positive caught by dynamic testing): the SMD loader candidate that the mandatory ASan run refuted. One DUD caught and discarded is more honest than a perfect-looking count.
  • 6 severity corrections downward in the adversarial audit, plus 1 upward. Four MEDIUM findings were initially over-tagged HIGH; the final severities are realistic-vector × proven-impact, nothing more.

Self-corrections worth naming: - CYCLOPS-2026-0014 (Trudesk NoSQL injection): the $ne-null operator injection is real and executed, but the downstream JWT mint was miswired. generateJWTToken is callback-style awaited without a callback, so the endpoint returned a 500 instead of a token. The "account takeover" claim was retracted; the injection component stands. - SMD loader (sibling of CYCLOPS-2026-0004): the AddBoneChildren recursion only visited valid indices; the crafted .smd imported cleanly under ASan. Excluded entirely. - Three candidates rejected by the authz table pass as false positives: a statistic stub with no data path, a route already gated behind an admin token, and a second-order chain that required the attacker to be an existing admin. None reached the report stage.

Representative findings

Archivy ripgrep argument injection (HIGH). The most direct finding. rg_cmd = ['rg', '-it', 'md', '--json', query, datadir] passes the search query positional with no -e separator and no -- terminator. A request to ?query=--pre=<cmd> causes ripgrep to execute an arbitrary command as the web server's user. Tested end-to-end on real ripgrep 14.1.1; the command ran.

Wekan unauthenticated instance takeover (HIGH). POST /api/createtoken/:userId calls an async auth guard without await in a synchronous try/catch. An async function never throws synchronously (it returns a rejected promise), so the guard's rejection lands on a detached promise while execution falls through to Accounts._insertLoginToken(id, token), which persists and returns a usable login token for any userId including admin. The REST API is enabled by default in the snap build and the shipped docker-compose. A faithful Node.js replica confirmed both an unauthenticated and a low-privilege caller receive the admin token; the guard's rejections fire after the response.

Mealie queryFilter logical injection (MEDIUM, unauthenticated). The public explore API string-concatenates the user-supplied queryFilter into a DSL: f"({q.query_filter}) AND {public_filter}". Mealie's QueryFilterBuilder consolidates groups right-associatively. Submitting name <> "zzz") OR (name <> "zzz" produces a WHERE clause that is true for every row, demoting the privateHousehold=FALSE AND public=TRUE gate into one OR-branch. Confirmed by running the real QueryFilterBuilder and inspecting the generated SQL and live SQLite output.

Assimp HL1 MDL bone-index OOB (MEDIUM). temp_bones_[pbone[i].parent] uses a file-controlled int32_t value with no bounds check. ASan aborted at HL1MDLLoader.cpp:472 with a heap-buffer-overflow 640 bytes past a 160-byte allocation. A second sink at :1222 (read_attachments) triggered independently. Unreported instances of the known unchecked-index class in this loader family; no dedicated CVE.

mealie/services/query_filter/builder.py (simplified): the structural injection sink. User input goes into the f-string at line 3; the DSL's right-associative consolidation then demotes the security gate into one OR-branch.
# controller_public_recipes.py: public explore endpoint
public_filter = "(household.preferences.privateHousehold = FALSE AND settings.public = TRUE)"
if q.query_filter:
    q.query_filter = f"({q.query_filter}) AND {public_filter}"  # attacker controls q.query_filter

# Attacker submits:  name <> "zzz") OR (name <> "zzz"
# f-string builds:
#   (name <> "zzz") OR (name <> "zzz") AND (household.preferences.privateHousehold = FALSE AND settings.public = TRUE)
#
# QueryFilterBuilder._consolidate_group iterates reversed(group), right-associative:
#   -> A OR (B AND PUBLIC_GATE)
#   -> With A = always-true predicate, every row matches.
#   The public/private gate no longer constrains the result set.

# Proof: ran on the real QueryFilterBuilder with a SQLite session.
# Generated WHERE: (name <> 'zzz') OR ((name <> 'zzz') AND (...))
# Rows returned: ALL recipes in the group, including private-household ones.

Memory-safety findings: sanitizer output

Five of the 17 reproduced findings are memory-safety defects in C and C++ parser code, confirmed by AddressSanitizer or valgrind. The terminal-recording GIFs below show actual sanitizer runs, not mocked output.

CYCLOPS-2026-0001: Sereal::Merger heap OOB read (valgrind, 3 sinks)

Sereal::Merger processes each document in two passes. Pass 1 (srl_build_track_table) advances the cursor over SHORT_BINARY tags with no bounds check. Pass 2 copies with srl_buf_copy_content_nocheck, justified by a comment claiming pass 1 already validated the lengths. For SHORT_BINARY and fixed-width numeric tags, it never did. Three distinct sinks all confirmed with valgrind --error-exitcode=99, exit code 99. Adjacent heap bytes are copied into the merged output document: the live heap leak demonstrated across three consecutive runs shows different bytes each time, confirming real process memory crossing the trust boundary. The Decoder sibling (patched CVE-2026-8796) and Path::Iterator sibling (has SRL_RDR_ASSERT_SPACE) are both safe; the Merger alone substituted the _nocheck path.

CYCLOPS-2026-0005: Sereal Ruby C ext signed-length OOB (ASan)

In buffer.h:142, s_get_p_req_inclusive takes int req (signed). The check req > 0 ? req-1 : 0 evaluates to 0 when len >= 0x80000000, skipping the bounds test. rb_str_new(ptr, len) then reads ~2 GB from a small buffer. The rejected control (0x10000000, a positive large value) fires the bounds check correctly, isolating the signedness bypass as the sole defect.

CYCLOPS-2026-0002/0003/0004: Assimp HL1 MDL, MMD/PMX, MD5 loader OOB

Three independent unchecked array-index reads (and one OOB write, analysis-derived) across Assimp 6.0.5's parser family. All three triggered via the standard Assimp::Importer::ReadFile API with crafted input files. Unreported instances of the known unchecked-index class in these loaders; no dedicated CVE assigned to any.

CYCLOPS-2026-0001: valgrind output, Sereal::Merger heap OOB read (3 sinks, exit 99). The over-read bytes are copied into the merged output; consecutive runs show distinct heap content.
doc hex=3df3726c050074 len=7      # header + single SHORT_BINARY tag claiming 20 bytes, 0 follow

==9512== Invalid read of size 8
==9512==    at 0x54BC9FB: srl_merge_single_value.isra.0 (.../Sereal/Merger/Merger.so)
==9512==    by 0x54C2CE8: srl_merger_append (.../Merger.so)
==9512==    by 0x54B5617: XS_Sereal__Merger_append (.../Merger.so)
==9512==  Address 0x502ad66 is 6 bytes inside a block of size 10 alloc'd   <- tag byte (in-bounds)
==9512== Invalid read of size 8
==9512==    at 0x54BCA09: srl_merge_single_value.isra.0 (.../Merger.so)
==9512==    by 0x54C2CE8: srl_merger_append (.../Merger.so)
==9512==  Address 0x502ad73 is 9 bytes after a block of size 10 alloc'd    <- OOB READ past input buffer

valgrind --error-exitcode=99 exit code: 99   (confirmed on all 3 sinks: SHORT_BINARY, FLOAT, REGEXP)

# Live heap info-leak: trailing bytes of merged output, 3 consecutive runs:
run 1 leaked heap -> [.................1...........B]
run 2 leaked heap -> [.................1.........0.]
run 3 leaked heap -> [.................1........s..Na]
CYCLOPS-2026-0042: Wekan un-awaited async guard, REPLICA_CONFIRMED output. Both an unauthenticated and a low-priv caller receive a usable admin login token; the guard's rejections fire after the response.
=== CYCLOPS-2026-0042 Wekan un-awaited-async-guard bypass: REPLICA proof ===

[UNAUTH]   POST /api/createtoken/admin-id  (req.userId=undefined)
           response: {"_id":"admin-id","authToken":"tok_admin-id_0"}
[LOWPRIV]  POST /api/createtoken/admin-id  (req.userId=bob-lowpriv)
           response: {"_id":"admin-id","authToken":"tok_admin-id_1"}

[detached unhandled rejections fired AFTER the response: 2]

Tokens actually minted (would authenticate as that user):
    {"userId":"admin-id","token":"tok_admin-id_0"}
    {"userId":"admin-id","token":"tok_admin-id_1"}

RESULT:
  unauth response carried an admin authToken : true
  lowpriv response carried an admin authToken: true
  admin login token was persisted (usable)   : true

  => BYPASS CONFIRMED: the async guard rejected on a DETACHED promise (caught by neither
     the sync try/catch nor any awaiter); the token-mint sink ran and returned a usable
     admin login token to BOTH an unauthenticated and a low-priv caller. Full ATO.

Additional proven findings

CYCLOPS-2026-0014: Trudesk unauthenticated NoSQL operator injection (MEDIUM)

POST /api/v2/token has no authentication middleware. The handler extracts req.body.refreshToken, checks only if (!refreshToken) (truthy check, so an object passes), then calls getUserByAccessToken(token) which runs findOne({ accessToken: token, deleted: false }) with no string coercion and no express-mongo-sanitize. Posting {"refreshToken":{"$ne":null}} selects the first accessToken-holding user. Executed: the operator injection returns the expected user record. The downstream JWT mint is miswired (callback-style generateJWTToken awaited without a callback → 500), so the "account takeover" claim was retracted and the ping pulled. The injection primitive itself is real and executed. Presenting it as such.

CYCLOPS-2026-0027: Mealie shopping-list cross-tenant IDOR (MEDIUM)

Mealie centralizes multi-tenant isolation in RepositoryGeneric._filter_builder(), which scopes reads to group_id and household_id. But update_many() and delete_many() build their queries from the unscoped _query(), filtering only by id.in_(...). There is also a SQLAlchemy 2.0 no-op in _query(): q.filter(...) returns a new Select that gets discarded. End-to-end detonation via a faithful-replica (SQLAlchemy 2.0.50 + SQLite, two tenants): reads stay scoped, cross-tenant update_many and delete_many both commit against the foreign row. The attacker's own item is untouched (confirming it is the absent scope, not a global wipe).

CYCLOPS-2026-0025: Realms-Wiki unauthenticated path traversal write (HIGH)

sanitize() and to_canonical() operate on a denylist that filters empty path segments but does not strip . or / characters. A page name of ../../etc/somefile survives sanitization, and path.join(wiki_root, name) escapes the wiki directory. Write, overwrite, delete, and rename all reach arbitrary .md paths. The @login_required decorator is applied via ALLOW_ANON, which is True by default, so the traversal is unauthenticated on a default install. Executed in sandbox: escaped the wiki root, overwrote a victim file.

CYCLOPS-2026-0014: Trudesk NoSQL operator injection, executed output. The $ne:null operator selects the first accessToken-holding user. ATO claim withdrawn (JWT mint returns 500); injection component stands.
[legit] correct refreshToken string -> { token: 'JWT(sub=admin1, role=admin)' }
[wrong] guessed string             -> 401
[ATTACK] {"$ne":null}              -> { token: 'JWT(sub=admin1, role=admin)' }   <-- operator injection selects admin user
[ATTACK] {"$gt":""}               -> { token: 'JWT(sub=admin1, role=admin)' }

# Note: downstream JWT mint is miswired in this target (generateJWTToken callback-style,
# awaited without callback -> 500). Operator injection is real and executed; ATO does
# not complete against the real target. Severity: MEDIUM (injection primitive, not ATO).

Memory-safety proof gallery

Terminal recordings of the actual sanitizer runs. Each GIF shows: root cause in source, crafted input construction, sanitizer invocation, and the abort or valgrind error output. These are not simulated. They are the real rg --error-exitcode / ./poc_harness / valgrind -q runs captured at the time of discovery.

Bug-class methods developed

Working through 20+ targets at this depth required building reusable methods for each bug class:

  • Sibling-guard divergence: diff the multiple guards or queries protecting one resource. The one loading less data can't enforce a check the other enforces. Found IDORs in Pingvin Share (reverse-share download), Let's Chat (file download), OhMyForm (submission resolvers), Trudesk (DM read vs. delete), and Peppermint (tickets, clients, webhooks).
  • Un-awaited async auth guard: an async guard called without await in a synchronous handler never throws. Its rejection detaches. The Wekan finding was the Tier-0 instance; three additional sites of the same pattern were found in the same codebase.
  • Query-DSL injection: concatenating user input into a filter DSL is structural injection even when values are parameterized. Right-associative folds demote security gates. Grep for f"... AND {security_filter}".
  • CLI argument injection: positional subprocess args without -e or -- separators accept flags. rg --pre, nmap --script, find -exec, and similar RCE primitives are reachable this way.
  • SSRF guard gap analysis: break custom SSRF guards at resolution decoupling (validated IP not pinned, redirect not re-validated), address-space gaps (CGNAT 100.64/10 missing from blocklists), and IPv4-IPv6 split.