summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-30 12:04:42 +0300
committerPaul Buetow <paul@buetow.org>2026-06-30 12:04:42 +0300
commit11aa2791771b6d8b87605511de146e4dd4cdef6e (patch)
treec46cac67654a1a6fd08f3254117d323104323ae3
parentce5180fbc09dc400b20d5946751106d80820dcbe (diff)
Fix path traversal (file read) and dlopen-traversal (RCE)
Three path-traversal vulnerabilities, all caused by sanitizing the raw URL before url_decode (so %-encoded %2e%2e / %2f survive the check and decode to '..' afterwards), and by concatenating attacker-controlled names into filesystem paths: 1. Arbitrary file read via the HTML template path. src/sock/sock.cpp stripped the literal '/..' from the raw query before tool::url_decode, so 'GET /..%2f..%2fetc%2fpasswd' decoded to '../../etc/passwd' and src/html.cpp opened ifstream(templatedir + request), escaping html/. Confirmed: returned /etc/passwd, /etc/hosts, and the app's own etc/ychat.conf (leaking the MySQL password). Fix: add tool::path_has_traversal (rejects any '.'/'..' path component and embedded NULs on the DECODED path) and redirect to the notfound page in handle_client_read when it triggers. 2. Arbitrary shared-object load (RCE) via the html-module dlopen path. reqp::run_html_mod built 'htmldir + yc_ + s_event + .so' from the attacker-controlled event query param and dlopen()'d it; 'event=..%2f..' could load a .so outside the modules dir. Fix: reject non-alphanumeric s_event before building the path. 3. Same via the command-module dlopen path. user::command built 'commandsdir + yc_ + s_command2 + .so' from the first token of a '/' chat message and dlopen()'d it. Fix: reject non-alphanumeric s_command2 before building the path. Verified in a container: file-read traversal now returns the notfound page (not file contents); command/module-name traversal is blocked and not dlopen'd; legit /time, normal pages, login and streaming all still work. Builds clean. Independent fresh-context review: APPROVE-WITH-NITS; the dlopen-traversal and NUL-hardening findings were addressed here; the (disabled-by-default) CGI popen command-injection was noted as a separate follow-up.
-rw-r--r--ychat/src/chat/user.cpp15
-rw-r--r--ychat/src/reqp.cpp10
-rw-r--r--ychat/src/sock/sock.cpp10
-rw-r--r--ychat/src/tool/tool.cpp31
-rw-r--r--ychat/src/tool/tool.h1
5 files changed, 67 insertions, 0 deletions
diff --git a/ychat/src/chat/user.cpp b/ychat/src/chat/user.cpp
index 1308cb0..5a35371 100644
--- a/ychat/src/chat/user.cpp
+++ b/ychat/src/chat/user.cpp
@@ -391,6 +391,21 @@ user::command( string &s_command )
string s_mod( wrap::CONF->get_elem("httpd.modules.commandsdir") + "yc_" );
string s_command2 = s_command.substr(0, pos2-1);
+
+ // Security: s_command2 is attacker-controlled (the first token of a chat
+ // message starting with '/') and is concatenated into the command-module
+ // .so path then dlopen()'d. Reject non-alphanumeric names so ".."/"/" can't
+ // traverse out of the commands dir and load an arbitrary shared object.
+ if ( ! tool::is_alpha_numeric(s_command2) )
+ {
+ wrap::system_message("Chat: blocked command-name traversal: " + s_command2);
+ string s_msg = "<font color=\"" + wrap::CONF->get_elem("chat.html.errorcolor") + "\""
+ + wrap::CONF->get_elem( "chat.msgs.err.findingcommand" )
+ + "</font>\n";
+ msg_post( &s_msg );
+ return;
+ }
+
s_mod.append( s_command2 ).append( ".so" );
dynmod *mod = wrap::MODL->get_module( s_mod, get_name() );
diff --git a/ychat/src/reqp.cpp b/ychat/src/reqp.cpp
index b68bed7..907161d 100644
--- a/ychat/src/reqp.cpp
+++ b/ychat/src/reqp.cpp
@@ -171,6 +171,16 @@ reqp::parse(context *p_context)
void
reqp::run_html_mod( string s_event, map<string,string> &map_params, user* p_user )
{
+ // Security: s_event is attacker-controlled (a query param) and is
+ // concatenated into the html-module .so path then dlopen()'d. Reject
+ // non-alphanumeric names so ".."/"/" can't traverse out of the modules dir
+ // and load an arbitrary shared object (RCE).
+ if ( ! tool::is_alpha_numeric(s_event) )
+ {
+ wrap::system_message("Reqp: blocked module-name traversal: " + s_event);
+ return;
+ }
+
container *c = new container;
c->elem[0] = (void*) wrap::WRAP;
diff --git a/ychat/src/sock/sock.cpp b/ychat/src/sock/sock.cpp
index 327b5ef..5fbcc93 100644
--- a/ychat/src/sock/sock.cpp
+++ b/ychat/src/sock/sock.cpp
@@ -474,6 +474,16 @@ sock::handle_client_read(int i_fd, short event, void *p_arg)
if (s_request.empty())
s_request = wrap::CONF->get_elem("httpd.startsite");
+ // Path-traversal guard (security): reject any request whose decoded path
+ // contains a "."/".." component — it would escape httpd.templatedir when
+ // html.cpp opens (templatedir + request). The raw-URL "/.." strip above
+ // is insufficient because it runs before url_decode (attacker uses %2e%2f).
+ if ( tool::path_has_traversal(s_request) )
+ {
+ wrap::system_message("Sock: blocked path traversal: " + s_request);
+ s_request = wrap::CONF->get_elem("httpd.html.notfound");
+ }
+
map_params["request"] = s_request;
{
diff --git a/ychat/src/tool/tool.cpp b/ychat/src/tool/tool.cpp
index 6ef0217..fc9bf5c 100644
--- a/ychat/src/tool/tool.cpp
+++ b/ychat/src/tool/tool.cpp
@@ -286,6 +286,37 @@ tool::url_decode( string s_url )
return s_dest;
}
+// Returns true if the URL-decoded request path contains a "." or ".."
+// path component (i.e. would escape the template directory when
+// concatenated to httpd.templatedir and opened). The check must run on the
+// DECODED path: the earlier raw-URL "/.." strip in sock.cpp misses
+// %-encoded dots (e.g. %2e%2f) which decode to ".." afterwards.
+bool
+tool::path_has_traversal( const string &s_request )
+{
+ // Reject embedded NULs outright: url_decode turns %00 into '\0', and a
+ // std::string can hold it while ifstream/c_str() truncate at it — a
+ // divergence that is never legitimate in a request path.
+ if ( s_request.find('\0') != string::npos )
+ return true;
+
+ size_t i_start = 0;
+ size_t i_len = s_request.size();
+
+ for ( size_t i = 0; i <= i_len; ++i )
+ {
+ if ( i == i_len || s_request[i] == '/' )
+ {
+ string s_seg = s_request.substr( i_start, i - i_start );
+ if ( s_seg == ".." || s_seg == "." )
+ return true;
+ i_start = i + 1;
+ }
+ }
+
+ return false;
+}
+
int
tool::htoi(string &s_str)
{
diff --git a/ychat/src/tool/tool.h b/ychat/src/tool/tool.h
index c1ad616..a9fbb80 100644
--- a/ychat/src/tool/tool.h
+++ b/ychat/src/tool/tool.h
@@ -51,6 +51,7 @@ public:
static string shell_command( string s_command, method m_method );
static string ychat_version();
static string url_decode(string s_url);
+ static bool path_has_traversal(const string &s_request);
static int htoi(string &s_str);
};