diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-30 12:04:42 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-30 12:04:42 +0300 |
| commit | 11aa2791771b6d8b87605511de146e4dd4cdef6e (patch) | |
| tree | c46cac67654a1a6fd08f3254117d323104323ae3 | |
| parent | ce5180fbc09dc400b20d5946751106d80820dcbe (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.cpp | 15 | ||||
| -rw-r--r-- | ychat/src/reqp.cpp | 10 | ||||
| -rw-r--r-- | ychat/src/sock/sock.cpp | 10 | ||||
| -rw-r--r-- | ychat/src/tool/tool.cpp | 31 | ||||
| -rw-r--r-- | ychat/src/tool/tool.h | 1 |
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); }; |
