espressif / esp-idf

Espressif IoT Development Framework. Official development framework for Espressif SoCs.
Apache License 2.0
13.83k stars 7.32k forks source link

Memory leak in HTTP Digest Authentication (IDFGH-14071) #14885

Open 7aman opened 1 week ago

7aman commented 1 week ago

https://github.com/espressif/esp-idf/blob/7a305c0284b7af7cd8b8f12b48f72e2685d9a363/components/esp_http_client/esp_http_client.c#L641

I believe the URI should be freed before being set to null. When I try to dump it in previous line, it still contains the URI value.

Try this simple example that sends a GET request 100 times in a loop.
Since the URI consists of the path and query, longer values could exacerbate the leak.

#define MAX_RETRY (100)

static esp_err_t simple_event_handler(esp_http_client_event_t* evt)
{
    switch (evt->event_id) {
    case HTTP_EVENT_ON_DATA:
        printf("recv> [len:%d]%.*s\n", evt->data_len, evt->data_len, (const char*)evt->data);
        break;

    default:
        break;
    }
    return ESP_OK;
}

static void example_task(void* p)
{
    printf("starting...\n");
    for (int i = 0; i < MAX_RETRY; ++i) {
        esp_http_client_config_t config = {
            .host = "YOUR_LOCAL_IP",
            .port = 8000,
            .path = "/a/very/long/path/can/exacerbate/the/leak",
            .query = "key=a-very-long-value-could-exacerbate-the-leak",
            .buffer_size_tx = 4096,
            .auth_type = HTTP_AUTH_TYPE_DIGEST,
            .username = "admin",
            .password = "admin",
            .event_handler = simple_event_handler,
        };
        esp_http_client_handle_t client = esp_http_client_init(&config);
        esp_http_client_set_header(client, "Upgrade-Insecure-Requests", "1");
        esp_http_client_perform(client);
        esp_http_client_cleanup(client);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
    printf("done.\n");
    while (1) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
    vTaskDelete(NULL);
}

Here's a simple Python server that listens on port 8000 and responds with "Hello" to all incoming requests:

from http.server import HTTPServer, BaseHTTPRequestHandler
import hashlib
import os
import re

# Define the username, password, realm, and quality of protection
USERNAME = "admin"
PASSWORD = "admin"
REALM = "Simple Digest Auth"
NONCE = os.urandom(16).hex()  # Fixed nonce for simplicity in this example
QOP = "auth"                  # Define qop as "auth" for quality of protection

# The response message to send to authenticated clients
RESPONSE_MESSAGE = "hello"

def generate_digest_response(username, realm, password, method, uri, nonce, nc, cnonce, qop):
    """Generate the expected Digest response for comparison."""
    # HA1: Hash of username:realm:password
    ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest()
    # HA2: Hash of method:uri
    ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest()
    # Response: Hash of HA1:nonce:nc:cnonce:qop:HA2
    response = hashlib.md5(f"{ha1}:{nonce}:{nc}:{cnonce}:{qop}:{ha2}".encode()).hexdigest()
    return response

def parse_digest_header(header):
    """Parse the Digest Authorization header into a dictionary."""
    # Regular expression to match key="value" pairs
    pattern = re.compile(r'(\b\w+\b)="?([^",]+)"?')
    return dict(pattern.findall(header))

class DigestAuthHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        # Parse the Authorization header for Digest Authentication
        auth_header = self.headers.get('Authorization')

        if auth_header is None or not auth_header.startswith('Digest '):
            self.request_authentication()
            return

        # Parse the digest values using the parse_digest_header function
        auth_params = parse_digest_header(auth_header[7:])

        # Validate the username, realm, nonce, uri, qop, nc, cnonce, and response
        if (auth_params.get("username") == USERNAME and
            auth_params.get("realm") == REALM and
            auth_params.get("nonce") == NONCE and
            auth_params.get("uri") == self.path and
            auth_params.get("qop") == QOP):

            # Compute the expected response using the qop, nc, and cnonce values
            expected_response = generate_digest_response(
                USERNAME, REALM, PASSWORD, self.command, self.path,
                NONCE, auth_params.get("nc"), auth_params.get("cnonce"), QOP
            )

            # If the response matches, grant access
            if auth_params.get("response") == expected_response:
                # Respond with the specified message and set Content-Length header
                self.send_response(200)
                self.send_header("Content-type", "text/plain")
                self.send_header("Content-Length", str(len(RESPONSE_MESSAGE)))
                self.end_headers()
                self.wfile.write(RESPONSE_MESSAGE.encode())
                return

        # If authentication fails, request it again
        self.request_authentication()

    def request_authentication(self):
        """Request Digest Authentication from the client with qop="auth", without returning a payload."""
        self.send_response(401)
        self.send_header(
            'WWW-Authenticate',
            f'Digest realm="{REALM}", nonce="{NONCE}", qop="{QOP}", algorithm="MD5"'
        )
        self.send_header('Content-type', 'text/plain')
        self.end_headers()  # No payload is written to the response body

# Define the server's address and port
server_address = ('', 8000)
httpd = HTTPServer(server_address, DigestAuthHandler)

print("Server running on port 8000...")
httpd.serve_forever()
7aman commented 1 day ago

Could it be fixed in the next stable release?