Hi all,
The following AI generated code analysis reports were sent to me for dwm, dmenu
and st.
I've already dug through some of the findings and fixed what I think are the
most important issues.
I'm sending these findings so people can see it, evaluate it and give their
patches or what needs addressing.
Before the thread derails: if you want to have a discussion about AI for code
analysis, its usefulness or uselessness please make a separate thread about it.
Again: this is only about the findings which were sent to me.
A few notes:
- st recursion / deadlock in ttywriteraw() and ttyread().
This has been reported before, but I cannot reproduce the issue or write
an elegant patch solution. I consider it a potential issue still.
- Findings in dwm and dmenu about monitor/display setups: I would like more
people
especially with more exotic setups to look at it.
Multi-monitor setups, different resolutions/rotations and hotplugging weird
configurations could be improved?
- dwm showhide recursion with many clients: a patch for this is welcome. But
it should not break any existing behaviour. If we can avoid recursion that
would be nice, even though I think spawning "100k tiny clients" is
unrealistic.
- I'm not sure the notes about XSendEvent or spoofing events is a security
issues. And it could break useful tools that simulate keypresses or events
for
example (like xdotool?).
- I don't think we should handle all possible (input) errors, because we assume
the user uses his/her brain.
Thank you,
Report below:
================================================================================
dwm - SECURITY REVIEW FINDINGS
================================================================================
Date reviewed : 2026-09-11
Repository : C:\dwm (branch master, HEAD 44dbc68)
Scope : dwm.c, drw.c, drw.h, util.c, util.h, config.def.h, transient.c
Reviewer : static source review (no build, no runtime fuzzing)
Code changed : NONE. Every diff below is ILLUSTRATIVE ONLY and has not been
applied. Line numbers refer to HEAD 44dbc68; hunk offsets are
approximate and may need `patch -l` / manual application.
--------------------------------------------------------------------------------
THREAT MODEL USED
--------------------------------------------------------------------------------
dwm is the X11 window manager: it is the most privileged client on the display
and its death ends the user's session. Every other X client connected to the
same display is UNTRUSTED for the purposes of this review, because in X11 any
client can:
* set arbitrary properties on its own windows (WM_NAME, _NET_WM_NAME,
WM_CLASS, WM_NORMAL_HINTS, WM_HINTS, _NET_WM_STATE, ...), and
* call XSendEvent() to deliver a SYNTHETIC event to any window, including the
root window and dwm's own bar window, as long as dwm has selected the
corresponding event mask there.
dwm selects this mask on the root window (dwm.c:1607-1611):
SubstructureRedirectMask|SubstructureNotifyMask|ButtonPressMask
|PointerMotionMask|EnterWindowMask|LeaveWindowMask|StructureNotifyMask
|PropertyChangeMask
and this mask on each bar window (dwm.c:1825):
ButtonPressMask|ExposureMask
So forged ConfigureNotify / EnterNotify / LeaveNotify / MotionNotify /
PropertyNotify / ButtonPress / Expose events ARE delivered to dwm. Of the 14
installed handlers, only unmapnotify() (dwm.c:1811) inspects ev->send_event.
That asymmetry is the root cause of findings SEC-01, SEC-02 and SEC-03.
Forged KeyPress is NOT currently reachable: dwm never selects KeyPressMask on
the root or bar windows (it relies on XGrabKey), so a synthetic KeyPress has no
recipient. See SEC-11 note.
Severity is availability/integrity-weighted for a window manager: "crash the
WM" == "kill the user's whole graphical session and every unsaved document in
it", so it is rated High, not Low.
--------------------------------------------------------------------------------
SUMMARY TABLE
--------------------------------------------------------------------------------
ID Severity Component Issue
-------- --------- ---------------------------- ------------------------------
SEC-01 High dwm.c configurenotify() Forged root ConfigureNotify ->
fatal X BadValue, dwm exits
SEC-02 High dwm.c buttonpress() Forged ButtonPress on bar ->
invokes button actions,
including spawn()
SEC-03 Medium dwm.c enternotify() Forged EnterNotify -> focus
theft / keystroke capture
SEC-04 Medium dwm.c updatesizehints() / Unsanitized WM_NORMAL_HINTS ->
applysizehints() signed overflow, div-by-zero,
inf->int conversion
SEC-05 Medium dwm.c arrangemon(), strncpy() with sizeof(dest)
createmon(), setlayout() leaves ltsymbol[16]
unterminated -> OOB read
SEC-06 Low/Med drw.c drw_text() Hostile window title -> per
redraw XftFontMatch storm and
unbounded font cache
SEC-07 Low dwm.c showhide() Unbounded recursion, one frame
per window -> stack exhaustion
SEC-08 Low dwm.c gettextprop() Leak of XmbTextPropertyToText
List() allocation
SEC-09 Low dwm.c updatenumlockmask() Unchecked XGetModifierMapping()
-> NULL deref
SEC-10 Low dwm.c updategeom(), NULL deref when the monitor
cleanupmon() list empties / desyncs
SEC-11 Low dwm.c clientmessage() No format check, no origin
restriction on _NET_WM_STATE
SEC-12 Low dwm.c spawn() Unchecked fork(), inherited fds
leak to children, dmenumon
one-byte mis-encode
SEC-13 Info dwm.c applyrules() Placement rules keyed on
client-spoofable WM_CLASS/title
================================================================================
SEC-01 Forged root ConfigureNotify kills dwm (and corrupts screen geometry)
================================================================================
Severity : High (availability - full session loss)
Location : dwm.c:552-578 configurenotify()
CWE : CWE-346 Origin Validation Error; CWE-20 Improper Input Validation
Description
-----------
configurenotify() acts on any ConfigureNotify whose ev->window == root without
checking ev->send_event:
dwm.c:561 if (ev->window == root) {
dwm.c:562 dirty = (sw != ev->width || sh != ev->height);
dwm.c:563 sw = ev->width;
dwm.c:564 sh = ev->height;
dwm.c:565 if (updategeom() || dirty) {
dwm.c:566 drw_resize(drw, sw, bh);
dwm.c:567 updatebars();
...
dwm.c:572 XMoveResizeWindow(dpy, m->barwin, m->wx, m->by,
m->ww, bh);
dwm has StructureNotifyMask selected on the root window (dwm.c:1609), so a
synthetic ConfigureNotify sent by ANY client is delivered here and is
indistinguishable from a real server-generated one.
The attacker fully controls sw and sh. With width == 0:
sw = 0
-> drw_resize(drw, 0, bh) -> XCreatePixmap(..., 0, bh, depth) (drw.c:76)
-> X_CreatePixmap / BadValue
and, in the non-Xinerama path, updategeom() sets mons->mw = mons->ww = 0
-> XMoveResizeWindow(dpy, m->barwin, ..., 0, bh)
-> X_ConfigureWindow / BadValue
BadValue is not in the ignore list in xerror() (dwm.c:2101-2110), so the next
XSync()/XNextEvent() dispatches it to:
dwm.c:2111 fprintf(stderr, "dwm: fatal error: ...");
dwm.c:2113 return xerrorxlib(dpy, ee); /* Xlib default handler: exit() */
dwm terminates. All managed clients are left unmanaged and the session is over.
Non-zero-but-bogus geometries are also damaging without crashing: sw/sh feed
the `interact` branch of applysizehints() (dwm.c:322-330) and, without
Xinerama, the entire monitor rectangle (dwm.c:1931-1936), so a client can
relocate/resize the bar and push windows off-screen.
Failure scenario
----------------
1. Any unprivileged client on the same display (a browser tab helper, a
sandboxed app with $DISPLAY, a compromised tray icon) runs:
XConfigureEvent ce = {0};
ce.type = ConfigureNotify;
ce.display = d; ce.event = root; ce.window = root;
ce.width = 0; ce.height = 0;
XSendEvent(d, root, False, StructureNotifyMask, (XEvent *)&ce);
XFlush(d);
2. dwm prints "dwm: fatal error: request code=53, error code=2" and exits.
Illustrative fix
----------------
Mirror what unmapnotify() already does, and validate the geometry:
--- a/dwm.c
+++ b/dwm.c
@@ -558,6 +558,13 @@ configurenotify(XEvent *e)
int dirty;
/* TODO: updategeom handling sucks, needs to be simplified */
if (ev->window == root) {
+ /* Any client may XSendEvent() a ConfigureNotify for the root
+ * window (we select StructureNotifyMask there). Only the server
+ * may tell us the screen changed size, and a bogus geometry
+ * makes XCreatePixmap()/XMoveResizeWindow() fail with BadValue,
+ * which xerror() treats as fatal. */
+ if (ev->send_event || ev->width <= 0 || ev->height <= 0)
+ return;
dirty = (sw != ev->width || sh != ev->height);
sw = ev->width;
sh = ev->height;
Defence in depth: also add BadValue-on-CreatePixmap tolerance to xerror(), and
clamp in drw_resize():
--- a/drw.c
+++ b/drw.c
@@ -66,10 +66,13 @@ void
drw_resize(Drw *drw, unsigned int w, unsigned int h)
{
if (!drw)
return;
+ /* XCreatePixmap rejects a zero extent with BadValue */
+ w = MAX(w, 1);
+ h = MAX(h, 1);
drw->w = w;
drw->h = h;
if (drw->drawable)
XFreePixmap(drw->dpy, drw->drawable);
(drw.c already includes util.h, so MAX is available.)
================================================================================
SEC-02 Forged ButtonPress on the bar invokes configured actions, incl. spawn()
================================================================================
Severity : High (integrity - unauthenticated triggering of WM commands)
Location : dwm.c:417-457 buttonpress()
CWE : CWE-346 Origin Validation Error
Description
-----------
Each bar window is created with ButtonPressMask (dwm.c:1825), and buttonpress()
never checks ev->send_event. The handler derives a click region purely from
attacker-supplied ev->window / ev->x / ev->state / ev->button and then calls
the matching entry of buttons[]:
dwm.c:433 if (ev->window == selmon->barwin) {
dwm.c:437 while (ev->x >= x && ++i < LENGTH(tags));
dwm.c:443 else if (ev->x > selmon->ww - (int)TEXTW(stext) + lrpad - 2)
dwm.c:444 click = ClkStatusText;
...
dwm.c:453 for (i = 0; i < LENGTH(buttons); i++)
dwm.c:454 if (click == buttons[i].click && buttons[i].func && ...
dwm.c:456 buttons[i].func(...);
With the shipped config.def.h this already reaches spawn():
config.def.h:108 { ClkStatusText, 0, Button2, spawn, {.v = termcmd } },
so any client can make dwm fork+exec `st`. It can also drive view(),
toggleview(), tag(), toggletag(), setlayout() and zoom() (config.def.h:105-115)
to reorganise the user's workspace. The severity rises sharply for the very
common patch/customisation of binding SHCMD("...") (config.def.h:57) to a bar
button - then a forged ButtonPress is arbitrary command execution as the user.
Note that repeated forged presses of ClkStatusText/Button2 are also a fork bomb
vector: one shell command executed per event, with no rate limit.
Failure scenario
----------------
1. Client learns selmon->barwin (it is a child of root; XQueryTree lists it, or
read _NET_CLIENT_LIST neighbours; the bar has WM_CLASS "dwm"/"dwm",
dwm.c:1827).
2. Client sends:
XButtonEvent be = {0};
be.type = ButtonPress; be.window = barwin; be.root = root;
be.x = bar_width - 5; /* status-text region */
be.button = Button2; be.state = 0; be.same_screen = True;
XSendEvent(d, barwin, False, ButtonPressMask, (XEvent *)&be);
3. dwm spawns termcmd (or whatever is bound), repeatedly, on demand.
Illustrative fix
----------------
--- a/dwm.c
+++ b/dwm.c
@@ -423,6 +423,10 @@ buttonpress(XEvent *e)
Monitor *m;
XButtonPressedEvent *ev = &e->xbutton;
+ /* never act on a forged press: buttons[] can spawn() processes */
+ if (ev->send_event)
+ return;
+
click = ClkRootWin;
/* focus monitor if necessary */
if ((m = wintomon(ev->window)) && m != selmon) {
================================================================================
SEC-03 Forged EnterNotify lets any client steal input focus
================================================================================
Severity : Medium (confidentiality - keystroke capture)
Location : dwm.c:759-776 enternotify()
CWE : CWE-346 Origin Validation Error; CWE-1021 Improper Restriction of
Rendered UI Layers (UI redress / input redirection)
Description
-----------
dwm selects EnterWindowMask on the root window (dwm.c:1608) and on every
managed client (dwm.c:1071). enternotify() filters on ev->mode/ev->detail but
not on ev->send_event:
dwm.c:766 if ((ev->mode != NotifyNormal || ev->detail == NotifyInferior)
&& ev->window != root)
dwm.c:767 return;
dwm.c:768 c = wintoclient(ev->window);
...
dwm.c:775 focus(c); /* -> setfocus() -> XSetInputFocus() */
A synthetic EnterNotify with mode == NotifyNormal, detail == NotifyAncestor and
window == the attacker's own managed window therefore causes dwm to move the
keyboard focus to the attacker, with the pointer never having moved. Every
subsequent keystroke - including one typed into what the user still believes is
their terminal, password prompt, or ssh session - is delivered to the attacker's
window.
Because dwm's focus-follows-mouse model means focus changes are routine and
produce only a border-colour change, this is visually very cheap to hide: the
attacker can steal focus, buffer keystrokes, and send a second forged
EnterNotify to hand focus back.
Failure scenario
----------------
1. User is typing a passphrase into a terminal.
2. Attacker client (already mapped, e.g. a 1x1 window on the same tag) sends a
synthetic EnterNotify naming its own window.
3. dwm calls XSetInputFocus() on the attacker window; the rest of the
passphrase is typed into the attacker.
Illustrative fix
----------------
--- a/dwm.c
+++ b/dwm.c
@@ -763,7 +763,10 @@ enternotify(XEvent *e)
Monitor *m;
XCrossingEvent *ev = &e->xcrossing;
- if ((ev->mode != NotifyNormal || ev->detail == NotifyInferior) &&
ev->window != root)
+ /* a forged crossing event would hand keyboard focus to the sender */
+ if (ev->send_event)
+ return;
+ if ((ev->mode != NotifyNormal || ev->detail == NotifyInferior) &&
ev->window != root)
return;
c = wintoclient(ev->window);
m = c ? c->mon : wintomon(ev->window);
The same one-line guard is worth adding to motionnotify() (dwm.c:1129, forged
motion silently switches selmon) and expose() (dwm.c:779, forged exposures are
a cheap redraw-loop CPU sink, and each redraw runs the drw_text() path of
SEC-06).
================================================================================
SEC-04 Unsanitized WM_NORMAL_HINTS -> integer overflow, div-by-zero, inf->int
================================================================================
Severity : Medium (availability; undefined behaviour)
Location : dwm.c:1961-2003 updatesizehints(); dwm.c:313-379 applysizehints()
CWE : CWE-190 Integer Overflow; CWE-369 Divide By Zero; CWE-20
Description
-----------
updatesizehints() copies every field of the client-controlled XSizeHints
straight into the Client struct with no range check:
dwm.c:1971 c->basew = size.base_width;
dwm.c:1979 c->incw = size.width_inc;
dwm.c:1984 c->maxw = size.max_width;
dwm.c:1997 c->mina = (float)size.min_aspect.y / size.min_aspect.x;
dwm.c:1998 c->maxa = (float)size.max_aspect.x / size.max_aspect.y;
These are plain `int`s written by the client via XSetWMNormalHints(), so they
may be negative, zero, or INT_MAX. applysizehints() then does signed arithmetic
on them:
(a) Signed overflow - dwm.c:351-352 and dwm.c:362-363:
*w -= c->basew; *h -= c->baseh;
With c->basew == INT_MIN + k this overflows (UB; in practice wraps).
(b) Division by zero / inf->int - dwm.c:355-359:
if (c->mina > 0 && c->maxa > 0) {
if (c->maxa < (float)*w / *h)
*w = *h * c->maxa + 0.5;
After (a), *h can be 0 (the earlier `*h = MAX(1, *h)` at dwm.c:321 and
`if (*h < bh) *h = bh;` at dwm.c:341 both happen BEFORE the subtraction),
so `(float)*w / *h` is a division by zero. Separately, min_aspect.x == 0
makes c->mina == +inf at dwm.c:1997, and `*w = *h * c->maxa + 0.5`
converts +inf/NaN to int - undefined behaviour, in practice INT_MIN.
(c) SIGFPE - dwm.c:366-369:
if (c->incw) *w -= *w % c->incw;
c->incw is only tested for != 0. With c->incw == -1 and *w == INT_MIN
(reachable via the wraparound in (a): pick base_width == INT_MIN + w0),
`INT_MIN % -1` traps on x86-64 (integer-divide #DE -> SIGFPE) and dwm
dies immediately with no X error involved.
Every one of these is reached on the normal path: manage() -> updatesizehints()
(dwm.c:1069) for any newly mapped window, and again on every resize through
resize() -> applysizehints() (dwm.c:345-347).
Failure scenario
----------------
A client maps a window after:
XSizeHints h = {0};
h.flags = PResizeInc | PBaseSize | PMinSize;
h.width_inc = -1; h.height_inc = -1;
h.base_width = INT_MIN + 500; /* window is 500px wide */
h.base_height = 0;
XSetWMNormalHints(d, win, &h);
dwm computes *w == INT_MIN, evaluates INT_MIN % -1, and takes SIGFPE. Session
over. A variant with min_aspect.x = 0 instead produces an inf->int conversion
and absurd window geometry rather than a crash.
Illustrative fix
----------------
Clamp at the trust boundary (where the hints are read), and re-establish the
>= 1 invariant before the aspect/increment maths:
--- a/dwm.c
+++ b/dwm.c
@@ -1961,43 +1961,49 @@ void
updatesizehints(Client *c)
{
long msize;
XSizeHints size;
if (!XGetWMNormalHints(dpy, c->win, &size, &msize))
/* size is uninitialized, ensure that size.flags aren't used */
size.flags = PSize;
+ /* WM_NORMAL_HINTS is entirely client controlled: clamp every field to
+ * a sane, non-negative range so that the signed arithmetic in
+ * applysizehints() cannot overflow, divide by zero, or trap. */
if (size.flags & PBaseSize) {
- c->basew = size.base_width;
- c->baseh = size.base_height;
+ c->basew = MAX(0, MIN(size.base_width, sw));
+ c->baseh = MAX(0, MIN(size.base_height, sh));
} else if (size.flags & PMinSize) {
- c->basew = size.min_width;
- c->baseh = size.min_height;
+ c->basew = MAX(0, MIN(size.min_width, sw));
+ c->baseh = MAX(0, MIN(size.min_height, sh));
} else
c->basew = c->baseh = 0;
if (size.flags & PResizeInc) {
- c->incw = size.width_inc;
- c->inch = size.height_inc;
+ c->incw = MAX(0, MIN(size.width_inc, sw));
+ c->inch = MAX(0, MIN(size.height_inc, sh));
} else
c->incw = c->inch = 0;
if (size.flags & PMaxSize) {
- c->maxw = size.max_width;
- c->maxh = size.max_height;
+ c->maxw = MAX(0, size.max_width);
+ c->maxh = MAX(0, size.max_height);
} else
c->maxw = c->maxh = 0;
if (size.flags & PMinSize) {
- c->minw = size.min_width;
- c->minh = size.min_height;
+ c->minw = MAX(0, MIN(size.min_width, sw));
+ c->minh = MAX(0, MIN(size.min_height, sh));
} else if (size.flags & PBaseSize) {
- c->minw = size.base_width;
- c->minh = size.base_height;
+ c->minw = MAX(0, MIN(size.base_width, sw));
+ c->minh = MAX(0, MIN(size.base_height, sh));
} else
c->minw = c->minh = 0;
- if (size.flags & PAspect) {
+ if ((size.flags & PAspect)
+ && size.min_aspect.x > 0 && size.max_aspect.y > 0) {
c->mina = (float)size.min_aspect.y / size.min_aspect.x;
c->maxa = (float)size.max_aspect.x / size.max_aspect.y;
} else
c->maxa = c->mina = 0.0;
--- a/dwm.c
+++ b/dwm.c
@@ -352,6 +352,9 @@ applysizehints(Client *c, int *x, int *y, int *w, int *h,
int interact)
if (!baseismin) { /* temporarily remove base dimensions */
*w -= c->basew;
*h -= c->baseh;
}
+ /* the subtraction above may have driven these to <= 0 */
+ *w = MAX(1, *w);
+ *h = MAX(1, *h);
/* adjust for aspect limits */
if (c->mina > 0 && c->maxa > 0) {
Behaviour note: clamping base/min/inc to the screen dimensions is a deliberate
policy choice - a legitimate client asking for a base size larger than the
screen will now be treated as asking for the screen size.
================================================================================
SEC-05 strncpy(dst, src, sizeof dst) leaves Monitor.ltsymbol unterminated
================================================================================
Severity : Medium (out-of-bounds read; gated on config.h content)
Location : dwm.c:398 arrangemon(); dwm.c:645 createmon(); dwm.c:1517
setlayout()
CWE : CWE-170 Improper Null Termination; CWE-125 Out-of-bounds Read
Description
-----------
Monitor.ltsymbol is `char ltsymbol[16]` (dwm.c:114). All three copies into it
pass the FULL size of the destination to strncpy():
dwm.c:398 strncpy(m->ltsymbol, m->lt[m->sellt]->symbol, sizeof
m->ltsymbol);
dwm.c:645 strncpy(m->ltsymbol, layouts[0].symbol, sizeof m->ltsymbol);
dwm.c:1517 strncpy(selmon->ltsymbol, selmon->lt[selmon->sellt]->symbol,
sizeof selmon->ltsymbol);
strncpy() does not NUL-terminate when strlen(src) >= n. A layout symbol of 16
or more characters therefore leaves ltsymbol without a terminator, and the
subsequent consumers all treat it as a C string:
dwm.c:732 w = TEXTW(m->ltsymbol); -> drw_fontset_getwidth ->
strlen-like walk
dwm.c:734 drw_text(drw, x, 0, w, bh, lrpad / 2, m->ltsymbol, 0);
dwm.c:441 else if (ev->x < x + TEXTW(selmon->ltsymbol))
drw_text() then walks past the 16-byte field, reading the adjacent struct
members (mfact, nmaster, num, by, mx...) and, once it runs off the end of the
heap allocation, unmapped memory -> SIGSEGV. Before crashing it RENDERS those
bytes into the bar, i.e. it discloses adjacent heap contents on screen.
Contrast dwm.c:1123, which gets this right: monocle() uses snprintf().
Reachability: layouts[] comes from config.h, so this is not remotely
attacker-controlled in a stock build. It is, however, a live foot-gun for the
patch-and-recompile workflow dwm is designed around - many popular layout
patches ship multi-byte/decorated symbols (e.g. "[]=" replaced with a padded
Unicode glyph string) that exceed 16 bytes once encoded in UTF-8.
Failure scenario
----------------
config.h contains `{ "||||||||||||||||", tile }` (16 chars). On the first
arrange(), drawbar() calls TEXTW(m->ltsymbol) which reads past ltsymbol[15]
into the Monitor struct and paints the raw bytes of mfact/nmaster into the
status bar; with a Monitor allocated at the end of a heap page it faults.
Illustrative fix
----------------
--- a/dwm.c
+++ b/dwm.c
@@ -395,7 +395,8 @@ void
arrangemon(Monitor *m)
{
- strncpy(m->ltsymbol, m->lt[m->sellt]->symbol, sizeof m->ltsymbol);
+ strncpy(m->ltsymbol, m->lt[m->sellt]->symbol, sizeof m->ltsymbol - 1);
+ m->ltsymbol[sizeof m->ltsymbol - 1] = '\0';
if (m->lt[m->sellt]->arrange)
m->lt[m->sellt]->arrange(m);
}
@@ -643,5 +644,6 @@ createmon(void)
m->lt[0] = &layouts[0];
m->lt[1] = &layouts[1 % LENGTH(layouts)];
- strncpy(m->ltsymbol, layouts[0].symbol, sizeof m->ltsymbol);
+ strncpy(m->ltsymbol, layouts[0].symbol, sizeof m->ltsymbol - 1);
+ m->ltsymbol[sizeof m->ltsymbol - 1] = '\0';
return m;
}
@@ -1515,5 +1517,6 @@ setlayout(const Arg *arg)
if (arg && arg->v)
selmon->lt[selmon->sellt] = (Layout *)arg->v;
- strncpy(selmon->ltsymbol, selmon->lt[selmon->sellt]->symbol, sizeof
selmon->ltsymbol);
+ strncpy(selmon->ltsymbol, selmon->lt[selmon->sellt]->symbol,
+ sizeof selmon->ltsymbol - 1);
+ selmon->ltsymbol[sizeof selmon->ltsymbol - 1] = '\0';
if (selmon->sel)
arrange(selmon);
A stronger alternative is a compile-time assert in the spirit of struct NumTags
(dwm.c:274), so an over-long symbol fails the build instead of corrupting the
bar at runtime.
================================================================================
SEC-06 Hostile window title -> XftFontMatch storm + unbounded font cache
================================================================================
Severity : Low/Medium (availability - CPU and memory exhaustion)
Location : drw.c:354-400 drw_text() fallback-font path
CWE : CWE-400 Uncontrolled Resource Consumption
Description
-----------
For each codepoint with no glyph in the loaded fonts, drw_text() performs a
fontconfig match and appends the result to drw->fonts permanently:
drw.c:359-366 /* 128-entry, 2-slot negative cache */
if (nomatches[h0] == utf8codepoint || nomatches[h1] == ...)
goto no_match;
drw.c:382 match = XftFontMatch(drw->dpy, drw->screen, fcpattern,
&result);
drw.c:390-392 for (curfont = drw->fonts; curfont->next; curfont =
curfont->next);
curfont->next = usedfont;
Two problems, both driven by fully client-controlled text (a window title read
in updatetitle(), dwm.c:2016, or the root-window status string read in
updatestatus(), dwm.c:2008):
1. The negative cache is 128 entries with only two hash slots per codepoint
(drw.c:263, drw.c:362-363). A title cycling through a few hundred distinct
unmapped codepoints thrashes it, so nearly every character triggers a full
FcConfigSubstitute + FcDefaultSubstitute + XftFontMatch on EVERY bar
redraw. That is a fontconfig traversal per character per redraw, inside
dwm's single-threaded event loop - the WM becomes unresponsive.
2. Fonts that DO match are appended to drw->fonts and never freed until exit
(only drw_free()/drw_fontset_free() release them). The list is also walked
linearly for every character (drw.c:294), so cost grows with list length.
Redraws are themselves client-triggerable: propertynotify() -> updatetitle() ->
drawbar() (dwm.c:1248-1252), or forged Expose events (see SEC-03 note).
Failure scenario
----------------
A client sets _NET_WM_NAME to ~200 codepoints drawn from unassigned planes
(e.g. U+E0100..U+E01CF) and then rewrites the property in a loop. Each rewrite
costs dwm hundreds of XftFontMatch calls; the WM stops servicing input while
RSS climbs with each newly opened fallback font.
Illustrative mitigation
-----------------------
This one has no clean one-liner; the sketch below caps the fallback chain and
is a behaviour trade-off (very exotic scripts will render as the invalid glyph
once the cap is hit):
--- a/drw.c
+++ b/drw.c
@@ -11,6 +11,8 @@
#define UTF_INVALID 0xFFFD
+/* bound the fallback chain: it is grown from client-supplied text */
+#define MAXFALLBACKS 32
@@ -387,10 +389,13 @@ no_match:
if (match) {
usedfont = xfont_create(drw, NULL, match);
- if (usedfont && XftCharExists(drw->dpy,
usedfont->xfont, utf8codepoint)) {
- for (curfont = drw->fonts;
curfont->next; curfont = curfont->next)
- ; /* NOP */
+ for (nfallbacks = 0, curfont = drw->fonts;
curfont->next;
+ curfont = curfont->next)
+ nfallbacks++;
+ if (usedfont && nfallbacks < MAXFALLBACKS
+ && XftCharExists(drw->dpy, usedfont->xfont,
utf8codepoint)) {
curfont->next = usedfont;
} else {
xfont_free(usedfont);
nomatches[nomatches[h0] ? h1 : h0] =
utf8codepoint;
(declare `unsigned int nfallbacks;` alongside the other locals at drw.c:251.)
Enlarging `nomatches` (drw.c:263) from 128 entries materially reduces the
XftFontMatch rate for the same cost in bss.
================================================================================
SEC-07 showhide() recurses once per window - stack exhaustion
================================================================================
Severity : Low (availability; requires many windows)
Location : dwm.c:1629-1645 showhide()
CWE : CWE-674 Uncontrolled Recursion
Description
-----------
showhide() walks the monitor's stack list by self-recursion, one frame per
client, in both branches:
dwm.c:1639 showhide(c->snext); /* visible: show, then recurse */
dwm.c:1642 showhide(c->snext); /* hidden: recurse, then hide */
The list length is the number of managed windows, which any client can inflate
by mapping windows in a loop (manage() imposes no limit). arrange() calls
showhide() on every layout change, so once the list is deep enough the next
arrange() overflows the stack. Compiled at -O2 GCC does not reliably turn the
visible branch into a tail call (there is work after the call in the hidden
branch), so the frames are real.
drw_fontset_free() (drw.c:160-167) has the same shape, but its list length is
bounded by the font cache, so it only matters in combination with SEC-06.
Failure scenario
----------------
A client maps ~100k tiny windows on one tag. The user presses MODKEY+t; dwm
recurses 100k frames deep in showhide() and receives SIGSEGV on stack
overflow.
Illustrative fix
----------------
Iterate. Hidden windows are moved off-screen, so the original bottom-up
ordering for that branch is not observable:
--- a/dwm.c
+++ b/dwm.c
@@ -1629,17 +1629,17 @@ void
showhide(Client *c)
{
- if (!c)
- return;
- if (ISVISIBLE(c)) {
- /* show clients top down */
- XMoveWindow(dpy, c->win, c->x, c->y);
- if ((!c->mon->lt[c->mon->sellt]->arrange || c->isfloating) &&
!c->isfullscreen)
- resize(c, c->x, c->y, c->w, c->h, 0);
- showhide(c->snext);
- } else {
- /* hide clients bottom up */
- showhide(c->snext);
- XMoveWindow(dpy, c->win, WIDTH(c) * -2, c->y);
- }
+ /* iterative: the stack list length is client controlled */
+ for (; c; c = c->snext) {
+ if (ISVISIBLE(c)) {
+ /* show clients top down */
+ XMoveWindow(dpy, c->win, c->x, c->y);
+ if ((!c->mon->lt[c->mon->sellt]->arrange ||
c->isfloating)
+ && !c->isfullscreen)
+ resize(c, c->x, c->y, c->w, c->h, 0);
+ } else {
+ XMoveWindow(dpy, c->win, WIDTH(c) * -2, c->y);
+ }
+ }
}
================================================================================
SEC-08 gettextprop() leaks the XmbTextPropertyToTextList allocation
================================================================================
Severity : Low (memory leak, client-triggerable)
Location : dwm.c:908-929 gettextprop()
CWE : CWE-401 Missing Release of Memory after Effective Lifetime
Description
-----------
dwm.c:922 } else if (XmbTextPropertyToTextList(dpy, &name, &list, &n)
>= Success && n > 0 && *list) {
dwm.c:923 strncpy(text, *list, size - 1);
dwm.c:924 XFreeStringList(list);
dwm.c:925 }
XFreeStringList() is inside the conjunction. If the conversion succeeds (return
value >= Success, which includes the positive "N characters could not be
converted" results) but `n == 0` or `*list == NULL`, `list` has been allocated
and is never freed. gettextprop() is on a client-driven hot path -
propertynotify() -> updatetitle() (dwm.c:1248-1252) runs on every WM_NAME /
_NET_WM_NAME change - so a client that can reach that state can leak on demand
until the OOM killer intervenes.
Illustrative fix
----------------
--- a/dwm.c
+++ b/dwm.c
@@ -918,9 +918,11 @@ gettextprop(Window w, Atom atom, char *text, unsigned int
size)
if (name.encoding == XA_STRING) {
strncpy(text, (char *)name.value, size - 1);
- } else if (XmbTextPropertyToTextList(dpy, &name, &list, &n) >= Success
&& n > 0 && *list) {
- strncpy(text, *list, size - 1);
- XFreeStringList(list);
+ } else if (XmbTextPropertyToTextList(dpy, &name, &list, &n) >= Success)
{
+ /* the list is allocated even when it turns out to be empty */
+ if (n > 0 && *list)
+ strncpy(text, *list, size - 1);
+ XFreeStringList(list);
}
text[size - 1] = '\0';
XFree(name.value);
================================================================================
SEC-09 updatenumlockmask() dereferences an unchecked XGetModifierMapping()
================================================================================
Severity : Low (NULL dereference)
Location : dwm.c:1945-1959 updatenumlockmask()
CWE : CWE-476 NULL Pointer Dereference
Description
-----------
dwm.c:1952 modmap = XGetModifierMapping(dpy);
dwm.c:1954 for (j = 0; j < modmap->max_keypermod; j++)
dwm.c:1955 if (modmap->modifiermap[i * modmap->max_keypermod + j]
XGetModifierMapping() returns NULL when the reply cannot be allocated. The
return value is never checked, so an allocation failure (which an X client can
help along by keeping the server and dwm under memory pressure - see SEC-06 and
SEC-08) turns into a NULL dereference. updatenumlockmask() runs from
grabbuttons() (dwm.c:934) on every focus change and from grabkeys()
(dwm.c:955), i.e. constantly.
Illustrative fix
----------------
--- a/dwm.c
+++ b/dwm.c
@@ -1948,7 +1948,8 @@ updatenumlockmask(void)
unsigned int i, j;
XModifierKeymap *modmap;
numlockmask = 0;
- modmap = XGetModifierMapping(dpy);
+ if (!(modmap = XGetModifierMapping(dpy)))
+ return;
for (i = 0; i < 8; i++)
================================================================================
SEC-10 NULL dereference when the monitor list empties or desyncs
================================================================================
Severity : Low (NULL dereference; requires an unusual RANDR/Xinerama state)
Location : dwm.c:498-512 cleanupmon(); dwm.c:1910-1924 and 1938-1941
updategeom()
CWE : CWE-476 NULL Pointer Dereference
Description
-----------
(a) cleanupmon() assumes `mon` is in the list:
dwm.c:506 for (m = mons; m && m->next != mon; m = m->next);
dwm.c:507 m->next = mon->next; /* m may be NULL here */
The loop guards against NULL but the dereference on the next line does not.
(b) updategeom()'s monitor-removal loop dereferences `m` unguarded, and the
same loop can empty the list entirely:
dwm.c:1912 for (m = mons; m && m->next; m = m->next);
dwm.c:1913 while ((c = m->clients)) { /* m may be NULL */
(c) If the removal loop does empty the list (nn == 0 reported by
XineramaQueryScreens), the tail of updategeom() leaves selmon NULL:
dwm.c:1939 selmon = mons; /* NULL */
dwm.c:1940 selmon = wintomon(root); /* recttomon() returns selmon ==
NULL */
Every later `selmon->...` (e.g. buttonpress() at dwm.c:433, focus() at
dwm.c:792) then faults.
Failure scenario
----------------
A RANDR/Xinerama transition that momentarily reports zero active screens (hot
unplug of the last output, or a driver glitch during suspend/resume) drives dwm
through (b)/(c) and it crashes on the next event.
Illustrative fix
----------------
--- a/dwm.c
+++ b/dwm.c
@@ -503,8 +503,9 @@ cleanupmon(Monitor *mon)
if (mon == mons)
mons = mons->next;
else {
for (m = mons; m && m->next != mon; m = m->next);
- m->next = mon->next;
+ if (m)
+ m->next = mon->next;
}
@@ -1910,7 +1911,9 @@ updategeom(void)
/* removed monitors if n > nn */
for (i = nn; i < n; i++) {
for (m = mons; m && m->next; m = m->next);
+ if (!m)
+ break;
while ((c = m->clients)) {
@@ -1938,6 +1941,8 @@ updategeom(void)
if (dirty) {
selmon = mons;
selmon = wintomon(root);
+ if (!selmon) /* never leave selmon dangling */
+ selmon = mons = mons ? mons : createmon();
}
return dirty;
(The last hunk is a sketch - the real invariant "mons is never empty" is better
enforced by refusing to remove the final monitor.)
================================================================================
SEC-11 clientmessage(): no format check, no origin restriction
================================================================================
Severity : Low
Location : dwm.c:514-531 clientmessage()
CWE : CWE-20 Improper Input Validation; CWE-346
Description
-----------
dwm.c:522 if (cme->message_type == netatom[NetWMState]) {
dwm.c:523 if (cme->data.l[1] == netatom[NetWMFullscreen]
dwm.c:524 || cme->data.l[2] == netatom[NetWMFullscreen])
cme->format is never checked. A client message sent with format 8 or 16 fills
the same 20-byte union, so data.l[1]/data.l[2] read reinterpreted bytes rather
than the atoms EWMH specifies. This is not memory-unsafe (the union is large
enough), but it means malformed messages can toggle fullscreen with values the
sender never intended to place there.
Separately, and by EWMH design, ANY client may send _NET_WM_STATE for ANY
window: a client can force an unrelated window (a password prompt, a video
call) into or out of fullscreen, and can set the urgency hint on any window
(dwm.c:527-529). This is spec-conformant, so it is noted for awareness rather
than as a defect - but it is the mechanism by which a hostile client can cover
the screen with its own window on demand.
Note for completeness: keypress() (dwm.c:1000) likewise never inspects
send_event, and keys[] includes spawn() and quit(). This is NOT currently
exploitable because dwm selects no KeyPressMask on the root or bar windows, so
a synthetic KeyPress has no recipient. Any patch that adds KeyPressMask to a
dwm-owned window turns this into a full command-execution bug; adding the
`if (ev->send_event) return;` guard now is cheap insurance.
Illustrative fix
----------------
--- a/dwm.c
+++ b/dwm.c
@@ -518,7 +518,7 @@ clientmessage(XEvent *e)
Client *c = wintoclient(cme->window);
- if (!c)
+ if (!c || cme->format != 32) /* EWMH mandates format 32 */
return;
if (cme->message_type == netatom[NetWMState]) {
================================================================================
SEC-12 spawn(): unchecked fork(), descriptor inheritance, dmenumon encoding
================================================================================
Severity : Low
Location : dwm.c:1647-1667 spawn()
CWE : CWE-252 Unchecked Return Value; CWE-403 Exposure of File
Descriptor to Unintended Control Sphere
Description
-----------
dwm.c:1652 if (arg->v == dmenucmd)
dwm.c:1653 dmenumon[0] = '0' + selmon->num;
dwm.c:1654 if (fork() == 0) {
dwm.c:1655 if (dpy)
dwm.c:1656 close(ConnectionNumber(dpy));
(a) fork() == -1 is not distinguished from the parent path, so under fork
exhaustion spawn() silently does nothing - no diagnostic, no feedback.
(b) The child closes only the X connection. Every other descriptor dwm
inherited from the session startup script (~/.xsession-errors, any log,
socket, or keyring/agent fd held open by .xinitrc) is passed to the
spawned program. dmenu/terminal launches are the most common path by
which an attacker-reachable process is created, so this is a real
least-privilege gap. Setting FD_CLOEXEC broadly, or closing the range
above stderr, is the fix.
(c) `dmenumon[0] = '0' + selmon->num` writes a single byte into
`static char dmenumon[2] = "0"` (config.def.h:60). No overflow, but for
monitor index >= 10 it writes ':', ';', ... and dmenu is handed a
non-numeric -m argument. Ten-plus-monitor setups exist.
Illustrative fix
----------------
--- a/dwm.c
+++ b/dwm.c
@@ -1647,12 +1647,20 @@ void
spawn(const Arg *arg)
{
struct sigaction sa;
+ pid_t pid;
- if (arg->v == dmenucmd)
+ if (arg->v == dmenucmd && selmon->num >= 0 && selmon->num < 10)
dmenumon[0] = '0' + selmon->num;
- if (fork() == 0) {
+ if ((pid = fork()) == -1) {
+ fprintf(stderr, "dwm: fork failed: %s\n", strerror(errno));
+ return;
+ }
+ if (pid == 0) {
if (dpy)
close(ConnectionNumber(dpy));
+ /* do not hand dwm's inherited descriptors to the child */
+ closefrom(STDERR_FILENO + 1); /* or a close_range()/loop
fallback */
setsid();
(`closefrom()` is BSD/glibc 2.34+; on older glibc use
`close_range(3, ~0U, 0)` or a getrlimit-bounded loop. A more portable
alternative is to set FD_CLOEXEC on descriptors as they are created.)
================================================================================
SEC-13 Placement rules are keyed on client-spoofable properties
(INFORMATIONAL)
================================================================================
Severity : Informational
Location : dwm.c:277-311 applyrules()
Description
-----------
dwm.c:295 if ((!r->title || strstr(c->name, r->title))
dwm.c:296 && (!r->class || strstr(class, r->class))
dwm.c:297 && (!r->instance || strstr(instance, r->instance)))
WM_CLASS and WM_NAME are set by the client itself, and the match is an
unanchored substring search. Any client can therefore opt in to any rule by
naming itself appropriately - e.g. claiming class "Firefox" to land on the
user's browser tag (config.def.h:31), or matching a rule that grants
isfloating/always-on-top placement. The string handling itself is sound
(c->name is NUL-terminated by gettextprop(), and `broken` is substituted when
the hints are absent).
This is inherent to X11 window rules rather than a dwm bug, but it means rules
must never be relied on as a trust or isolation boundary.
================================================================================
CHECKED AND FOUND SOUND
================================================================================
Recording these so the coverage of the review is auditable:
* getatomprop() (dwm.c:863-878) - the `nitems > 0 && format == 32` guard added
in c3dd6a8 is correct; reading `*(long *)p` is right for format 32 because
Xlib returns a long array regardless of the wire format. Requesting
`sizeof atom` (8) longs is an over-request, which is harmless.
* getstate() (dwm.c:890-906) - `n != 0 && format == 32` correctly prevents the
NULL/short read; XFree(NULL) on the not-found path is safe. The access-type
fix in 5c9f303 holds up.
* gettextprop() bounds (dwm.c:921, 926) - `strncpy(..., size - 1)` plus the
explicit `text[size - 1] = '\0'` is correct, and XGetTextProperty()
guarantees a NUL-terminated value, so the source-side read is in bounds.
(The only defect is the leak in SEC-08.)
* utf8decode() tables (drw.c:16-34) - `lens[*s >> 3]` indexes 0..31 into a
32-entry table and `leading_mask[len - 1]` / `overlong[len - 1]` index 0..3
into 4-entry tables, with the len == 0 case returning early. Surrogate,
overlong and out-of-range rejection at drw.c:41 is correct.
* unmapnotify() (dwm.c:1804-1816) - correctly distinguishes synthetic from
real unmaps. This is the pattern the handlers in SEC-01/02/03 should follow.
* struct NumTags (dwm.c:274) - the compile-time assert that tags fit a 31-bit
mask is sound, and every `arg->ui` is masked with TAGMASK before use
(dwm.c:1672, 1745, 1756, 2059), so the `1 << i` shifts cannot go out of
range.
* tile() (dwm.c:1687-1713) - the two divisors `MIN(n, m->nmaster) - i` and
`n - i` are provably >= 1 given the loop bounds, and incnmaster() clamps
nmaster at 0 (dwm.c:983), so the signed/unsigned comparison at dwm.c:1697 is
safe. No division by zero.
* sendevent() (dwm.c:1447-1470) - `while (!exists && n--)` terminates correctly
for n == 0 and does not index below zero.
* main() (dwm.c:2143-2165) - argv handling is bounded; the OpenBSD pledge() at
dwm.c:2157 is a genuine hardening win and its absence elsewhere is noted
below.
* transient.c - a standalone manual test program, not linked into dwm. No
findings.
* No use of system(), popen(), exec*p() with attacker-controlled argv, and no
format string is ever taken from client data (die()/fprintf() callers all
pass literals).
================================================================================
CROSS-CUTTING OBSERVATIONS
================================================================================
1. Add the missing origin check systematically. Ten of the fourteen handlers in
`handler[]` (dwm.c:245-260) act on data that may have arrived via
XSendEvent. A single audit pass adding `if (ev->send_event) return;` to
configurenotify, buttonpress, enternotify, motionnotify, expose,
propertynotify and keypress closes SEC-01, SEC-02, SEC-03 and pre-empts the
keypress variant, at a cost of about seven lines.
2. xerror()'s fail-closed default is load-bearing and currently over-strict for
BadValue. Any BadValue dwm provokes by feeding client-supplied numbers to
Xlib is fatal (SEC-01). Either validate the numbers (preferred) or add the
specific request_code/BadValue pairs to the ignore list - not BadValue
wholesale, which would mask real bugs.
3. Client-supplied integers cross the trust boundary unclamped. SEC-04 is the
acute case, but the same pattern appears in configurerequest() (dwm.c:589-
608: c->bw, c->x/y/w/h taken verbatim from the request) - those flow into
WIDTH()/HEIGHT() macros that add `2 * bw`, so a border_width near INT_MAX
overflows there too. A single sanitising accessor for "geometry value from
a client" would cover all of them.
4. Build hardening is absent. config.mk should carry, at minimum:
-D_FORTIFY_SOURCE=2 -fstack-protector-strong -fPIE -pie
-Wl,-z,relro,-z,now -Wl,-z,noexecstack
and, for development builds, -fsanitize=address,undefined - UBSan in
particular would have caught SEC-04's signed overflow and inf->int
conversion immediately. Consider also -Wall -Wextra -Wformat=2
-Wconversion; the codebase currently compiles clean under far less.
5. Privilege reduction is OpenBSD-only. The pledge() call at dwm.c:2157 has no
Linux counterpart. A seccomp-bpf filter after setup() (the syscall set dwm
needs post-initialisation is small: read/write/poll on the X socket, plus
fork/execve for spawn) would materially limit the impact of any memory-
safety bug found above.
6. No fuzzing harness exists. The highest-value next step is a synthetic X
client that drives dwm with randomised properties (WM_NORMAL_HINTS, WM_HINTS,
_NET_WM_STATE, long/exotic titles) and randomised XSendEvent traffic, run
against an ASan+UBSan build under Xephyr. SEC-04 and SEC-06 would surface in
minutes.
================================================================================
END OF FINDINGS
================================================================================
================================================================================
dmenu -- C SOURCE SECURITY REVIEW
================================================================================
Target : C:\dmenu (suckless dmenu 5.4)
Commit : 7175c48 "add -of and -ob arguments for outline colors options
for multi-selection" (branch master, clean tree)
Scope : dmenu.c (799 L), drw.c (471 L), stest.c (109 L), util.c (37 L),
arg.h, drw.h, util.h, config.def.h, plus the shipped shell
wrappers (dmenu_path, dmenu_run) and the build config.
Date : 2026-09-11
Method : Manual source audit (data-flow from the three untrusted inputs:
stdin item list, X11 keyboard/IM events, and the
PRIMARY/CLIPBOARD
selection) plus argv handling and build-hardening review.
IMPORTANT - STATUS OF THE PATCHES BELOW
No files in the repository were modified. The diffs in Part 2 are proposed
patches written by hand against the pristine tree at 7175c48. They have NOT
been applied and have NOT been compiled or tested. Apply them one at a time
with `patch -p1 < <hunk>` (patch resolves line-number offsets by context);
if you apply several, later hunk headers will be off by the earlier deltas.
THREAT MODEL / CAVEAT
dmenu is an unprivileged, non-setuid, single-user X11 client. It holds no
privilege boundary of its own, and X11 itself provides no isolation between
clients on the same display. Consequently nothing below is a
privilege-escalation bug. The realistic impacts are: (a) crashes / denial of
service driven by untrusted menu content or by the invoking arguments,
(b) out-of-bounds reads, and (c) exposure of typed secrets, which matters
because dmenu is widely used as a passphrase prompt (passmenu, pass-dmenu).
Findings are ranked by exploitability within that model, not by CVSS.
--------------------------------------------------------------------------------
SUMMARY
--------------------------------------------------------------------------------
ID Sev CWE Location Issue
---- ------ -------- ------------------------------
------------------------------
F1 HIGH CWE-476 dmenu.c:443 / dmenu.c:178 NULL-pointer dereference:
End key leaves curr ==
NULL,
drawmenu() then reads
curr->left. Reachable
crash.
F2 MED CWE-125 dmenu.c:661-667 Out-of-bounds read of
info[n] when the pointer
lies on no Xinerama screen.
F3 MED CWE-190 dmenu.c:637,743-745 `lines = MAX(lines, 0)` is
CWE-20 dead code (lines is
unsigned); atoi() lets -l
wrap; (lines+1)*bh
overflows.
F4 MED CWE-476 dmenu.c:696 XCreateIC() return value
unchecked -> NULL xic is
passed to
XmbLookupString().
F5 LOW CWE-457 dmenu.c:326,417 Uninitialised read of
buf[0]
when XmbLookupString()
returns XLookupKeySym.
F6 LOW CWE-191 drw.c:340-344 Unsigned underflow of `w`
in
measure-only mode on
invalid
UTF-8.
F7 LOW CWE-190 dmenu.c:559,562 Unchecked size multiply in
CWE-20 realloc(); getline() return
used without a `len > 0`
guard.
F8 LOW CWE-226 dmenu.c:36,99-112 Typed input (possibly a
CWE-528 passphrase) is left in a
static buffer and can reach
a core dump.
F9 MED CWE-426 dmenu_path:9-10 Empty PATH element makes
CWE-59 stest list $PWD binaries;
`tee` follows a symlinked
cache path.
F10 INFO CWE-1104 config.mk:27-29 No build hardening
(RELRO/PIE/FORTIFY/stack
protector).
F11 INFO CWE-401 dmenu.c:232-244, 693-697 Leaks: tokv, xim/xic never
released.
F12 INFO CWE-667 dmenu.c:788-790 `-f` grabs the keyboard
before reading stdin: a
stalled producer wedges the
whole X session's keyboard.
F13 INFO CWE-200 dmenu.c:217-218 `-w` (embed) skips the
keyboard grab entirely.
F14 INFO CWE-367 stest.c:28-48 access()-based tests are
CWE-807 TOCTOU-prone by design.
F15 INFO -- dmenu_run:2 Selection is piped straight
into $SHELL (by design, but
it is the impact multiplier
for F9).
CHECKED AND FOUND *NOT* VULNERABLE (documented so the next reviewer can skip):
* insert() (dmenu.c:284) -- the classic suspect. `strlen(text) + n` with a
negative ssize_t n wraps to a huge size_t and is correctly rejected by the
`> sizeof text - 1` test; dest = &text[cursor+n] and the length
`sizeof text - cursor - MAX(n,0)` both stay inside text[] for every call
site (paste, XK_u, XK_w, BackSpace, Delete). No overflow.
* match() (dmenu.c:240) -- `char buf[sizeof text]` + strcpy(buf, text) is
size-exact; the tokv realloc sizes tokn >= tokc before writing
tokv[tokc-1].
* keypress() XK_Tab (dmenu.c:519) -- strnlen(sel->text, sizeof text - 1)
bounds both the memcpy and the terminator write.
* utf8decode() (drw.c:14) -- lens[*s >> 3] is a 32-entry table indexed 0..31;
leading_mask[len-1] is bounded 0..3; the continuation loop stops on '\0'
and
returns the number of bytes actually consumed, so drw_font_getexts() never
reads past the terminator.
* nextrune() (dmenu.c:297) -- returns (size_t)-1 when cursor == 0, but every
caller is guarded by `cursor > 0` or an explicit `cursor == 0` early
return.
Latent trap, not currently reachable.
* readstdin() NUL terminator (dmenu.c:557,571) -- the `i + 1 >= itemsiz`
growth check keeps both index i and the sentinel index i valid.
* stest.c FLAG() / flag[26] -- ARGC() is validated with
strchr("abcdefghlpqrsuvwx", ...) before indexing, and ARGBEGIN guarantees
ARGC() != '\0', so `flag[(x)-'a']` cannot go out of bounds.
* die() (util.c:11) -- vfprintf with a caller-supplied format, but every one
of the 12 call sites passes a string literal; attacker-controlled data
(colour names, font names, window ids) is always an argument, never the
format. No format-string bug.
* drw_text() ellipsis recursion -- recursive calls with w == 0 are rejected
by
the `render && (!drw->scheme || !w)` guard, and ellipsis_len is always <=
the current utf8strlen, so XftDrawStringUtf8 never overruns utf8str.
================================================================================
PART 1 -- FINDINGS
================================================================================
--------------------------------------------------------------------------------
F1 HIGH -- NULL-pointer dereference via the End key (CWE-476)
--------------------------------------------------------------------------------
Where : dmenu.c:443 (root cause), dmenu.c:178 (crash site), dmenu.c:80-96
Impact: Reliable, remotely-influenced crash of dmenu. Availability only -- the
dereference is of a plain NULL, not of attacker-chosen memory, so this
is not exploitable for code execution.
The End handler walks `curr` to the last page:
dmenu.c:437-445
if (next) {
/* jump to end of list and position items in reverse */
curr = matchend;
calcoffsets();
curr = prev;
calcoffsets();
while (next && (curr = curr->right))
calcoffsets();
}
calcoffsets() computes the page break:
dmenu.c:85-95
if (lines > 0)
n = lines * bh;
else
n = mw - (promptw + inputw + TEXTW("<") + TEXTW(">"));
for (i = 0, next = curr; next; next = next->right)
if ((i += (lines > 0) ? bh : textw_clamp(next->text, n)) > n)
break;
If the very first item of the page is already wider than the budget `n`, the
loop breaks immediately with `next == curr`, i.e. non-NULL. The while loop at
:443 therefore keeps advancing, and when `curr` reaches `matchend` the
assignment `curr = curr->right` yields NULL, ends the loop, and leaves the
global `curr` NULL. drawmenu() is then called and takes the horizontal branch:
dmenu.c:174-178
} else if (matches) {
/* draw horizontal list */
x += inputw;
w = TEXTW("<");
if (curr->left) { <-- NULL dereference
Two ways to make `next == curr` hold for every page, both trivially reachable:
a) A long `-p` prompt. `promptw` grows without bound while `inputw` is fixed
at mw/3, so `n` at :88 goes negative. `n` is a signed int, so the
comparison `(i += textw_clamp(...)) > n` is true on the first item
regardless of its width. (Note textw_clamp() takes `unsigned int n`, so
the negative n converts to ~4e9 inside it and the clamp is a no-op --
the returned width is the real, small width, which is then compared
against the *signed* negative n.)
b) A single menu item wider than roughly 2/3 of the screen (n is about
mw - mw/3 minus the arrow widths). ~200 characters at a default font
size is enough. This is ordinary, non-malicious input: long paths, long
command lines, `pass` entry names.
Repro (a):
printf 'a\nb\n' | dmenu -p "$(printf 'x%.0s' $(seq 1 400))"
then press End -> segfault
Repro (b):
{ printf 'short\n'; printf 'y%.0s' $(seq 1 400); echo; } | dmenu
then press End -> segfault
Traced for the two-item case (i1 narrow, i2 wide): curr=matchend=i2 ->
calcoffsets sets next=i2,prev=i1 -> curr=prev=i1 -> calcoffsets sets next=i2 ->
loop: curr=i1->right=i2, calcoffsets sets next=i2 -> loop: curr=i2->right=NULL
-> exit with curr==NULL -> drawmenu -> curr->left.
Fix: two parts (Patch F1). Primary, at :443, never let `curr` become NULL --
advance only when `curr->right` exists. Secondary, defence in depth at :174,
require `curr` in the horizontal branch. The deeper design flaw -- that
calcoffsets() can fail to make forward progress when a single item does not fit
the budget -- is left alone deliberately: making it always advance at least one
item changes paging behaviour and belongs in a separate change.
--------------------------------------------------------------------------------
F2 MEDIUM -- Out-of-bounds read of the Xinerama screen array (CWE-125)
--------------------------------------------------------------------------------
Where : dmenu.c:659-667
Impact: Heap over-read of one XineramaScreenInfo (~20 bytes) past the end of
an Xlib allocation. The garbage is used as window geometry, so the
observable effect ranges from a mispositioned menu to a huge
XCreatePixmap/XCreateWindow request (BadAlloc / X-server memory
pressure). Not attacker-controlled input, so this is a robustness and
memory-safety defect rather than an attack primitive.
dmenu.c:659-667
/* no focused window is on screen, so use pointer location instead */
if (mon < 0 && !area && XQueryPointer(dpy, root, &dw, &dw, &x, &y, &di,
&di, &du))
for (i = 0; i < n; i++)
if (INTERSECT(x, y, 1, 1, info[i]) != 0)
break;
x = info[i].x_org;
y = info[i].y_org + (topbar ? 0 : info[i].height - mh);
mw = info[i].width;
If the loop completes without a match, `i == n` and info[n] is read -- one
element past the array XineramaQueryScreens() allocated. The loop does not
match when the pointer is inside the root window but outside every CRTC area.
That region exists whenever the monitor layout is not a perfect rectangle
(e.g. a 1920x1080 beside a 2560x1440 leaves an L-shaped dead zone inside the
root window's bounding box), and the pointer can be placed there by
XWarpPointer from any client on the display.
Second, smaller instance in the same statement: `n` is never checked to be
>= 1. If XineramaQueryScreens() ever returns a non-NULL array with n == 0,
neither of the two `for (j/i = 0; ... < n; ...)` loops runs, `i` stays 0, and
info[0] is read out of bounds.
Fix: Patch F2 -- reject n < 1 up front, and clamp `i` back to 0 when the
pointer matched no screen.
--------------------------------------------------------------------------------
F3 MEDIUM -- Dead bounds check + integer overflow on -l (CWE-190, CWE-20)
--------------------------------------------------------------------------------
Where : dmenu.c:637-638, dmenu.c:742-745, config.def.h:17
Impact: Unvalidated numeric argv; an X resource-exhaustion / BadValue path.
Requires control of dmenu's own command line, so the direct security
value is low -- but the dead check makes this a latent bug that any
future patch touching `lines` will fall into, which is why it is rated
MEDIUM rather than INFO.
`lines` is unsigned (config.def.h:17 `static unsigned int lines = 0;`), so the
guard at dmenu.c:637 is dead code -- `MAX(lines, 0)` on an unsigned value can
never change it:
dmenu.c:636-638
bh = drw->fonts->h + 2;
lines = MAX(lines, 0); <-- no-op, lines is unsigned
mh = (lines + 1) * bh; <-- overflows
The value arrives via atoi(), which has no error reporting and silently
accepts negatives and garbage:
dmenu.c:742-745
else if (!strcmp(argv[i], "-l")) /* number of lines in vertical list */
lines = atoi(argv[++i]);
else if (!strcmp(argv[i], "-m"))
mon = atoi(argv[++i]);
`dmenu -l -1` therefore sets lines = 4294967295. Two consequences:
* `(lines + 1) * bh` at :638 wraps unsigned to 0, so mh becomes 0 and
XCreateWindow(..., mh=0, ...) raises BadValue.
* `mh` is a plain `int`, so a large-but-legal item count also overflows it
signed (undefined behaviour) before reaching XCreatePixmap.
Partially mitigated today by `lines = MIN(lines, i);` at dmenu.c:572, which
clamps `lines` to the number of items read from stdin. That mitigation is
incidental -- it happens to run before setup() -- and it still permits
mh = (itemcount + 1) * bh, i.e. a ~100k-line stdin plus `-l -1` asks the X
server for a pixmap around 1.7 million pixels tall.
Fix: Patch F3 -- parse -l/-m with strtol and a range check, and replace the
dead MAX() with a real clamp that keeps (lines + 1) * bh inside the CARD16
range the X protocol actually allows for a window dimension.
--------------------------------------------------------------------------------
F4 MEDIUM -- XCreateIC() return value unchecked (CWE-476)
--------------------------------------------------------------------------------
Where : dmenu.c:696-697, used at dmenu.c:331
Impact: NULL-pointer dereference inside Xlib on the first keypress. Crash
before any user input is processed.
XOpenIM is checked, XCreateIC is not:
dmenu.c:693-697
if ((xim = XOpenIM(dpy, NULL, NULL, NULL)) == NULL)
die("XOpenIM failed: could not open input device");
xic = XCreateIC(xim, XNInputStyle, XIMPreeditNothing | XIMStatusNothing,
XNClientWindow, win, XNFocusWindow, win, NULL);
XCreateIC legitimately returns NULL -- an input method that does not support
the requested PreeditNothing/StatusNothing style, or a resource failure. The
NULL then goes straight into XmbLookupString():
dmenu.c:331
len = XmbLookupString(xic, ev, buf, sizeof buf, &ksym, &status);
Fix: Patch F4 -- die() on a NULL context, matching the XOpenIM handling
directly above it.
--------------------------------------------------------------------------------
F5 LOW -- Uninitialised stack read in keypress() (CWE-457)
--------------------------------------------------------------------------------
Where : dmenu.c:326 (declaration), dmenu.c:417 (read)
Impact: Undefined behaviour; reads one byte of uninitialised stack. Benign in
practice (the accompanying len is 0, so nothing is inserted), but it is
a real UB / MSan finding and the kind of thing that turns into a bug
when the surrounding code is edited.
dmenu.c:326 char buf[64];
dmenu.c:331 len = XmbLookupString(xic, ev, buf, sizeof buf, &ksym,
&status);
When XmbLookupString() reports status == XLookupKeySym it returns 0 and does
not write to `buf` at all. Execution continues into the keysym switch, and any
unhandled keysym falls through to the default arm:
dmenu.c:414-419
switch(ksym) {
default:
insert:
if (!iscntrl((unsigned char)*buf)) <-- buf is uninitialised
insert(buf, len); <-- len == 0 here
break;
`*buf` is read before anything has written it. The subsequent insert(buf, 0) is
a harmless self-memmove, so the only concrete consequence is the UB itself.
Fix: Patch F5 -- test `len > 0` before touching buf, and zero-initialise the
buffer at its declaration.
--------------------------------------------------------------------------------
F6 LOW -- Unsigned underflow of the width budget in drw_text() (CWE-191)
--------------------------------------------------------------------------------
Where : drw.c:340-344
Impact: `w` (unsigned int) wraps to ~4e9 in measure-only mode, defeating the
early-exit in the measuring loop. Result: wasted CPU walking the whole
string instead of stopping at the clamp, on any item containing invalid
UTF-8. No memory-safety consequence -- callers clamp the return value.
drw.c:340-345
if (utf8err && (!render || invalid_width < w)) {
if (render)
drw_text(drw, x, y, w, h, 0, invalid, invert);
x += invalid_width;
w -= invalid_width;
}
The `invalid_width < w` guard is short-circuited by `!render`, which is exactly
the mode reached from drw_fontset_getwidth_clamp() (drw.c:431) where `w` was
set to the small clamp value `n`:
drw.c:269-270
if (!render) {
w = invert ? invert : ~invert; /* w = n for the clamp path */
So with n smaller than the cached `invalid_width`, `w -= invalid_width`
underflows and every later `ew + tmpw > w` test becomes false. The function
still returns a clamped value (textw_clamp does MIN(w, n) at dmenu.c:64), so
output is correct; only the work is wasted. Attacker cost: put a stray 0x80
byte in a long menu item.
Note that in a pure measuring run `invalid_width` may still be 0, because it is
only initialised under `render` (drw.c:286-287) -- that path does not
underflow,
which is why this is easy to miss.
Fix: Patch F6 -- apply the `invalid_width < w` guard unconditionally.
--------------------------------------------------------------------------------
F7 LOW -- Unchecked allocation size and getline() length (CWE-190, CWE-20)
--------------------------------------------------------------------------------
Where : dmenu.c:556-563
Impact: On 32-bit builds, `itemsiz * sizeof(*items)` can wrap, producing an
undersized allocation and a subsequent heap overflow. Needs roughly
1.3e8 stdin lines to reach, so realloc() almost certainly fails first;
treat this as hardening rather than an exploitable bug.
dmenu.c:556-563
for (i = 0; (len = getline(&line, &linesiz, stdin)) != -1; i++) {
if (i + 1 >= itemsiz) {
itemsiz += 256;
if (!(items = realloc(items, itemsiz * sizeof(*items))))
die("cannot realloc %zu bytes:", itemsiz *
sizeof(*items));
}
if (line[len - 1] == '\n')
line[len - 1] = '\0';
Two small issues:
* the multiplication is unchecked (use reallocarray(), or test against
SIZE_MAX / sizeof(*items));
* `line[len - 1]` assumes len >= 1. glibc's getline() never returns 0, so
this is currently safe -- but note stest.c:86 writes the same loop
defensively as `while ((n = getline(...)) > 0)`. The inconsistency is worth
removing.
Fix: Patch F7 -- add the overflow check and the `len > 0` guard.
--------------------------------------------------------------------------------
F8 LOW -- Typed secrets retained in memory and exposed to core dumps
(CWE-226, CWE-528)
--------------------------------------------------------------------------------
Where : dmenu.c:36 (`static char text[BUFSIZ]`), dmenu.c:99-112 (cleanup)
Impact: Confidentiality. dmenu is a standard passphrase prompt (passmenu and
friends pipe it into `pass`), so text[] routinely holds a secret. It is
a static buffer in .bss that is never cleared, and core dumps are not
disabled, so a crash -- including the one in F1, which is reachable
with the *same* keystrokes a user makes while typing -- can write the
passphrase to disk via the system core handler.
dmenu.c:36 static char text[BUFSIZ] = "";
dmenu.c:99-112 cleanup() ungrabs, frees the item list and closes the
display; text[] is left intact.
Pasted clipboard contents (paste(), dmenu.c:531) land in the same buffer, as do
the `match()` scratch copy (`char buf[sizeof text]`, dmenu.c:235) and the IM
buffer (`char buf[64]`, dmenu.c:326), neither of which is scrubbed either.
This is a design-level gap rather than a coding error, and a complete fix is
out of scope for a source patch (it would also want mlock() to keep the buffer
out of swap, and the scratch copies handled). Patch F8 does the two cheap,
high-value parts: disable core dumps with setrlimit(RLIMIT_CORE, 0) and scrub
text[] in cleanup() using a volatile-qualified loop the compiler is not
permitted to optimise away (a plain memset() here is routinely elided).
--------------------------------------------------------------------------------
F9 MEDIUM -- dmenu_path: empty PATH element and symlink-following cache
(CWE-426, CWE-59)
--------------------------------------------------------------------------------
Where : dmenu_path:6, dmenu_path:9-10
Impact: Untrusted search path leading to command execution, because dmenu_run
pipes whatever is selected straight into $SHELL (F15).
dmenu_path (full file, 13 lines)
cachedir="${XDG_CACHE_HOME:-"$HOME/.cache"}"
cache="$cachedir/dmenu_run"
[ ! -e "$cachedir" ] && mkdir -p "$cachedir"
IFS=:
if stest -dqr -n "$cache" $PATH; then
stest -flx $PATH | sort -u | tee "$cache"
else
cat "$cache"
fi
(a) Empty PATH element -> current directory (CWE-426). With IFS=: and $PATH
unquoted, a leading ':', a trailing ':' or an embedded '::' produces an
empty argument to stest. stest resolves an empty path as the current
directory, so `stest -flx $PATH` lists every executable in $PWD under its
bare name, mixed indistinguishably into the menu. Since the same empty
PATH element also makes the shell resolve bare names against $PWD, picking
one of those entries in dmenu_run executes the file from the current
directory. Dropping a file named e.g. `ls` or `firefox` into any directory
the user will `cd` into is enough. An empty PATH element is a common
shell-profile accident, not something the attacker has to arrange.
(b) Cache written through an unvalidated path (CWE-59). `tee "$cache"` opens
the path with O_CREAT and follows symlinks. If $cache can be pre-created as
a symlink, tee truncates and overwrites the target with the executable
list. The path is inside the user's own cache directory, so this needs
either a pre-existing hostile symlink or a writable/attacker-influenced
XDG_CACHE_HOME -- hence MEDIUM, not HIGH.
(c) `mkdir -p "$cachedir"` uses the ambient umask; with a lax umask (002/022)
the cache is group- or world-writable, which turns (b) from conditional
into trivial on a shared host. The `[ ! -e ]` test is also wrong for a
dangling symlink (-e follows the link and reports false), so the mkdir is
skipped in exactly the case that matters.
Fix: Patch F9 -- filter empty PATH elements before handing them to stest,
create the cache directory with mode 700, and write the cache via a fresh
temp file under `umask 077` plus an atomic rename instead of tee-ing through
a path an attacker may have staged.
--------------------------------------------------------------------------------
F10 INFO -- No build hardening (CWE-1104)
--------------------------------------------------------------------------------
Where : config.mk:27-29
CPPFLAGS = -D_DEFAULT_SOURCE ... $(XINERAMAFLAGS)
CFLAGS = -std=c99 -pedantic -Wall -Os $(INCS) $(CPPFLAGS)
LDFLAGS = $(LIBS)
Missing: -D_FORTIFY_SOURCE=2, -fstack-protector-strong, full RELRO
(-Wl,-z,relro,-z,now), PIE (-fPIE / -pie), and -Wextra. -Wall alone also does
not flag the signed/unsigned comparison at dmenu.c:704
(`for (i = 0; i < du && ...)`, int vs unsigned int) that -Wextra would catch.
Fix: Patch F10. Note -fstack-clash-protection is GCC 8+/recent-clang and
x86/aarch64 only; drop it if the build target is older or on another arch.
_FORTIFY_SOURCE requires optimisation, which -Os already supplies.
--------------------------------------------------------------------------------
F11 INFO -- Resource leaks (CWE-401)
--------------------------------------------------------------------------------
* dmenu.c:232-244 -- the static `tokv` token vector grows across match() calls
and is never freed. Bounded by the longest input ever typed, so it is a
bookkeeping leak, not a DoS.
* dmenu.c:693-697 -- `xim` and `xic` are never released; cleanup() calls
neither XDestroyIC() nor XCloseIM().
Both are reclaimed at process exit. No patch proposed.
--------------------------------------------------------------------------------
F12 INFO -- `-f` holds the keyboard grab while reading stdin (CWE-667)
--------------------------------------------------------------------------------
Where : dmenu.c:788-790
if (fast && !isatty(0)) {
grabkeyboard();
readstdin();
With -f the keyboard is grabbed *before* stdin is drained. An X11 keyboard
grab is display-wide, so if the process feeding stdin stalls or never closes
its end, no other client -- including the window manager -- receives key
events, and there is no keyboard path to recover. The user must kill dmenu
from another input device or another session. This is the documented intent of
-f (grab early so no keystrokes are lost) and the trade-off is inherent, so
this is recorded for awareness rather than as a defect. No patch proposed.
--------------------------------------------------------------------------------
F13 INFO -- Embed mode does not grab the keyboard (CWE-200)
--------------------------------------------------------------------------------
Where : dmenu.c:217-218
if (embed)
return;
With `-w <windowid>`, grabkeyboard() returns immediately. The keyboard grab is
the only thing that stops other X clients from reading keystrokes intended for
dmenu, so anything typed while embedded -- including a passphrase -- can be
observed by any client on the display. The skip is required for embedding to
work at all, and X11 offers no per-client input isolation regardless, so this
is a property of the platform. Worth documenting in dmenu.1 next to -w if the
tool is used for secrets. No patch proposed.
--------------------------------------------------------------------------------
F14 INFO -- stest: TOCTOU-prone permission tests (CWE-367, CWE-807)
--------------------------------------------------------------------------------
Where : stest.c:28-48
test() stats a path, then calls access(path, R_OK/W_OK/X_OK) on it, then prints
the name; the caller (dmenu_run) later executes the result. The file can change
between the stat, the access and the exec. access() is additionally the wrong
primitive for an authorisation decision in general. Here there is no privilege
boundary -- stest is not setuid and runs as the same user that will run the
command -- so the race has no security consequence in the shipped usage.
Flagged
only because the pattern is a false positive magnet in automated scanners. Also
note test() follows symlinks unless -h is given. No patch proposed.
--------------------------------------------------------------------------------
F15 INFO -- dmenu_run executes the selection (by design)
--------------------------------------------------------------------------------
Where : dmenu_run:2
dmenu_path | dmenu "$@" | ${SHELL:-"/bin/sh"} &
The selected or typed line is handed to a shell, unquoted and uninterpreted --
that is the entire purpose of dmenu_run. It is listed here because it is the
impact multiplier that turns F9 from "the menu shows the wrong entries" into
command execution, and because it means anything that can influence dmenu's
stdin or the dmenu_path cache is one Enter keypress from running code. No patch
proposed; this is intended behaviour.
================================================================================
PART 2 -- PROPOSED PATCHES (NOT APPLIED, NOT COMPILED)
================================================================================
Each patch below is independent and is written against the pristine tree at
commit 7175c48. Apply with:
patch -p1 < <one of the hunks below>
Line numbers in the @@ headers are exact for a single patch applied to the
clean tree; if you apply several, `patch` will report and absorb the offsets.
Recommended order if applying all: F1, F4, F2, F5, F3, F7, F8, F6, F9, F10.
--------------------------------------------------------------------------------
Patch F1 -- NULL-pointer dereference via the End key
--------------------------------------------------------------------------------
--- a/dmenu.c
+++ b/dmenu.c
@@ -171,7 +171,7 @@ drawmenu(void)
/* draw vertical list */
for (item = curr; item != next; item = item->right)
drawitem(item, x, y += bh, mw - x);
- } else if (matches) {
+ } else if (matches && curr) {
/* draw horizontal list */
x += inputw;
w = TEXTW("<");
@@ -438,9 +438,11 @@ keypress(XKeyEvent *ev)
/* jump to end of list and position items in reverse */
curr = matchend;
calcoffsets();
curr = prev;
calcoffsets();
- while (next && (curr = curr->right))
+ /* advance only while there is a next item: assigning
+ * curr = curr->right would leave curr NULL, and
+ * drawmenu() dereferences curr->left */
+ while (next && curr->right) {
+ curr = curr->right;
calcoffsets();
+ }
}
sel = matchend;
--------------------------------------------------------------------------------
Patch F2 -- Out-of-bounds read of the Xinerama screen array
--------------------------------------------------------------------------------
--- a/dmenu.c
+++ b/dmenu.c
@@ -640,6 +640,8 @@ setup(void)
i = 0;
if (parentwin == root && (info = XineramaQueryScreens(dpy, &n))) {
+ if (n < 1)
+ die("Xinerama reported no screens");
XGetInputFocus(dpy, &w, &di);
if (mon >= 0 && mon < n)
i = mon;
@@ -659,7 +661,11 @@ setup(void)
/* no focused window is on screen, so use pointer location
instead */
if (mon < 0 && !area && XQueryPointer(dpy, root, &dw, &dw, &x,
&y, &di, &di, &du))
for (i = 0; i < n; i++)
if (INTERSECT(x, y, 1, 1, info[i]) != 0)
break;
+ /* the pointer can sit inside the root window but outside every
+ * screen (gaps in a mixed-resolution layout), leaving i == n;
+ * fall back to the first screen rather than reading info[n] */
+ if (i >= n)
+ i = 0;
x = info[i].x_org;
--------------------------------------------------------------------------------
Patch F3 -- Validate -l/-m; clamp the menu height
--------------------------------------------------------------------------------
--- a/dmenu.c
+++ b/dmenu.c
@@ -1,9 +1,12 @@
/* See LICENSE file for copyright and license details. */
#include <ctype.h>
+#include <errno.h>
+#include <limits.h>
#include <locale.h>
+#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>
#include <unistd.h>
@@ -635,6 +638,9 @@ setup(void)
/* calculate menu geometry */
bh = drw->fonts->h + 2;
- lines = MAX(lines, 0);
- mh = (lines + 1) * bh;
+ /* X11 window dimensions are CARD16, so clamp instead of overflowing.
+ * The previous MAX(lines, 0) was dead code: lines is unsigned. */
+ if (bh > 0 && lines > UINT16_MAX / (unsigned int)bh - 1)
+ lines = UINT16_MAX / (unsigned int)bh - 1;
+ mh = (lines + 1) * bh;
#ifdef XINERAMA
i = 0;
@@ -712,5 +718,18 @@
}
+static int
+parseint(const char *s, int min, int max, const char *opt)
+{
+ char *end;
+ long n;
+
+ errno = 0;
+ n = strtol(s, &end, 10);
+ if (errno || end == s || *end || n < min || n > max)
+ die("invalid value for %s: %s", opt, s);
+ return (int)n;
+}
+
static void
usage(void)
{
@@ -742,5 +761,5 @@ main(int argc, char *argv[])
else if (!strcmp(argv[i], "-l")) /* number of lines in
vertical list */
- lines = atoi(argv[++i]);
+ lines = parseint(argv[++i], 0, INT_MAX, "-l");
else if (!strcmp(argv[i], "-m"))
- mon = atoi(argv[++i]);
+ mon = parseint(argv[++i], 0, INT_MAX, "-m");
else if (!strcmp(argv[i], "-p")) /* adds prompt to left of
input field */
Behaviour note: -m no longer accepts a negative argument. `mon` defaults to -1
meaning "auto-detect", and dmenu.1 documents -m as taking a monitor number
starting at 0, so a negative -m was only ever a confusing way to ask for the
default. Omit -m to get auto-detection.
--------------------------------------------------------------------------------
Patch F4 -- Check XCreateIC()
--------------------------------------------------------------------------------
--- a/dmenu.c
+++ b/dmenu.c
@@ -692,8 +692,9 @@ setup(void)
/* input methods */
if ((xim = XOpenIM(dpy, NULL, NULL, NULL)) == NULL)
die("XOpenIM failed: could not open input device");
- xic = XCreateIC(xim, XNInputStyle, XIMPreeditNothing | XIMStatusNothing,
- XNClientWindow, win, XNFocusWindow, win, NULL);
+ if (!(xic = XCreateIC(xim, XNInputStyle, XIMPreeditNothing |
XIMStatusNothing,
+ XNClientWindow, win, XNFocusWindow, win, NULL)))
+ die("XCreateIC failed: could not obtain input context");
XMapRaised(dpy, win);
--------------------------------------------------------------------------------
Patch F5 -- Do not read buf[] when XmbLookupString() wrote nothing
--------------------------------------------------------------------------------
--- a/dmenu.c
+++ b/dmenu.c
@@ -325,7 +325,7 @@ keypress(XKeyEvent *ev)
{
- char buf[64];
+ char buf[64] = "";
int len;
KeySym ksym = NoSymbol;
Status status;
len = XmbLookupString(xic, ev, buf, sizeof buf, &ksym, &status);
@@ -414,7 +414,7 @@ keypress(XKeyEvent *ev)
switch(ksym) {
default:
insert:
- if (!iscntrl((unsigned char)*buf))
+ if (len > 0 && !iscntrl((unsigned char)*buf))
insert(buf, len);
break;
case XK_Delete:
--------------------------------------------------------------------------------
Patch F6 -- Guard the width budget against unsigned underflow
--------------------------------------------------------------------------------
--- a/drw.c
+++ b/drw.c
@@ -337,7 +337,7 @@ drw_text(Drw *drw, int x, int y, unsigned int w, unsigned
int h, unsigned int lp
x += ew;
w -= ew;
}
- if (utf8err && (!render || invalid_width < w)) {
+ if (utf8err && invalid_width < w) {
if (render)
drw_text(drw, x, y, w, h, 0, invalid, invert);
--------------------------------------------------------------------------------
Patch F7 -- Check the allocation size and the getline() length
--------------------------------------------------------------------------------
--- a/dmenu.c
+++ b/dmenu.c
@@ -1,9 +1,10 @@
/* See LICENSE file for copyright and license details. */
#include <ctype.h>
#include <locale.h>
+#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>
#include <unistd.h>
@@ -556,8 +557,10 @@ readstdin(void)
for (i = 0; (len = getline(&line, &linesiz, stdin)) != -1; i++) {
if (i + 1 >= itemsiz) {
itemsiz += 256;
+ if (itemsiz > SIZE_MAX / sizeof(*items))
+ die("too many items");
if (!(items = realloc(items, itemsiz * sizeof(*items))))
die("cannot realloc %zu bytes:", itemsiz *
sizeof(*items));
}
- if (line[len - 1] == '\n')
+ if (len > 0 && line[len - 1] == '\n')
line[len - 1] = '\0';
--------------------------------------------------------------------------------
Patch F8 -- Keep typed secrets out of core dumps and scrub on exit
--------------------------------------------------------------------------------
--- a/dmenu.c
+++ b/dmenu.c
@@ -1,9 +1,10 @@
/* See LICENSE file for copyright and license details. */
#include <ctype.h>
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
+#include <sys/resource.h>
#include <time.h>
#include <unistd.h>
@@ -98,7 +99,15 @@
+/* zeroing the compiler is not permitted to optimise away; a plain memset()
+ * on a buffer that is never read again is routinely elided */
+static void
+scrub(volatile char *p, size_t n)
+{
+ while (n--)
+ *p++ = '\0';
+}
+
static void
cleanup(void)
{
size_t i;
XUngrabKeyboard(dpy, CurrentTime);
+ scrub(text, sizeof text);
for (i = 0; i < SchemeLast; i++)
@@ -721,6 +730,7 @@ main(int argc, char *argv[])
int
main(int argc, char *argv[])
{
XWindowAttributes wa;
int i, fast = 0;
+ struct rlimit nocore = { 0, 0 };
for (i = 1; i < argc; i++)
@@ -767,4 +777,8 @@ main(int argc, char *argv[])
+ /* dmenu is commonly used as a passphrase prompt (passmenu): keep the
+ * contents of text[] out of core dumps */
+ setrlimit(RLIMIT_CORE, &nocore);
+
if (!setlocale(LC_CTYPE, "") || !XSupportsLocale())
fputs("warning: no locale support\n", stderr);
if (!(dpy = XOpenDisplay(NULL)))
die("cannot open display");
Incomplete by design: this does not scrub match()'s `char buf[sizeof text]`
(dmenu.c:235) or keypress()'s `char buf[64]` (dmenu.c:326), and it does not
mlock() text[] against swap. Those need a larger change; see F8.
--------------------------------------------------------------------------------
Patch F9 -- dmenu_path: drop empty PATH elements, write the cache safely
--------------------------------------------------------------------------------
--- a/dmenu_path
+++ b/dmenu_path
@@ -1,13 +1,29 @@
#!/bin/sh
cachedir="${XDG_CACHE_HOME:-"$HOME/.cache"}"
cache="$cachedir/dmenu_run"
-[ ! -e "$cachedir" ] && mkdir -p "$cachedir"
+# -e follows symlinks, so a dangling symlink skipped the mkdir entirely.
+# Create unconditionally, and not group/world writable.
+mkdir -p -m 700 "$cachedir" || exit 1
+
+# An empty element in PATH (a leading or trailing ':', or '::') means "the
+# current directory". stest would then list every executable in $PWD under its
+# bare name, and dmenu_run pipes the selection into $SHELL, which resolves that
+# bare name against $PWD too. Drop empty elements.
+cleanpath=
+oldifs=$IFS
+IFS=:
+for d in $PATH; do
+ [ -n "$d" ] || continue
+ cleanpath="${cleanpath:+$cleanpath:}$d"
+done
+IFS=$oldifs
IFS=:
-if stest -dqr -n "$cache" $PATH; then
- stest -flx $PATH | sort -u | tee "$cache"
+if stest -dqr -n "$cache" $cleanpath; then
+ # write to a fresh private temp file and rename, rather than letting
+ # tee(1) follow a symlink planted at $cache
+ tmp="$cache.$$"
+ rm -f "$tmp"
+ (umask 077; stest -flx $cleanpath | sort -u > "$tmp") || exit 1
+ mv -f "$tmp" "$cache"
+ cat "$cache"
else
cat "$cache"
fi
--------------------------------------------------------------------------------
Patch F10 -- Build hardening
--------------------------------------------------------------------------------
--- a/config.mk
+++ b/config.mk
@@ -26,4 +26,9 @@
# flags
-CPPFLAGS = -D_DEFAULT_SOURCE -D_BSD_SOURCE -D_XOPEN_SOURCE=700
-D_POSIX_C_SOURCE=200809L -DVERSION=\"$(VERSION)\" $(XINERAMAFLAGS)
-CFLAGS = -std=c99 -pedantic -Wall -Os $(INCS) $(CPPFLAGS)
-LDFLAGS = $(LIBS)
+CPPFLAGS = -D_DEFAULT_SOURCE -D_BSD_SOURCE -D_XOPEN_SOURCE=700
-D_POSIX_C_SOURCE=200809L -DVERSION=\"$(VERSION)\" $(XINERAMAFLAGS)
-D_FORTIFY_SOURCE=2
+# -fstack-clash-protection is GCC 8+/recent clang on x86 and aarch64 only;
+# drop it on other toolchains or architectures.
+HARDENFLAGS = -fstack-protector-strong -fstack-clash-protection -fPIE \
+ -Wformat -Wformat-security
+CFLAGS = -std=c99 -pedantic -Wall -Wextra -Os $(HARDENFLAGS) $(INCS)
$(CPPFLAGS)
+LDFLAGS = -Wl,-z,relro,-z,now -Wl,--as-needed -pie $(LIBS)
-Wextra will also surface the pre-existing signed/unsigned comparison at
dmenu.c:704 (`for (i = 0; i < du && dws[i] != win; ++i)`, int vs unsigned int).
Harmless in practice, but expect the warning.
================================================================================
VERIFICATION STATUS
================================================================================
Done : Manual read of all 1,557 lines of C plus headers, both shell wrappers,
Makefile and config.mk. F1 confirmed by hand-tracing the item list
through calcoffsets()/keypress() for the one-item and two-item cases.
NOT done: nothing was built or executed. This is a Windows host with no X11
toolchain, so the code was not compiled, the patches were not applied,
and the F1/F2 crashes were not reproduced against a running binary.
Before acting on this report, apply the patches on a Linux/BSD host
and confirm at minimum:
1. the two F1 repro commands segfault before the patch and do not
after it;
2. `make` is warning-clean with Patch F10 applied (modulo the
dmenu.c:704 -Wextra warning noted above);
3. normal operation is unaffected: paging with Home/End/PgUp/PgDn in
both -l and horizontal modes, a multi-byte/IM input path, and
Ctrl-y paste.
Suggested follow-up tooling: build with -fsanitize=address,undefined and drive
it with a fuzzed stdin corpus (long lines, invalid UTF-8, embedded NULs, 100k+
items) -- that path is where F6 and F7 live and where anything I missed in
drw_text()'s multi-font fallback loop would show up.
================================================================================
st - SECURITY REVIEW FINDINGS
================================================================================
Target : c:\st (suckless st terminal emulator)
Reviewed : st.c (2710 lines), x.c (2108 lines), st.h, win.h, config.def.h
Date : 2026-09-11
Method : manual static source review of the C code only.
Nothing was built, run, or fuzzed; no dependency/toolchain
analysis. The proposed patch at the end is UNCOMPILED.
Repo state : unmodified by this review (findings written to this file only).
BASELINE / PROVENANCE
This tree is upstream st 0.9.3 plus exactly three commits; "git diff
0.9.3..HEAD" is 30 lines:
0723b7e Disable bracked paste in reset (upstream)
688f70a st: guard tsetdirt() against zero-sized terminal
04ce0d6 fix async-unsafe error paths in sigchld()
All three are clean. The sigchld() rework is a genuine improvement: it
replaces die()/vfprintf() in a signal handler with _exit() and saves and
restores errno, removing async-signal-unsafety from the handler.
Every finding below is therefore a defect in upstream st, not a local
regression, and should be reported upstream to suckless.
SUMMARY
# Severity Finding Location
- -------- ---------------------------------------------------
-------------
1 HIGH Heap OOB write in tsetchar(): wide-glyph dummy cell st.c:1221
shifted into column 0 by DCH -> term.line[y][-1] st.c:1274
2 HIGH Heap OOB read in draw() -> unbounded OOB read on st.c:2690
dc.col[] via attacker-influenced colour index x.c:1404
3 HIGH ttywriteraw() re-enters ttyread(), corrupting its st.c:822
static parse buffer state; buflen can go negative st.c:876
4 MEDIUM Selection coordinates never revalidated on resize st.c:2578
-> OOB reads in getsel()/selsnap(), leakable to fd 1 st.c:584
5 LOW size_t underflow in stty() -> stack overflow of cmd st.c:748
6 LOW Unchecked XGetWMHints() NULL deref (crash on BEL) x.c:1758
Findings 1 and 2 are reachable from terminal output alone - a 12-byte
payload in anything the terminal displays (cat of an untrusted file, ssh
output, a curl response, a tailed log). Fix those first.
================================================================================
1. HIGH - Heap out-of-bounds write in tsetchar()
================================================================================
LOCATION
st.c:1221-1224 (sink) tsetchar()
st.c:1274 (root cause) tdeletechar()
CODE
st.c:1216
if (term.line[y][x].mode & ATTR_WIDE) {
if (x+1 < term.col) { <- guarded
term.line[y][x+1].u = ' ';
term.line[y][x+1].mode &= ~ATTR_WDUMMY;
}
} else if (term.line[y][x].mode & ATTR_WDUMMY) {
term.line[y][x-1].u = ' '; <- NO x > 0 guard
term.line[y][x-1].mode &= ~ATTR_WIDE;
}
MECHANISM
The missing guard is safe only under the invariant "an ATTR_WDUMMY cell
always has its ATTR_WIDE partner at x-1, therefore x >= 1". tputc()
establishes that invariant (st.c:2527-2537, which only sets ATTR_WDUMMY
at gp[1] when term.c.x+1 < term.col, so never at column 0).
tdeletechar() breaks it. It shifts the row left with no wide-glyph
fix-up:
st.c:1267
LIMIT(n, 0, term.col - term.c.x);
dst = term.c.x; src = term.c.x + n; size = term.col - src;
memmove(&line[dst], &line[src], size * sizeof(Glyph));
tclearregion(term.col-n, term.c.y, term.col-1, term.c.y);
With term.c.x == 0 the cell that lands in column 0 is whatever was at
column n - which can be an ATTR_WDUMMY. The trailing tclearregion() only
scrubs the tail of the row, so nothing resets the flag.
TRACE (term.col = 80, cursor at 0,0)
step input resulting state
1 U+4E00 wcwidth==2 -> line[0][0]=ATTR_WIDE, line[0][1]=ATTR_WDUMMY,
term.c.x=2
2 CR tcontrolcode -> tmoveto(0, y) -> term.c.x=0
3 CSI P tdeletechar(1): dst=0, src=1
-> line[0][0] is now ATTR_WDUMMY with no partner
4 A tputc: width=1, no wrap, MODE_INSERT off, 0+1 <= 80,
so tsetchar('A', attr, 0, 0) -> term.line[0][-1]
Payload (12 bytes): printf '\u4e00\r\033[PA'
IMPACT
Rows are bare xmalloc(col * sizeof(Glyph)) allocations (st.c:2625) and
Glyph is 16 bytes {Rune u; ushort mode; uint32_t fg; uint32_t bg}, so
term.line[y][-1] straddles the 16 bytes immediately before the row:
row-16 .. row-13 .u = ' ' (4-byte write)
row-12 .. row-11 .mode &= ~ATTR_WIDE (2-byte read-modify-write)
On a normal glibc heap that is the chunk header of the row allocation
(prev_size field). This is a heap-metadata corruption primitive driven
entirely by displayed output, i.e. by any untrusted data reaching the
terminal. No user interaction, no non-default configuration.
FIX
Guard the sink AND repair the invariant at the source. See patch: the
"x > 0 &&" guard in tsetchar(), plus a new tfixwide() helper called at
the shift seams in tdeletechar() and tinsertblank().
================================================================================
2. HIGH - Heap OOB read in draw() -> unbounded OOB read on dc.col[]
================================================================================
LOCATION
st.c:2688-2697 draw()
x.c:1404, x.c:1415 xdrawglyphfontspecs()
CODE
st.c:2688
LIMIT(term.ocx, 0, term.col-1);
LIMIT(term.ocy, 0, term.row-1);
if (term.line[term.ocy][term.ocx].mode & ATTR_WDUMMY)
term.ocx--; /* 0 -> -1 */
if (term.line[term.c.y][cx].mode & ATTR_WDUMMY)
cx--; /* 0 -> -1 */
...
xdrawcursor(cx, term.c.y, term.line[term.c.y][cx],
term.ocx, term.ocy, term.line[term.ocy][term.ocx]);
MECHANISM
Same root cause as finding 1 (ATTR_WDUMMY at column 0). The LIMIT() calls
run BEFORE the decrements, so they do not constrain the result: index 0
becomes -1 and a whole Glyph is read from before the row. Steps 1-3 of
the trace in finding 1 are sufficient; the 'A' is not needed.
The out-of-bounds Glyph then flows into rendering. The cursor glyph g is
sanitised (x.c:1542-1558 overwrite g.fg/g.bg with configured defaults),
but the old-cursor glyph og is NOT:
x.c:1404
fg = &dc.col[base.fg]; /* base.fg = 4 bytes of heap metadata */
x.c:1415
bg = &dc.col[base.bg];
dc.collen is 260 - LEN(colorname) is 260 because of the "[255] = 0"
designator at config.def.h:118, so MAX(260, 256) = 260. A chunk-size
field value such as 0x511 indexes roughly a thousand Color entries past
the array, and XftDrawRect() then dereferences it. IS_TRUECOL() only
diverts values with bit 24 set, so most header bit patterns take the
indexing path.
IMPACT
Wild read far outside dc.col[] -> crash, or a rendered colour derived
from out-of-bounds heap memory. Same trigger conditions as finding 1.
FIX
Move the bounds test into the conditions: "term.ocx > 0 &&" / "cx > 0 &&".
(cx is term.c.x, already within [0, term.col-1] by the tmoveto()
invariant, so the guard is purely about the decrement.)
================================================================================
3. HIGH - ttywriteraw() re-enters ttyread() and corrupts its static state
================================================================================
LOCATION
st.c:822-846 ttyread()
st.c:876-931 ttywriteraw() (nested calls at st.c:916 and st.c:925)
CODE
st.c:822
static char buf[BUFSIZ];
static int buflen = 0;
ret = read(cmdfd, buf+buflen, LEN(buf)-buflen);
...
buflen += ret;
written = twrite(buf, buflen, 0);
buflen -= written;
if (buflen > 0)
memmove(buf, buf + written, buflen);
MECHANISM
ttyread() keeps its parse buffer in statics and is not reentrant, but
ttywriteraw() calls it from inside its write loop, and twrite() reaches
ttywrite() through every query-response handler:
st.c:1660 DA (CSI c)
st.c:1793 DSR (CSI 5n)
st.c:1798 CPR (CSI 6n)
st.c:2290 DECID (0x9a)
st.c:2362 DECID (ESC Z)
st.c:1888 osc_color_response()
So the call graph contains:
ttyread -> twrite -> tputc -> csihandle -> ttywrite -> ttywriteraw
-> ttyread (nested)
Three consequences, all while the outer twrite() is still iterating buf:
a) The nested twrite(buf, buflen) restarts at index 0, RE-EXECUTING bytes
the outer frame already consumed. That duplicates output and re-emits
the same queries, so the recursion compounds rather than terminating.
b) The nested frame's memmove() shifts the buffer out from under the
outer frame's loop index.
c) The outer frame then computes "buflen -= written" against the nested
frame's already-updated buflen, so buflen can go NEGATIVE. The
"if (buflen > 0)" test skips the memmove but leaves the negative value
in the static. The next ttyread() then evaluates
read(cmdfd, buf + buflen, LEN(buf) - buflen)
with a destination BEFORE buf and a length LARGER than BUFSIZ: an
attacker-controlled out-of-bounds write into the preceding BSS.
TRIGGER
Ordinary conditions: a child that writes without draining stdin (cat, a
chatty build, a tight output loop) emitting repeated CSI c. Each query
makes st write 5 bytes into the pty that nobody reads; once the pty input
buffer fills, write() blocks, ttywriteraw() loops, cmdfd is readable, and
it recurses.
IMPACT
Stack exhaustion is the certain outcome (unbounded compounding
recursion). The buflen desync in (c) is the memory-corruption path.
FIX
A reentrancy flag so a nested ttyread() only buffers and never runs
twrite(); this also caps recursion depth at 2, removing the stack
exhaustion. A "buffer already full" early-out is required alongside it,
because read(fd, p, 0) returns 0 and st.c:834 treats 0 as EOF -> exit(0);
it returns 1 rather than 0 so that ttywriteraw()'s "lim" stays non-zero
and the write loop keeps making forward progress.
The proper long-term fix is to stop writing query responses synchronously
from inside twrite() and queue them for the run() loop instead.
Note the arithmetic stays correct with the guard: after a nested call
appends k bytes at the old offset,
buflen = (old_buflen + k) - written
is exactly the unparsed remainder plus the new bytes, contiguous at
offset "written", and buf + written + buflen == buf + old_buflen + k,
which is within bounds.
================================================================================
4. MEDIUM - Selection coordinates are never revalidated on resize
================================================================================
LOCATION
st.c:2578-2657 tresize()
st.c:584-637 getsel() (indexes term.line[y] at st.c:605, 608, 611)
st.c:512-582 selsnap() (indexes term.line[*y][*x] at st.c:524)
MECHANISM
tresize() reallocates term.line, term.alt, every row, term.dirty and
term.tabs, but touches nothing in "sel". The only thing that could clear
a selection is selclear() via the two tclearregion() calls at
st.c:2647-2652 - and when rows shrink, tclearregion(0, minrow, col-1,
row-1) has y1 > y2, gets swapped and LIMITed, and ends up clearing only
row row-1. The discarded rows are never visited. A selection anchored
below the new height, or right of the new width, survives with stale
coordinates.
TWO SINKS
a) getsel() indexes term.line[y] for y in [sel.nb.y, sel.ne.y]. With
nb.y >= term.row that reads past the reallocated Line* array and
dereferences a wild pointer - and whatever it reads is written to the
print fd. Reachable FROM THE STREAM, not just from a keybinding:
CSI 2 i -> tdumpsel() (st.c:1648), and iofd defaults to 1 (st.c:226),
so tprinter() writes to stdout unconditionally.
b) selsnap() does prevgp = &term.line[*y][*x] with *x from sel.ob.x.
After a column shrink, a double-click selection (sel.snap == SNAP_WORD)
whose ob.x exceeds the new term.col reads past the end of the
reallocated row. Reached via selscroll() -> selnormalize()
(st.c:1120) on ordinary scrolling output.
Sequence for (a): select text low in a tall window; shrink the window or
zoom the font (zoomabs -> cresize -> tresize); then any displayed output
containing CSI 2 i dumps out-of-bounds heap memory to stdout.
IMPACT
Out-of-bounds read / wild pointer dereference, with an information
disclosure path to fd 1. Requires a resize between the selection and the
trigger, hence MEDIUM rather than HIGH.
FIX
Clamp or drop the selection in tresize(). The patch drops it (selclear())
when either endpoint no longer fits, which is the conservative choice.
================================================================================
5. LOW - size_t underflow in stty() -> stack buffer overflow
================================================================================
LOCATION
st.c:737-758 stty()
CODE
st.c:739
char cmd[_POSIX_ARG_MAX], **p, *q, *s;
size_t n, siz;
...
siz = sizeof(cmd) - n;
for (p = args; p && (s = *p); ++p) {
if ((n = strlen(s)) > siz-1) <- siz is size_t
die("stty parameter length too long");
*q++ = ' ';
memcpy(q, s, n);
q += n;
siz -= n + 1;
}
*q = 0;
MECHANISM
The test admits n == siz-1, which drives siz to exactly 0. Bytes written
are then exactly sizeof(cmd) and q == cmd + sizeof(cmd), so the final
NUL store is already a one-byte overflow. With one more argument, siz-1
wraps to SIZE_MAX, the guard can never fire, and the memcpy() runs
unbounded past cmd. (Invariant: bytes written == sizeof(cmd) - siz.)
IMPACT
Stack buffer overflow, but reachable only via "st -l <line> <stty_args>",
so the input is the invoking user's own command line. st is not setuid,
so no privilege boundary is crossed - genuine bug, low severity.
FIX
Test "n + 1 >= siz", reserving both the separator and the trailing NUL.
================================================================================
6. LOW - Unchecked XGetWMHints() NULL dereference
================================================================================
LOCATION
x.c:1756-1763 xseturgency()
CODE
XWMHints *h = XGetWMHints(xw.dpy, xw.win);
MODBIT(h->flags, add, XUrgencyHint);
MECHANISM
XGetWMHints() returns NULL when the window has no WM_HINTS property.
xbell() calls xseturgency(1) whenever the window is unfocused (x.c:1768),
so a bare BEL in the output stream dereferences NULL.
IMPACT
Remote-triggerable crash (denial of service). No memory corruption.
FIX
NULL check with an XAllocWMHints() fallback.
================================================================================
LOWER-PRIORITY NOTES (no patch proposed)
================================================================================
N1 base64dec() silently accepts invalid input - st.c:361
base64_digits[] maps every unlisted byte to 0, indistinguishable from a
legitimate 'A', so corrupt data is decoded rather than rejected. The
ALLOCATION math is sound: in_len/4*3+1 exactly covers the worst case,
because base64dec_getc() consumes at least four source bytes per three
output bytes and non-printable skipping only reduces output. Reachable
only with allowwindowops enabled, which is 0 by default
(config.def.h:48).
N2 getsel() bufsize is int - st.c:594
(term.col+1) * (sel.ne.y-sel.nb.y+1) * UTF_SIZ overflows above roughly
5e8 cells. Not reachable through any real window geometry; widening to
size_t is free hardening.
N3 tdumpline() forms &bp[-1] - st.c:2117
When tlinelen() returns 0, end = &bp[-1]. Technically UB; benign in
practice because the loop guard rejects it immediately.
N4 "width" is left uninitialised in tputc() - st.c:2404-2411
When u >= 127, MODE_UTF8 is set and ISCONTROL(u) is true (e.g. u ==
0x7f), the else branch never assigns width. Currently dead: every path
that reads width is guarded by control == 0. One refactor away from
being live.
N5 tabspaces == 0 in config.h would hang st - st.c:1030, st.c:2634
"i += tabspaces" and "bp += tabspaces" loop forever. Configuration
robustness only; not attacker-controlled.
================================================================================
WHAT IS ALREADY HARDENED (verified present, no action needed)
================================================================================
* kpress(): the long-composed-input overflow fix is present, including the
XBufferOverflow check (x.c:1855-1861, upstream e5e9598).
* CSI b (REP) argument is capped at 65535 (st.c:1663, upstream 95f22c5).
* dc.col bounds checks in xgetcolor() and xsetcolorname() are present
(x.c:821, x.c:836, upstream a6bbc0c); dc.collen == 260 covers every
index tsetattr()/tdefcolor() can produce (indexed <= 255, truecolour
diverted by bit 24, defaults 256-259).
* csiparse()/strparse() respect their ESC_ARG_SIZ / STR_ARG_SIZ caps
(st.c:1163, st.c:2018).
* csiescseq.buf and the growable strescseq.buf both correctly reserve room
for the terminating NUL written by csiparse()/strparse()
(st.c:2474-2477 caps len at sizeof(buf)-1; st.c:2430 grows when
len+len >= siz, with a SIZE_MAX guard at st.c:2444).
* tclearregion(), tmoveto(), tsetscroll(), tscrollup(), tscrolldown(),
tinsertblank(), tdeletechar() and tputtab() all LIMIT() every
attacker-supplied CSI argument, including negatives.
* ttyread()'s UTF-8 continuation handling cannot stall: utf8decode()
returns 0 only when clen <= 3, so a full buffer always makes progress.
* OSC 52 clipboard writes are disabled by default (allowwindowops = 0).
* sigchld() is async-signal-safe after commit 04ce0d6.
================================================================================
PROPOSED PATCH
================================================================================
Covers findings 1-6. Extract the diff below to a file and apply from the
repository root:
git apply -p1 st-security-fixes.patch
STATUS: verified to apply cleanly to this tree with "git apply --check -p1".
NOT COMPILED and NOT TESTED - no toolchain analysis was in scope for this
review. Review and build before committing. Brace balance was checked
(st.c 300 -> 305 pairs, x.c unchanged).
--------------------------------------------------------------------------------
--- a/st.c
+++ b/st.c
@@ -176,6 +176,7 @@
static void tcursor(int);
static void tdeletechar(int);
static void tdeleteline(int);
+static void tfixwide(int, int);
static void tinsertblank(int);
static void tinsertblankline(int);
static int tlinelen(int);
@@ -745,7 +746,12 @@
q = cmd + n;
siz = sizeof(cmd) - n;
for (p = args; p && (s = *p); ++p) {
- if ((n = strlen(s)) > siz-1)
+ /*
+ * siz is unsigned, and "n > siz-1" admits n == siz-1, which
+ * drives siz to 0 so the next iteration compares against
+ * SIZE_MAX. Reserve the separator and the trailing NUL.
+ */
+ if ((n = strlen(s)) + 1 >= siz)
die("stty parameter length too long\n");
*q++ = ' ';
memcpy(q, s, n);
@@ -824,8 +830,22 @@
{
static char buf[BUFSIZ];
static int buflen = 0;
+ static int parsing = 0;
int ret, written;
+ /*
+ * ttywriteraw() re-enters ttyread() while twrite() is still on the
+ * stack: a DA/DSR/OSC-colour response fills the pty, write() blocks,
+ * and the drain path calls back in here. buf and buflen are static,
+ * so a nested twrite() replays bytes the outer frame has already
+ * consumed and desynchronises buflen -- it can end up negative, which
+ * turns the read() below into an out-of-bounds write before buf, and
+ * the replay makes the recursion compound instead of terminating.
+ * A nested call therefore only buffers; the outer frame parses.
+ */
+ if (buflen >= (int)LEN(buf))
+ return 1; /* full: keep ttywriteraw() progressing */
+
/* append read bytes to unprocessed bytes */
ret = read(cmdfd, buf+buflen, LEN(buf)-buflen);
@@ -836,11 +856,15 @@
die("couldn't read from shell: %s\n", strerror(errno));
default:
buflen += ret;
+ if (parsing)
+ return ret;
+ parsing = 1;
written = twrite(buf, buflen, 0);
buflen -= written;
/* keep any incomplete UTF-8 byte sequence for the next call */
if (buflen > 0)
memmove(buf, buf + written, buflen);
+ parsing = 0;
return ret;
}
}
@@ -1218,7 +1242,7 @@
term.line[y][x+1].u = ' ';
term.line[y][x+1].mode &= ~ATTR_WDUMMY;
}
- } else if (term.line[y][x].mode & ATTR_WDUMMY) {
+ } else if (x > 0 && (term.line[y][x].mode & ATTR_WDUMMY)) {
term.line[y][x-1].u = ' ';
term.line[y][x-1].mode &= ~ATTR_WIDE;
}
@@ -1258,6 +1282,37 @@
}
}
+/*
+ * A wide glyph occupies two cells: ATTR_WIDE at x and ATTR_WDUMMY at x+1.
+ * Shifting a row horizontally can separate the halves, and an ATTR_WDUMMY
+ * cell with no ATTR_WIDE partner at x-1 breaks the invariant that
+ * tsetchar() and draw() rely on -- at x == 0 both of them then index
+ * term.line[y][-1]. Turn any orphaned half back into a blank.
+ */
+void
+tfixwide(int y, int x)
+{
+ Glyph *line;
+
+ if (!BETWEEN(x, 0, term.col - 1))
+ return;
+
+ line = term.line[y];
+ if (line[x].mode & ATTR_WDUMMY) {
+ if (x == 0 || !(line[x-1].mode & ATTR_WIDE)) {
+ line[x].u = ' ';
+ line[x].mode &= ~ATTR_WDUMMY;
+ term.dirty[y] = 1;
+ }
+ } else if (line[x].mode & ATTR_WIDE) {
+ if (x + 1 >= term.col || !(line[x+1].mode & ATTR_WDUMMY)) {
+ line[x].u = ' ';
+ line[x].mode &= ~ATTR_WIDE;
+ term.dirty[y] = 1;
+ }
+ }
+}
+
void
tdeletechar(int n)
{
@@ -1273,6 +1328,10 @@
memmove(&line[dst], &line[src], size * sizeof(Glyph));
tclearregion(term.col-n, term.c.y, term.col-1, term.c.y);
+ /* the shift can strand one half of a wide glyph at the seams */
+ tfixwide(term.c.y, dst - 1);
+ tfixwide(term.c.y, dst);
+ tfixwide(term.c.y, term.col - n - 1);
}
void
@@ -1290,6 +1349,10 @@
memmove(&line[dst], &line[src], size * sizeof(Glyph));
tclearregion(src, term.c.y, dst - 1, term.c.y);
+ /* the shift can strand one half of a wide glyph at the seams */
+ tfixwide(term.c.y, src - 1);
+ tfixwide(term.c.y, dst);
+ tfixwide(term.c.y, term.col - 1);
}
void
@@ -2637,6 +2700,17 @@
/* update terminal size */
term.col = col;
term.row = row;
+ /*
+ * The selection survives a resize, but the rows and the Line array
+ * it indexes have just been reallocated and nothing else
+ * revalidates it -- the tclearregion() calls below never visit the
+ * discarded rows. Drop a selection that no longer fits instead of
+ * letting getsel()/selsnap() index out of bounds later.
+ */
+ if (sel.ob.x != -1 &&
+ (!BETWEEN(sel.ob.x, 0, col-1) || !BETWEEN(sel.ob.y, 0, row-1) ||
+ !BETWEEN(sel.oe.x, 0, col-1) || !BETWEEN(sel.oe.y, 0, row-1)))
+ selclear();
/* reset scrolling region */
tsetscroll(0, row-1);
/* make use of the LIMIT in tmoveto */
@@ -2687,9 +2761,9 @@
/* adjust cursor position */
LIMIT(term.ocx, 0, term.col-1);
LIMIT(term.ocy, 0, term.row-1);
- if (term.line[term.ocy][term.ocx].mode & ATTR_WDUMMY)
+ if (term.ocx > 0 && (term.line[term.ocy][term.ocx].mode & ATTR_WDUMMY))
term.ocx--;
- if (term.line[term.c.y][cx].mode & ATTR_WDUMMY)
+ if (cx > 0 && (term.line[term.c.y][cx].mode & ATTR_WDUMMY))
cx--;
drawregion(0, 0, term.col, term.row);
--- a/x.c
+++ b/x.c
@@ -1757,6 +1757,10 @@
{
XWMHints *h = XGetWMHints(xw.dpy, xw.win);
+ /* no WM_HINTS property set yet -> XGetWMHints() returns NULL */
+ if (h == NULL && (h = XAllocWMHints()) == NULL)
+ return;
+
MODBIT(h->flags, add, XUrgencyHint);
XSetWMHints(xw.dpy, xw.win, h);
XFree(h);
--------------------------------------------------------------------------------
end of findings
--
Kind regards,
Hiltjo