From 77c23e337ae5b4c0d37bea153dd1497fb3d3507b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 30 Jun 2026 12:18:53 +0300 Subject: Fix unauth operator escalation -> /exec RCE In guest/no-DB mode (Mode A, no authentication) chat::login granted operator status (rang 0) to anyone who logged in with the nick matching chat.defaultop ('Snoop'). An operator can run /exec (mods/commands/ yc_exec.cpp) which does popen() on an attacker-controlled shell string, i.e. unauth -> RCE, and /set to re-enable disabled commands. Fix: gate the defaultop grant on p_user->get_is_reg(). In Mode A is_reg is only ever set under #ifdef DATABASE (compiled out), so no guest can become operator; the only set_status(0) path is now dead. Registered defaultops still get op once DB auth (Mode B) is enabled. Defense-in-depth (per independent review): also physically omit mods/commands/yc_exec.so from the revival image so the popen RCE primitive is absent, not merely unreachable. Verified in a container: logging in as 'Snoop' no longer grants op (/exec returns 'No such command', no command output); /time and normal guest use still work. Builds clean. Independent fresh-context review: APPROVE-WITH-NITS; the drop-yc_exec.so hardening was applied; blanking chat.defaultop was deliberately skipped to preserve the Mode B bootstrap-op path. --- ychat/Dockerfile | 6 ++++++ ychat/src/chat/chat.cpp | 9 +++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/ychat/Dockerfile b/ychat/Dockerfile index 1804d54..43e9d7f 100644 --- a/ychat/Dockerfile +++ b/ychat/Dockerfile @@ -54,6 +54,12 @@ COPY --from=builder /build/ychat/bin/ychat /app/bin/ychat # Read-only resources COPY --from=builder /build/ychat/html/ /app/html/ COPY --from=builder /build/ychat/mods/ /app/mods/ +# Defense-in-depth: the /exec command module does popen() on an attacker- +# controlled shell string (host RCE for any operator). It is gated to +# operators by permissions and the defaultop grant now requires a +# registered user, but we also physically omit it from the revival image +# so the RCE primitive is absent, not merely unreachable. +RUN rm -f /app/mods/commands/yc_exec.so COPY docker-entrypoint.sh /app/docker-entrypoint.sh COPY etc/ychat.conf /app/etc/ychat.conf diff --git a/ychat/src/chat/chat.cpp b/ychat/src/chat/chat.cpp index cea9ab6..8910a80 100644 --- a/ychat/src/chat/chat.cpp +++ b/ychat/src/chat/chat.cpp @@ -257,8 +257,13 @@ chat::login( map &map_params ) p_user->set_status( tool::string2int(map_params["status"])); } - // Prove if user is the default operator. - if ( tool::to_lower(wrap::CONF->get_elem("chat.defaultop")) == tool::to_lower(s_user) ) + // Grant operator status only to an AUTHENTICATED (registered) user whose + // nick matches chat.defaultop. In guest/no-DB mode (Mode A) is_reg is always + // false, so nobody can claim operator by simply logging in as the defaultop + // nick — this closes unauth -> op -> /exec (popen RCE) and /set (re-enable) + // escalation. Registered ops still work once database auth (Mode B) is on. + if ( p_user->get_is_reg() && + tool::to_lower(wrap::CONF->get_elem("chat.defaultop")) == tool::to_lower(s_user) ) { wrap::system_message(CHATDOP); p_user->set_status(0); -- cgit v1.2.3