An attack path should let another engineer follow one actor, one input, one reachable entry, the controls that run or fail, and one harmful effect at a named revision and deployment. Put the plain-language consequence first, then show the short chain of evidence. Mark each step as source-observed, deployment-confirmed, safely observed in an authorized environment, or still inferred. If a missing middleware rule, service check or route map could break the chain, make that uncertainty visible. A source-traced path is an explanation of how harm may occur; it is not evidence that someone executed the attack.
For an access-control issue, the key question is not merely whether the user can call a function. It is whether that user may act on the specific object the function retrieves. OWASP's API1:2023 broken object-level authorization guidance makes that distinction explicit. An attack path should show where the object-specific decision is made or missed.
Make the path small enough to check
Use five nodes:
- Actor and prerequisite: the least privileged actor for whom the claim holds, plus any needed account, role or network access.
- Entry and controlled value: the request, job, event or file that carries a value the actor can choose.
- Control boundary: authentication, authorization, validation, policy and service checks that should constrain the action.
- Sensitive operation: the read, write, export, call or decision that uses the value.
- Effect and recipient: the data or state that changes and how it reaches the actor or another party.
The chain must be contiguous. A citation to a route and a separate citation to a database method do not prove that this route calls that method. The call edge, transformation and relevant condition matter. The OWASP Authorization Cheat Sheet recommends validating permission on every request; OWASP's Authorization Patterns guide describes keeping enforcement close to the protected resource so the requested action, object and tenant can be checked.
Illustrative case: an attachment under the wrong project
Fictional application, identifiers and source references. Ashfall Projects lets a user view attachments for projects they belong to. A viewer is a member of Project A but not Project B. The UI calls a gateway route to download an attachment. The request includes a project ID and an attachment ID.
At revision JKL012, a source reviewer sees this chain:
Viewer in Project A
→ GET /projects/A/attachments/{attachment_id_for_B}
→ gateway confirms the viewer belongs to Project A
→ attachment service looks up the supplied attachment ID
→ file service returns the selected attachment body
The missing comparison may be between the attachment's actual parent and Project A. The gateway checked membership in the project named in the URL, but that alone would not authorize an attachment whose stored parent is Project B. The sensitive step is the attachment lookup and delivery, not the presence of a guessable ID. A random ID can make discovery harder; it does not replace authorization for the requested object. This is the kind of object-level distinction in OWASP API1:2023.
The draft path is still incomplete. Perhaps the attachment service compares its stored parent with the URL project; perhaps the file service checks the viewer independently; perhaps the deployed gateway route differs from the reviewed branch. The writer checks those points and records each result rather than writing “authorization bypass” over a single code line.
Here is a filled path ledger after the illustrative review:
| Step | Evidence at JKL012 | Confidence and limit |
|---|---|---|
| Viewer can submit the attachment ID | Gateway route schema accepts the ID from the request; viewer role can call the route. | Source-supported; production route mapping separately confirmed. |
| Gateway checks Project A membership | Gateway policy call and Project A membership lookup are visible. | Source-supported; this check authenticates access to A, not to attachment B. |
| Attachment service fetches by supplied ID | Handler passes the ID to a direct lookup. | Source-supported. |
| Attachment belongs to B | Fictional test fixture's owner record binds it to B. | Test-fixture evidence, not a real customer's record. |
| Parent or viewer check after lookup | No such check found in the inspected service and file-delivery paths. | Bounded absence claim; list every path reviewed. |
| Delivery | File service appears to return the selected body. | Source-supported; no live production download was attempted. |
The “no check found” row must name the bounded paths searched. It cannot mean that no control exists anywhere in the infrastructure. A service maintainer or second reviewer should inspect the exact deployed artifact and independent authorization points before approving a vulnerability verdict.
Write the human version before the code version
A useful first sentence is:
A viewer authorized for Project A may be able to download an attachment belonging to Project B when the request names A in the URL and supplies B's attachment ID, because the service uses the ID without verifying the attachment's stored project against the viewer's access.
That sentence carries actor, action, asset and failed rule. “May be able” is appropriate until the independent downstream and deployment checks finish. If they finish and support the path, remove the conditional and say what evidence established it. Do not switch to “proven exploit” unless an authorized exploit was actually performed. If a safe test was performed in a synthetic environment, name that test and keep its environment separate from production.
The technical trail should expose the actual handoff, not merely list plausible filenames. Here is pseudocode for the constructed case:
get_attachment(viewer, url_project, attachment_id):
require_member(viewer, url_project)
attachment = attachments.get_by_id(attachment_id)
return files.read(attachment.storage_key)
For viewer Lea, url_project = A; the authoritative attachment record is {id: B7, project: B, storage_key: K7}. The first check can pass correctly. The record then selects K7 without comparing B with A or asking whether Lea may download B7. The source path supports the proposed mismatch only if files.read(K7) actually delivers that object's bytes without a later applicable denial.
A convincing-looking citation can still break this chain. Suppose the first write-up cites files.preview, which checks a project, but the handler calls files.read. The cited control says nothing about this call. Replace it with the implementation reached by files.read, bind its storage client to the same deployment, and trace K7 into the returned body. In the supplied exercise artifacts, that method uses a service credential and performs no viewer or project decision. The chain is now continuous; it supports independent verification of this specific cross-project download claim.
Change the implementation to require authorize_download(viewer, attachment.project, attachment.id) before reading K7 and the verdict changes. Merely adding attachment.project to a log does not. A useful review comment names the exact edge that is cut: the foreign attachment is denied after authoritative lookup and before file delivery.
In a real record, each pseudocode edge needs a pinned source reference, including the actual storage wrapper and policy. Keep those references access controlled. OWASP's reporting guidance supports enough technical detail to reproduce the reasoning while protecting sensitive evidence.
Include the controls you ruled out
An attack path is stronger when it explains why nearby defenses do not settle the case:
- Login check: establishes identity, not entitlement to attachment B.
- Project A membership: permits actions within A; it does not change B's stored parent.
- UI hides B: the UI is not the server's authorization decision.
- Random attachment ID: raises the knowledge prerequisite but does not authorize use of a known ID.
- File service policy: potentially decisive; if it binds caller, action and attachment B, the path fails. Inspect it rather than assuming.
This is not a demand to prove a negative across the whole system. It is a bounded statement about the route and controls that could break this path. If the code calls a shared policy engine, cite the policy input and decision, not just the call name. If a job or cache serves the file later, account for that delivery path separately.
Give the developer a repair condition
The repair condition should state the property that must hold, not prescribe one patch blindly:
Before an attachment body is returned, derive its stored project and authorize the current viewer for the requested download action on that attachment in that project. A project ID supplied in the URL is a selector to verify, not proof of ownership.
Then specify an acceptance check at a new revision: an authorized Project A attachment remains accessible to the viewer; an attachment stored under B is denied through the direct download and any alternate export path; the check still holds at the delivery service or another enforced boundary. An engineer might implement this in a repository query, service policy or dedicated authorization layer. The reviewer should verify the property across the relevant paths, not declare victory because a line was added.
If the deployed route, attachment parent or downstream decision remains unknown, write “investigation” and name the missing artifact. If a checked control binds the stored parent and denies the viewer, reject this path with that counterevidence. If a possible disclosure warrants precaution, notify the service owner with a conditional risk statement while review continues.
Represent branches without losing the object
A longer path is not necessarily a better explanation. If attachment delivery has several branches, keep the protected object stable and draw only the branches that can change authorization or receipt. For the Ashfall case, a useful extension is a small branch table, with B7 remaining the selected attachment throughout:
| Branch after lookup | Fact to establish | Consequence for the original path |
|---|---|---|
| Direct body response | The reader of B7's storage key does not make a later resource decision | The originally described delivery remains relevant. |
| Background export | The job binds the original caller, selected attachment and authorized delivery scope | A separate asynchronous edge needs evidence. |
| Previously generated download | Retrieval checks the applicable permission before returning usable access | A safe generation path alone cannot answer retrieval. |
Do not combine the most permissive facts from different branches into an imaginary attack. For example, the background worker's broad storage access and the direct route's lack of an owner comparison do not prove harm unless the direct route actually invokes that worker or otherwise reaches that access. Each arrow should describe a real call, message, lookup or response relationship in the selected source.
The same discipline applies to identities. If the request begins with Lea's user session but storage uses a service credential, name the transition. The service credential explains why the storage read is technically permitted; it does not automatically preserve Lea's resource restrictions. The path should show where those restrictions are enforced or lost. Repeating “authenticated” at both hops hides the very distinction the developer needs to see.
Before sending the path, perform a cold read: follow the source references in order without using the summary as an assumption. Can you recover the stored parent B, the action being authorized and the recipient of the bytes? If the answer changes halfway through, split the path or obtain the missing link. A compact path with one explicit unknown is easier to check than a seamless narrative assembled from unrelated evidence.
Try the five-node form on your next finding and ask a developer unfamiliar with it to locate each node from your references. If they cannot, repair the path before filing. For the broader ticket fields, use the finding checklist. When the chain crosses several owning services, continue with reviewing a cross-service finding.