$NetBSD: patch-CVE-2009-4491,v 1.1 2026/09/25 14:20:39 wiz Exp $

CVE-2009-4491 -- log injection.  thttpd writes the request line, the
headers and values derived from them to the log exactly as the client
sent them, so a request can place terminal escape sequences into the
log.  Control characters are written as \xHH instead.

This is not only the access log.  Every one of these logs a value the
client controls, and none of them sanitised it:

  make_log_entry()  the access log and its syslog form: URL, Referer,
                    User-Agent, remote user
  the "unparsable time" pair  the raw If-Modified-Since and If-Range
                    header values
  check_referrer()  the Referer host, the URL and the Referer
  thirteen more     the URL or the expanded filename, in the "goes
                    outside the web tree", "tried to index a
                    directory", "tried to retrieve an auth file",
                    "isn't CGI", opendir, execve and spawn messages
  thttpd.c          the URL in the write-error message

httpd_log_escape() is exported because thttpd.c needs it too.  It hands
back a pointer into a small ring of static buffers so that several
values can be escaped in one syslog() call; thttpd is single-threaded,
so that is safe.

Not in ACME's 2.30 changelog, and not carried by FreeBSD ports, Debian,
Fedora, Alpine, MacPorts or Void.

--- libhttpd.c.orig
+++ libhttpd.c
@@ -172,6 +172,7 @@
 static int cgi( httpd_conn* hc );
 static int really_start_request( httpd_conn* hc, struct timeval* nowP );
 static void make_log_entry( httpd_conn* hc, struct timeval* nowP );
+static char* log_escape( char* dst, size_t dstsize, const char* src );
 static int check_referrer( httpd_conn* hc );
 static int really_check_referrer( httpd_conn* hc );
 static int sockaddr_check( httpd_sockaddr* saP );
@@ -1619,7 +1620,7 @@
 	++nlinks;
 	if ( nlinks > MAX_LINKS )
 	    {
-	    syslog( LOG_ERR, "too many symlinks in %.80s", path );
+	    syslog( LOG_ERR, "too many symlinks in %.80s", httpd_log_escape( path ) );
 	    return (char*) 0;
 	    }
 	lnk[linklen] = '\0';
@@ -2173,7 +2174,8 @@
 		cp = &buf[18];
 		hc->if_modified_since = tdate_parse( cp );
 		if ( hc->if_modified_since == (time_t) -1 )
-		    syslog( LOG_DEBUG, "unparsable time: %.80s", cp );
+		    syslog( LOG_DEBUG, "unparsable time: %.80s",
+			httpd_log_escape( cp ) );
 		}
 	    else if ( strncasecmp( buf, "Cookie:", 7 ) == 0 )
 		{
@@ -2214,7 +2216,8 @@
 		cp = &buf[9];
 		hc->range_if = tdate_parse( cp );
 		if ( hc->range_if == (time_t) -1 )
-		    syslog( LOG_DEBUG, "unparsable time: %.80s", cp );
+		    syslog( LOG_DEBUG, "unparsable time: %.80s",
+			httpd_log_escape( cp ) );
 		}
 	    else if ( strncasecmp( buf, "Content-Type:", 13 ) == 0 )
 		{
@@ -2380,7 +2383,7 @@
 	    {
 	    syslog(
 		LOG_NOTICE, "%.80s URL \"%.80s\" goes outside the web tree",
-		httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+		httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	    httpd_send_err(
 		hc, 403, err403title, "",
 		ERROR_FORM( err403form, "The requested URL '%.80s' resolves to a file outside the permitted web server directory tree.\n" ),
@@ -2734,7 +2737,7 @@
     dirp = opendir( hc->expnfilename );
     if ( dirp == (DIR*) 0 )
 	{
-	syslog( LOG_ERR, "opendir %.80s - %m", hc->expnfilename );
+	syslog( LOG_ERR, "opendir %.80s - %m", httpd_log_escape( hc->expnfilename ) );
 	httpd_send_err( hc, 404, err404title, "", err404form, hc->encodedurl );
 	return -1;
 	}
@@ -2974,7 +2977,8 @@
 
 	/* Parent process. */
 	closedir( dirp );
-	syslog( LOG_DEBUG, "spawned indexing process %d for directory '%.200s'", r, hc->expnfilename );
+	syslog( LOG_DEBUG, "spawned indexing process %d for directory '%.200s'",
+	    r, httpd_log_escape( hc->expnfilename ) );
 #ifdef CGI_TIMELIMIT
 	/* Schedule a kill for the child process, in case it runs too long */
 	client_data.i = r;
@@ -3563,7 +3567,7 @@
     (void) execve( binary, argp, envp );
 
     /* Something went wrong. */
-    syslog( LOG_ERR, "execve %.80s - %m", hc->expnfilename );
+    syslog( LOG_ERR, "execve %.80s - %m", httpd_log_escape( hc->expnfilename ) );
     httpd_send_err( hc, 500, err500title, "", err500form, hc->encodedurl );
     httpd_write_response( hc );
     _exit( 1 );
@@ -3602,7 +3606,8 @@
 	}
 
     /* Parent process. */
-    syslog( LOG_DEBUG, "spawned CGI process %d for file '%.200s'", r, hc->expnfilename );
+    syslog( LOG_DEBUG, "spawned CGI process %d for file '%.200s'",
+	    r, httpd_log_escape( hc->expnfilename ) );
 #ifdef CGI_TIMELIMIT
     /* Schedule a kill for the child process, in case it runs too long */
     client_data.i = r;
@@ -3654,7 +3659,7 @@
 	syslog(
 	    LOG_INFO,
 	    "%.80s URL \"%.80s\" resolves to a non world-readable file",
-	    httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+	    httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	httpd_send_err(
 	    hc, 403, err403title, "",
 	    ERROR_FORM( err403form, "The requested URL '%.80s' resolves to a file that is not world-readable.\n" ),
@@ -3709,7 +3714,7 @@
 	    syslog(
 		LOG_INFO,
 		"%.80s URL \"%.80s\" tried to index a directory with indexing disabled",
-		httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+		httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	    httpd_send_err(
 		hc, 403, err403title, "",
 		ERROR_FORM( err403form, "The requested URL '%.80s' resolves to a directory that has indexing disabled.\n" ),
@@ -3729,7 +3734,7 @@
 #else /* GENERATE_INDEXES */
 	syslog(
 	    LOG_INFO, "%.80s URL \"%.80s\" tried to index a directory",
-	    httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+	    httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	httpd_send_err(
 	    hc, 403, err403title, "",
 	    ERROR_FORM( err403form, "The requested URL '%.80s' is a directory, and directory indexing is disabled on this server.\n" ),
@@ -3757,7 +3762,7 @@
 	    syslog(
 		LOG_INFO,
 		"%.80s URL \"%.80s\" resolves to a non-world-readable index file",
-		httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+		httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	    httpd_send_err(
 		hc, 403, err403title, "",
 		ERROR_FORM( err403form, "The requested URL '%.80s' resolves to an index file that is not world-readable.\n" ),
@@ -3786,7 +3791,7 @@
 	    syslog(
 		LOG_NOTICE,
 		"%.80s URL \"%.80s\" tried to retrieve an auth file",
-		httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+		httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	    httpd_send_err(
 		hc, 403, err403title, "",
 		ERROR_FORM( err403form, "The requested URL '%.80s' is an authorization file, retrieving it is not permitted.\n" ),
@@ -3801,7 +3806,7 @@
 	syslog(
 	    LOG_NOTICE,
 	    "%.80s URL \"%.80s\" tried to retrieve an auth file",
-	    httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+	    httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	httpd_send_err(
 	    hc, 403, err403title, "",
 	    ERROR_FORM( err403form, "The requested URL '%.80s' is an authorization file, retrieving it is not permitted.\n" ),
@@ -3828,7 +3833,7 @@
 	{
 	syslog(
 	    LOG_NOTICE, "%.80s URL \"%.80s\" is executable but isn't CGI",
-	    httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+	    httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	httpd_send_err(
 	    hc, 403, err403title, "",
 	    ERROR_FORM( err403form, "The requested URL '%.80s' resolves to a file which is marked executable but is not a CGI file; retrieving it is forbidden.\n" ),
@@ -3839,7 +3844,7 @@
 	{
 	syslog(
 	    LOG_INFO, "%.80s URL \"%.80s\" has pathinfo but isn't CGI",
-	    httpd_ntoa( &hc->client_addr ), hc->encodedurl );
+	    httpd_ntoa( &hc->client_addr ), httpd_log_escape( hc->encodedurl ) );
 	httpd_send_err(
 	    hc, 403, err403title, "",
 	    ERROR_FORM( err403form, "The requested URL '%.80s' resolves to a file plus CGI-style pathinfo, but the file is not a valid CGI file.\n" ),
@@ -3904,12 +3909,65 @@
     }
 
 
+/* Copy src into dst, replacing control characters with \xHH.  The request
+** line, the headers and anything derived from them reach the log exactly as
+** the client sent them, so without this a request can write terminal escape
+** sequences into the log.
+*/
+static char*
+log_escape( char* dst, size_t dstsize, const char* src )
+    {
+    static const char hex[] = "0123456789abcdef";
+    size_t i = 0;
+    unsigned char c;
+
+    if ( src == (const char*) 0 )
+	src = "";
+    for ( ; *src != '\0' && i + 4 < dstsize; ++src )
+	{
+	c = (unsigned char) *src;
+	if ( c < 0x20 || c == 0x7f )
+	    {
+	    dst[i++] = '\\';
+	    dst[i++] = 'x';
+	    dst[i++] = hex[c >> 4];
+	    dst[i++] = hex[c & 0xf];
+	    }
+	else
+	    dst[i++] = c;
+	}
+    dst[i] = '\0';
+    return dst;
+    }
+
+
+/* The same, for use straight in a syslog() argument list.  Returns a pointer
+** into a small ring of buffers so that more than one value can be escaped in
+** one call.  thttpd is single-threaded, so this is safe.
+*/
+char*
+httpd_log_escape( const char* src )
+    {
+    static char bufs[4][1000];
+    static int next = 0;
+    char* dst;
+
+    dst = bufs[next];
+    next = ( next + 1 ) % 4;
+    return log_escape( dst, sizeof(bufs[0]), src );
+    }
+
+
 static void
 make_log_entry( httpd_conn* hc, struct timeval* nowP )
     {
     char* ru;
     char url[305];
     char bytes[40];
+    char eurl[305 * 4];
+    char eref[200 * 4 + 1];
+    char eua[200 * 4 + 1];
+    char eru[80 * 4 + 1];
 
     if ( hc->hs->no_log )
 	return;
@@ -3922,7 +3980,7 @@
 
     /* Format remote user. */
     if ( hc->remoteuser[0] != '\0' )
-	ru = hc->remoteuser;
+	ru = log_escape( eru, sizeof(eru), hc->remoteuser );
     else
 	ru = "-";
     /* If we're vhosting, prepend the hostname to the url.  This is
@@ -3937,6 +3995,9 @@
     else
 	(void) my_snprintf( url, sizeof(url),
 	    "%.200s", hc->encodedurl );
+    (void) log_escape( eurl, sizeof(eurl), url );
+    (void) log_escape( eref, sizeof(eref), hc->referrer );
+    (void) log_escape( eua, sizeof(eua), hc->useragent );
     /* Format the bytes. */
     if ( hc->bytes_sent >= 0 )
 	(void) my_snprintf(
@@ -3985,8 +4046,8 @@
 	(void) fprintf( hc->hs->logfp,
 	    "%.80s - %.80s [%s] \"%.80s %.300s %.80s\" %d %s \"%.200s\" \"%.200s\"\n",
 	    httpd_ntoa( &hc->client_addr ), ru, date,
-	    httpd_method_str( hc->method ), url, hc->protocol,
-	    hc->status, bytes, hc->referrer, hc->useragent );
+	    httpd_method_str( hc->method ), eurl, hc->protocol,
+	    hc->status, bytes, eref, eua );
 #ifdef FLUSH_LOG_EVERY_TIME
 	(void) fflush( hc->hs->logfp );
 #endif
@@ -3995,8 +4056,8 @@
 	syslog( LOG_INFO,
 	    "%.80s - %.80s \"%.80s %.200s %.80s\" %d %s \"%.200s\" \"%.200s\"",
 	    httpd_ntoa( &hc->client_addr ), ru,
-	    httpd_method_str( hc->method ), url, hc->protocol,
-	    hc->status, bytes, hc->referrer, hc->useragent );
+	    httpd_method_str( hc->method ), eurl, hc->protocol,
+	    hc->status, bytes, eref, eua );
     }
 
 
@@ -4023,7 +4084,9 @@
 	    cp = "";
 	syslog(
 	    LOG_INFO, "%.80s non-local referrer \"%.80s%.80s\" \"%.80s\"",
-	    httpd_ntoa( &hc->client_addr ), cp, hc->encodedurl, hc->referrer );
+	    httpd_ntoa( &hc->client_addr ), httpd_log_escape( cp ),
+	    httpd_log_escape( hc->encodedurl ),
+	    httpd_log_escape( hc->referrer ) );
 	httpd_send_err(
 	    hc, 403, err403title, "",
 	    ERROR_FORM( err403form, "You must supply a local referrer to get URL '%.80s' from this server.\n" ),
--- libhttpd.h.orig
+++ libhttpd.h
@@ -263,6 +263,12 @@
 extern char* httpd_err408form;
 extern char* httpd_err503title;
 extern char* httpd_err503form;
+
+/* Escape control characters in a string for the log, so that a request
+** cannot write terminal escape sequences into it.  Returns a pointer into
+** a small ring of static buffers.
+*/
+char* httpd_log_escape( const char* src );
 
 /* Generate a string representation of a method number. */
 char* httpd_method_str( int method );
--- thttpd.c.orig
+++ thttpd.c
@@ -1778,7 +1778,8 @@
 	** And ECONNRESET isn't interesting either.
 	*/
 	if ( errno != EPIPE && errno != EINVAL && errno != ECONNRESET )
-	    syslog( LOG_ERR, "write - %m sending %.80s", hc->encodedurl );
+	    syslog( LOG_ERR, "write - %m sending %.80s",
+		httpd_log_escape( hc->encodedurl ) );
 	clear_connection( c, tvP );
 	return;
 	}
