CWE-642: External Control of Critical State Data
Overview
External Control of Critical State Data occurs when an application stores security-critical state - user role, permission level, price, or session attribute - in a location the client can read or write (a cookie, hidden form field, URL parameter, or local storage) without server-side validation or cryptographic protection. Because the client can edit that value directly, an attacker can set role=admin or price=0.01 and have the application trust it as if the server had produced it.
Relationship to Other CWEs
CWE-642 is a Class, and MITRE marks it ALLOWED-WITH-REVIEW for mapping with the rationale that it "might have Base-level children that would be more appropriate". Four of those children have their own page here, and each names a specific sink with its own remediation - so check them before filing a finding against this entry:
- CWE-472 (External Control of Assumed-Immutable Web Parameter) - the state travels in a web parameter the application assumes the client cannot change: a hidden field, a disabled input, a cookie, a URL parameter.
- CWE-15 (External Control of System or Configuration Setting) - the state is a configuration setting.
- CWE-73 (External Control of File Name or Path) - the state is a filesystem path.
- CWE-426 (Untrusted Search Path) - the state is the search path used to locate an executable or library. Its MITRE peer CWE-427 covers the case where the path is fixed and one of its directories is writable by the wrong principal; note CWE-427 sits under CWE-668 rather than under this entry, so it is outside the CWE-642 subtree even though the two are routinely reported together.
- CWE-565 (Reliance on Cookies without Validation and Integrity Checking) - the fifth child, with no page here; use CWE-472 for a cookie-borne finding.
Use this page when the finding does not land on one of those - client-side state in local storage or a mobile app's own store, workflow state carried between steps, a signed token whose payload is trusted without checking what it is bound to - or as the overview when one value feeds several of them.
Two neighbours worth separating:
- CWE-639 (Authorization Bypass Through User-Controlled Key) - a user-controlled object identifier bypasses an ownership check, where CWE-642 is user-controlled state (role, price, flags) trusted directly for a security or business decision.
- CWE-501 (Trust Boundary Violation) - the mirror image. CWE-642 is critical state kept where the client can reach it; CWE-501 is untrusted data written into a store the server trusts.
CWE-642 itself sits under CWE-668 (Exposure of Resource to Wrong Sphere) in MITRE's Research Concepts view, which is the router for the wider family.
OWASP Classification
A06:2025 - Insecure Design
Risk
High: Client-controlled state variables are a direct, easily exploitable path to privilege escalation, authentication bypass, price manipulation, and business-logic circumvention. It takes no specialized tooling, only an edit to a cookie, form field, or request parameter.
Remediation Steps
Core Principle: Never trust client-supplied data for a security decision - store security-critical state server-side, and validate any client-provided value against the authoritative server record before acting on it.
Trace the Data Path
- Source: A cookie, hidden form field, URL/query parameter, or local storage value the client controls (role, permission flag, price,
can_edit). - Sink: A security or business decision that branches on that value (authorization check, payment amount, feature access).
- Missing Control: No server-side authoritative source is consulted - the client-supplied value is trusted as-is instead of being re-derived or verified.
Store Critical State Server-Side (Primary Defense)
Keep role, permission, and authentication state in a server-side session store, keyed by an opaque session identifier. The client only ever holds the session ID, never the security-relevant value itself.
// VULNERABLE - role read directly from a client-controlled cookie
role = request.cookie("role")
if role == "admin":
grantAdminAccess()
// SECURE - role read from server-side session state
session = loadServerSideSession(request.sessionId)
if session.role == "admin":
grantAdminAccess()
Validate Client-Supplied Values Against the Authoritative Source
When the client legitimately sends a value that affects a business decision (product ID, quantity), recompute the security-relevant part (price, permission) from the server's own data instead of accepting it from the request.
// VULNERABLE - price taken directly from the client
processPayment(request.body.price)
// SECURE - price recomputed from the authoritative source, and the one value
// that must come from the client is range-checked before it multiplies anything
product = db.getProduct(request.body.productId)
quantity = to_int(request.body.quantity)
if quantity < 1 or quantity > product.max_per_order:
reject(400)
processPayment(product.price * quantity)
Recomputing the price is only half of it. The quantity still has to come from the request, so it is the remaining attacker-controlled input into the amount, and multiplying an authoritative price by an unchecked one gives the attacker the total anyway. A negative quantity turns a charge into a credit, a zero makes the order free, and an absurdly large one is a different problem for inventory. Any client value that survives into a business decision needs a range check even when everything it is combined with is server-side.
Use Cryptographically Protected Tokens When Client-Side State Is Necessary
For stateless designs where some state must travel with the client (JWTs or similar), the control is integrity, verified before the payload is parsed. A tampered payload must fail that check and be rejected rather than parsed and trusted, and the token needs a short expiry.
Encryption is not integrity protection, and reaching for it instead is how this weakness survives a fix. A confidentiality-only mode - AES-CBC, AES-CTR, any stream cipher, anything without an authentication tag - is malleable: an attacker who cannot read the plaintext can still edit the ciphertext and cause predictable changes in what it decrypts to. An encrypted role claim can be tampered from user to root without the server noticing, which is the same CWE-642 finding with an extra step.
How much that buys the attacker depends on the mode, and CBC - the weaker-looking of the two - is the one that gives them less. Measured on Node 24.3 against the 31-byte token {"user":"alice"}{"role":"user"}, rewriting "user" to "root" - an equal-length edit, which matters for the reason given below:
- CTR and stream ciphers give surgical control. Decryption XORs the ciphertext against a keystream that does not depend on the ciphertext, so flipping a ciphertext bit flips exactly the corresponding plaintext bit and nothing else. The token decrypted as
{"user":"alice"}{"role":"root"}- the intended edit, no other damage. - CBC gives a chosen edit in one block at the cost of the previous one. Because
P[i] = D(C[i]) XOR C[i-1], editingC[i-1]applies a chosen XOR toP[i]: the second block came out as exactly{"role":"root"}, whileP[i-1]- the plaintext of the block that was edited - decrypted to unpredictable bytes. Two conditions attach: the attacker needs to know the original bytes of the block being rewritten, which a token with a fixed structure hands them, and the wrecked block has to be somewhere the application does not check. That makes CBC noisier than CTR, not safe.
The edit has to be equal-length because PKCS#7 padding lives in the plaintext, so an edit that runs past the end of the real data overwrites the padding bytes, and the decrypt then fails validation before returning anything. Substituting a longer value ("ADMIN" for "user") into this token consumes the single 0x01 padding byte and Node answers error:1C800064:Provider routines::bad decrypt. Read that as the boundary of the attack rather than as protection: the attacker simply keeps the length the same, or targets a token whose length is already block-aligned so the padding sits in its own untouched block. Padding is not an integrity check, and a padding error that is distinguishable from any other error is its own weakness - it is the oracle behind padding-oracle attacks.
Neither mode is a control. "We encrypt it" tells you nothing about whether the value can be modified. That is what an AEAD is for: the same edit against AES-GCM was refused at the tag check, before any plaintext was returned.
Two acceptable shapes:
- Sign it: a MAC or signature over the payload (HMAC-SHA256, or a JWS with a pinned algorithm), verified before parsing. The payload stays readable by the client, which is fine for state that is not itself secret.
- Use authenticated encryption: AES-GCM, ChaCha20-Poly1305 or a JWE with an AEAD, and treat a failed tag as a rejected request. AEAD gives both properties in one operation, which is why it is the default to reach for if the state must also be hidden.
Encrypting and separately signing is fine, but the encryption is not what makes it safe. An unauthenticated cipher with no MAC is not safe at all.
Two things an integrity check does not do on its own, and both have to be closed explicitly:
- It does not say the token belongs on this request. A valid signature proves the server minted the token, not that it was minted for this user, this resource, or this moment. Put every fact the value is conditional on inside the signed payload - subject, audience, the resource identifier, an expiry - and check each one against the current request after the signature verifies. Otherwise a genuine token is replayable wherever its payload happens to suit the attacker.
- It does not say which algorithm was acceptable. A verifier that takes the algorithm from the token's own header is being told by the attacker how to check it, which is CWE-347 (Improper Verification of Cryptographic Signature). Pin the expected algorithm and key at the verification call.
Test with Malicious Inputs
- Edit the cookie, hidden field, or parameter that carries role/permission/price data and confirm the server rejects or ignores the tampered value.
- Submit a lower price or a different product ID than the UI would send and confirm the server recomputes the charge from its own data.
- Tamper with a protected token's payload and confirm it is rejected before being parsed, not silently accepted. Where the token is encrypted, flip a byte of the ciphertext rather than editing a readable claim: that is the test a confidentiality-only mode fails, and it passes trivially against an AEAD.
- Replay an unmodified signed token where it should not apply - another user's session, another resource, after its expiry. A tampering test only exercises the signature; this is what exercises whether the token is bound to the request.
- Submit a negative and a zero quantity, and one far above any real order, and confirm each is refused rather than multiplied into the total.
- Add unexpected fields (
is_admin,role) to a profile-update request and confirm they are ignored rather than applied. - Complete one ordinary purchase and one ordinary permitted edit after each change. Every assertion above passes against an endpoint that has stopped accepting anything at all, so the accepts are what tell a working fix from a broken one.
- Re-scan with the security scanner to confirm the finding is resolved.
Common Vulnerable Patterns
// VULNERABLE - permission decision taken from a client-supplied parameter
canEdit = request.param("can_edit")
if canEdit == "true":
updateDocument(request.body.content)
// Attack: attacker adds ?can_edit=true to a request they should not be
// allowed to make, and the server trusts it.
Why this is vulnerable: the parameter is not describing what the user wants to do, it is supplying the decision about whether they may. The server asks the caller for permission and believes the answer, so there is no check to bypass - the authorization logic runs correctly on an input the attacker wrote.
This shape usually arrives by accident, through a value that was legitimate somewhere else. A flag the server computed and sent to the client to decide whether to render a button is genuinely useful for that; the mistake is reading it back on the next request and treating it as the same fact. By then it has made a round trip through a machine the attacker controls, and nothing in the request distinguishes the value the server sent from one that replaced it. State that decides what a caller may do has to be recomputed server-side from the caller's identity on every request, or held server-side and referenced by an opaque handle - never carried by the client, even when the client is only carrying it back.
Secure Patterns
// SECURE - permission decision derived from server-side data
userId = session.userId
rows = db.query(
"SELECT 1 FROM document_permissions"
" WHERE user_id = ? AND document_id = ? AND can_edit = TRUE",
userId, request.body.documentId)
if rows.count > 0:
updateDocument(request.body.documentId, request.body.content)
else:
reject(403)
Why this works: the decision is made from a database record tied to the authenticated session, not from anything present in the request. There is no client-controlled field left for an attacker to edit that would change the outcome - changing can_edit in the request body has no effect because that value is never read.
What the query returns matters as much as what it selects. The obvious spelling - SELECT can_edit ... followed by if canEdit: - tests the wrong thing in most database libraries, because a query returns a result set and a result set holding one row whose can_edit is false is not empty. The truthiness of the handle grants where the value would have refused, and the bug is invisible until someone tests with a row that exists and says no. Putting the condition in the WHERE clause and asserting on the row count keeps the decision in one place: no matching row and a matching row that denies both produce zero rows, and the missing-permission case and the explicit-denial case cannot diverge. Where the value must be read out instead, extract the column and coerce it explicitly rather than testing the object the driver handed back.