Introduction
While completing a routine biology assignment through a popular educational portal, I stumbled upon a surprising oversight in the platform’s frontend design. The assignment consisted of over 70 questions, each seemingly interactive and secure. However, with a bit of browser inspection, I uncovered that the answers were not only being loaded client-side but were also obfuscated in a predictable manner. By analyzing the JavaScript and digging through the request flow, I was able to decode the answer choices and access every question’s answer data. This post explores how such a basic implementation flaw highlights larger issues in educational tech platforms' security practices and why developers must take client-side security seriously.
Discovering Network Requests

The website did a GET request to https://[REDACTED]/[ASSIGNMENT_ID]/assessment-items/b63d15fb-1f07-4ecc-9468-ce809884abeb.json where b63d15fb-1f07-4ecc-9468-ce809884abeb is the question assessment ID, which I found out by searching for that term (6b3d...) using (ctrl-f) and located another JSON response contains all the upcoming questions and their IDs with really useful information in a JSON called objectives.json:
...
"assessmentItemIdMap": [
[
"871558",
[
"b63d15fb-1f07-4ecc-9468-ce809884abeb",
"228f3834-1710-455d-97ed-7ee220876c50"
]
],
[
"1166563",
[
"f1c2f831-77f8-4339-a549-9b5c88ef836b",
"f1a08fa4-9cad-4e97-bb86-7123ec78b9de",
]
],
[
"500336",
[
"a3db0c2e-c6cb-448f-9758-020f944808cf",
"8cfef9c2-f219-4ff1-8c4c-d6372fcd3cde"
]
],
[
"1166560",
[
"42a44e49-05e3-482a-95bf-186449efb0a5",
"1806b5f5-62c6-4e06-9aeb-e3bf968a3476"
]
],
< and so on...>
This JSON response will be useful a bit later
The Obfuscated Payload
Now going back to the original response when we render a question, we get this response back from the server:
{
"id": "b63d15fb-1f07-4ecc-9468-ce809884abeb",
"learningObjective": {
"id": 871558,
"$path": "learning-objectives/871558.json"
},
"type": "MULTIPLE_CHOICE",
"hidata": {
"version": "1",
"payload": "heNb3yZJnOXxiOIGiITIYBix0ZhAzT5QZyjLtYNYijYMmZiiGY0z4VmMNIj5gY2Jimw9LtCcicIO8HcQiaD0GI5HXFGRbU25YcgVgmw9ckmc0d9auWINWbH12YB2yRGVZ9nzNImlwyuBc0mZ0aFIyGZFGZSuGdBGpVWldwGj8YghgHlBdhGIucAdomZVCZX0XIhGjBiZdBXvRctlhnvMdpGbcPAL+zIx3ciumZwGiFj9cpntVI0JlGj9LpCazYpIb2eVjZyiXOJirkGIZITxBOx0hjzAY5iZyLMYtTYQjYm4iZNGi0zVYMmiIY5g2CiJNjjL0b5Z02I5WPj+HQoXiAHNbVDlxehAcn+0Ls3IrekZiyOJXYi4zZIW2QiUM1m4ZNhRjWiILtTMiO1YlGMZSNj3mMB2lQWELQC3JNi5jW05b02ZiIAP+jUoHd2sWPVF4FDwf4Svxcit7SlII6me5IJMmjNMTLT3WYdTlMDAOdSt1NhViTmFNjDLmOBYmTYFTNjsyIkm3InNbRnvQblIiD85OFichdlcvWdtnPGvFclDjwS4Ixmitf7Ilm6MexSI5YMOz2LIDNTtDNkDzQiQO0DzNY4YmjwFLlTM1ZUZxTNkWICvmbInsNnROQilIbi58WI1chDdcbAL+jIx3fn=Q 1 d="
}
...
}
Here we again have the ID (question assessment ID), and the learningObjective.id (question ID). What caught my curiosity was the payload variable that we got. It looked heavily obfuscated. At first I tried running it through CyberChef but nothing seemed to work so I started looking inside the JS to figure out how the website deobfuscates the payload, as it will have to use it in its plain-text form to display and check the answer.
I was looking for anything that could relate to hidata (the object that contains the payload) and this is where I found a class called Cs inside one of the main loaded JS files:
class Cs {
static b64DecodeUnicode(St) {
return decodeURIComponent(atob(St).split("").map(Zt => "%" + ("00" + Zt.charCodeAt(0).toString(16)).slice(-2)).join(""))
}
deobfuscate(St) {
if (St.length % 9 > 0 || St.match(/[^a-zA-Z0-9\/= +]/g))
throw new Error("Hidata payload does not match the interface");
const Zt = St.replace(/(.)(.)(.)(.)(.)(.)(.)(.)(.)/g, "$2$6$8$1$4$9$3$5$7").replace(/ +$/, "")
, Rn = Cs.b64DecodeUnicode(Zt);
return JSON.parse(Rn)
}
}
There is also another class function where the payload will get passed to be deobfuscated:
deobfuscateData(St) {
if (St.hidata) {
const Zt = function Oo(Et) {
return 1 === Et ? new Cs : null
}(Number(St.hidata.version));
if (Zt)
return Zt.deobfuscate(St.hidata.payload);
throw new Error(`Can't deobfuscate version ${St.hidata.version}`)
}
return St.data
}
Part that deobfuscates the payload:
return Zt.deobfuscate(St.hidata.payload);
I copied and pasted the code in nodeJS and tested it out with the previous payload shown in the response
...
console.log(Cs.deobfuscate(payload))
Resulted in:
{
answer: '18b1e0ab-3f09-42b3-bcbb-8debc39bb668',
prompt: '<p>What type of reproduction produces offspring that are identical to the parent (except for mutations)?</p>',
random: true,
choices: [
{
key: '18b1e0ab-3f09-42b3-bcbb-8debc39bb668',
content: '<p>Asexual</p>'
},
{
key: '6c48ee86-a2fc-4b1b-8fba-e20e6d73a75d',
content: '<p>Sexual</p>'
},
{
key: '3912f57e-c7a0-47a9-b45f-1c91fa0fb977',
content: '<p>Eukaryotic</p>'
},
{
key: 'c1cb9833-9344-443b-883f-6021ee95ee14',
content: '<p>Human</p>'
}
]
}
Interesting! This is the code that the client-side code uses to make the obfuscated code readable so it can render the question and check the answers.
We can see that the answer ID is one of the IDs of the answer choices, which tells us that this question's answer is "Asexual". Trying it out in the assignment showed that it is the correct answer

Let's try another question's payload by clicking next and loading the next question:
{
"id": "a3db0c2e-c6cb-448f-9758-020f944808cf",
"learningObjective": {
"id": 500336,
"$path": "learning-objectives/500336.json"
},
"type": "FILL_IN_THE_BLANK",
"hidata": {
"version": "1",
"payload": "we9ctycJmOH8icQDiIX5bJmEcdptgXyNJwHZzbpZj2Z5TNjyTMMi5Uz0LRT1RNiIiDtgM4GOlONMhCN1TNjijNI24VHRcJH9JIlVvGjlZvHdgbVZuiZw2YXpybRmpBmZd1GvlchBvmm9btic3IBbwHYRybXznIJGlRWNbJmvVbpRzGvZIvHIgc4Ygmb0Wc2y3aZWmBj5Lx3nALcw+Wi5IziYydIc62WVyd3smdsWiFjVIpnzNIbVlChIesHblIJednLNCZCwXbJ2yNS5IImzNZ6kmT1IMyzN3LINtTNUGYG4iOIDw0WgMU24ELxg2W2IM3jNsZJIhCdInbGsmZlSvFyILIC6IWi1iCdJXpXL1bZdvncBEdm6CI1mhISFIImuFes9sW35b5GQkTIZi3OJXbmlHLZChNXJUN2jVYlluX0Zcl2amIxYzjZpWdSvGTwWi9UFcF3udeuMlmiZchnOlb=f=H NQ"
},
...
}
Here we have a different type of question, it's a fill in the blank.
Running the deobfuscate function shows us:
{
prompt: '<p>During ${response:cf395522-574b-4b0b-8888-e13a62865b7d} reproduction, genetic information from two parents combines to form an offspring.</p>',
answers: [
{
values: [Array],
response: 'cf395522-574b-4b0b-8888-e13a62865b7d',
rationale: [Array]
}
],
inputFormat: 'any',
allowAnyOrder: false,
caseSensitive: false,
tooManyAnswers: false
}
Further unpacking the JS Array object entries in values: [sexual, sex]. We can see that this question is expecting one of these answers in the blank.

We can keep trying this for each question we get and it will result in the same process.
Automating the Process with Python
We can automate this task using python. Since the URL is the same for each question request with the only thing changing is the question assessment ID. And I mentioned how the previous JSON response objectives.json will become helpful, as in this case we can read objectives.json and get the assessmentItemIdMap which shows each question ID tied to it's question assessment ID's , then we can use that information to build the URL with the assessment ID of each question and then deobfuscate it for all the questions listed in objectives. Then we can automatically get the right answer by looking up the matching answer ID
You might have noticed that there are mutliple assessment ID's in
objectives.jsonfor each question ID, which is there for in case you get the first question wrong, it will load the other one, I found out that it's usually the first ID that's there is the one that is chosen
Here is a sample of the code:
# libraries, requests, cookies, headers not shown
...
# JS code -> Python
def deobfuscate(payload: str):
if len(payload) % 9 != 0 or re.search(r"[^a-zA-Z0-9/= +]", payload):
raise ValueError("Hidata payload does not match the interface")
def reorder_chunk(chunk):
return (chunk[1] + chunk[5] + chunk[7] + chunk[0] + chunk[3] +
chunk[8] + chunk[2] + chunk[4] + chunk[6])
rearranged = "".join(
reorder_chunk(payload[i:i + 9])
for i in range(0, len(payload), 9)).rstrip(" ")
decoded_bytes = base64.b64decode(rearranged)
percent_encoded = "".join(f"%{b:02x}" for b in decoded_bytes)
unicode_str = urllib.parse.unquote(percent_encoded)
return json.loads(unicode_str)
# Read the data from objectives.json
question_id_data = data_dict["state"]["scope"]["assessmentItemIdMap"]
# looping over every question ID
for i, question_id in enumerate(question_id_data):
question_selected = data_dict["state"]["scope"]["assessmentItemIdMap"][
i]
question_id = question_selected[0]
question_path_id_list = question_selected[1]
# picking the first question
question_path_id = question_path_id_list[0]
full_url = f"https://[REDACTED]/[ASSIGNMENT_ID]/assessmentitems/{question_path_id}.json"
response = requests.get(full_url, headers=headers, cookies=cookies)
# getting the response with the obfuscated payload for question
response_dict = json.loads(response.text)
payload_str = response_dict.get("hidata").get("payload")
question_type = response_dict.get("type")
# Calls the deobfuscate function
result = deobfuscate.deobfuscate(payload_str)
# Match the answer ID with the real answer
if question_type == "MULTIPLE_CHOICE":
question_answer_id = result.get("answer")
choices_list: list[dict] = result.get("choices")
answer = ""
if choices_list:
for choice in choices_list:
if choice.get("key") == question_answer_id:
answer = choice.get("content")
final_result = {
"question": result.get("prompt"),
"answer(s)": answer,
}
# similar to code above, but for question with multiple answer choices
elif (question_type == "MULTIPLE_CHOICE_MULTI_SELECT" or question_type == "ORDERING"):
question_answer_id = result.get("answers")
choices_list: list[dict] = result.get("choices")
answers = []
if choices_list:
for choice in choices_list:
if choice.get("key") in question_answer_id:
answers.append(choice.get("content"))
final_result = {
"question": result.get("prompt"),
"answer(s)": answers,
}
list_of_questions_data_inside_question_id[final_result.get("question")] =
answers
...
This will show a JSON object:
{
"871061": [
{
"question": "<p>Anything that takes up space is defined as</p>",
"answer(s)": "<p>matter.</p>"
}
],
"887703": [
{
"question": "<p>Select all of the following that are forms of energy.</p>",
"answer(s)": [
"<p>heat</p>",
"<p>light</p>",
"<p>chemical bonds</p>"
]
}
],
< and so on...>
}
The script is also simplifying the way the answer is shown, instead of it being just an ID it will be the answer string that has that ID
Running this script will result in getting the answers for all the questions in the assignment.
All the script needs is the assignment ID (and cookies + headers of course) which then it can plug into the URL: https://[REDACTED]/[ASSIGNMENT_ID]/objectives.json to get the objectives.json for creating the URL for the each question.
This makes it very easy and automated
Using this knowledge an attacker (student in this case) could use this information to cheat through the course work and get a perfect score.
Security Implications and Final Thoughts
The fact that this was simple to happen, without breaking any encryption, brute-forcing, injecting JS, etc.. but simply by the fact of seeing how the application worked and what functions were being called I was able to figure out how the answers were stored, loaded, and even linked to each question ID through predictable patterns in the network requests and client-side code.
When sensitive logic like answer validation or response data is handled entirely on the front end, you are trusting the user not to look under the hood. That's not security, that's obscurity. Any motivated user with basic browser dev tools can inspect requests, parse JavaScript, and figure out how the system works. That highlights a huge real-world security mistake: developers often assume that if it's "hard to read," it's secure. It's not. Obfuscation is not a substitute for proper backend validation and access control. This example reflects a broader issue in edtech and frontend heavy applications: prioritizing performance or ease-of-use over actual security hygiene.
This experience reinforced a fundamental lesson in cybersecurity: never trust the client. Obfuscation is not security, and when sensitive logic like correct answers is exposed in the browser, it’s only a matter of time before someone finds it.
For developers, always perform validation and answer logic server-side.
For students learning cybersecurity like me this is a great example of how simply observing client-side behavior can reveal design flaws.
Disclaimer: This post is for educational purposes only. The goal is to demonstrate how client-side security mistakes can expose sensitive information in educational platforms.
