Skip to content

Commit a0a56a4

Browse files
authored
Fix memleak invoking functions with trap set in parent scope (#948)
Vincent Mihalkovič (@vmihalko) from Red Hat reports: > ksh leaks memory when running a loop that combines trap and > command substitution. Without the trap or command substitution, > the loop does not leak. The leak is reproducible on upstream > HEAD. > > ### Reproducer > > trap exit INT > > n=10000 > while [ "$n" -gt 0 ]; do > value=`echo a` > n=$((n - 1)) > done > exit 0 > > Steps: > > 1. Save as e.g. `reproducer_limited.ksh` and make executable: > chmod +x reproducer_limited.ksh > 2. Run under valgrind: > valgrind --leak-check=full -q /path/to/ksh \ > reproducer_limited.ksh > 3. Inspect valgrind output for the LEAK SUMMARY and > "definitely lost" lines. > > Valgrind reports "definitely lost" blocks from the > trap/command-substitution path. Example (upstream HEAD): > > * 49,995 bytes in 9,999 blocks are definitely lost > * Stack points to: malloc → _ast_strdup → sh_strdup (init.c) > → sh_subshell (subshell.c) → comsubst (macro.c) → … > > Without the 'trap exit INT' line, the same loop does not show > "definitely lost" (valgrind is clean for that path); the trap is > required to trigger the leak. > > ### References > > * Downstream: RHEL-19580 – ksh leak with traps > https://issues.redhat.com/browse/RHEL-19580 > (Red Hat Jira). Affects: rhel-7.9, rhel-8.8.0, rhel-9.4. > * Related: > - Bugzilla 1460940 > https://bugzilla.redhat.com/show_bug.cgi?id=1460940 > - Bugzilla 1117404 > https://bugzilla.redhat.com/show_bug.cgi?id=1117404 > (trap/crash context). Analysis: The Red Hat patch backported back in 2020, in 6193c6a, is not correct. The original 93u+ code mostly made sense; there is no reason why saved trap action pointers should change in subshells or ksh function invocations, because we're creating a new scope and any changes in traps apply to the new scope only, so the original pointers should never be invalidated. Any such occurrence is a bug. The Red Hat patch doesn't actually fix any such bug; it papers over it at the expense of introducing a memory leak problem. So we have to revert it, then figure out what the actual bug is and find a proper fix for that bug. src/cmd/ksh93/sh/subshell.c, src/cmd/ksh93/sh/xec.c: - Mostly revert to the 93u+ method for saving/restoring trap table, which simply copies over the array of pointers. - Avoid reintroducing the off-by-one error that was fixed in 3aee10d and which carried over into the Red Hat patch. - sh_subshell(): Allocate the buffer for saving the trap action pointers on the AST stack, which is automatically reset appropriately at the end of every recursive sh_exec() run, which removes the need to free the buffer manually while guaranteeing the buffer stays available while we need it. (Note that sh_funscope() was already using this method in 93u+.) With these changes applied, the memory leak problem disappears, and the regression tests appear to pass normally. However, when compiling with AddressSanitizer, the patch fails one of the two regression tests that were introduced by referenced commit, the one that tests a ksh function. This is not unexpected. The failing test is at src/cmd/ksh93/tests/functions.sh, lines 1368-1383. (The other one, at subshell.sh lines 932-943, passes, because as of 3d1a472, ksh always forks a subshell when it traps a signal, which circumvents the bug.) The regression is caused by a use after free. The offending free call is in b_trap(), line 180. That code is essentially unchanged since 93u+. This use after free appears to be the very problem that the Red Hat patch from the referenced commit works around. So we need a new and correct solution for that use after free. It occurs when a trap for a signal is set or ignored in the parent environment and is then set again in a ksh function. The function then needs to be called at twice for the use after free to occur. Reproducer: trap "" HUP function ksh_fun { trap ": foo" HUP trap ": bar" HUP } ksh_fun ksh_fun A simplistic fix would be to avoid the free call at trap.c:180 if we're currently inside a ksh function. But that would introduce a memory leak when the same signal is trapped locally more than once in the same ksh function. The challenge is this: The first time a trap is set in a ksh function scope, the parent trap should not be freed, because it will be restored. However, the second and subsequent times, it should be freed, because it's overwriting the first one. The following changes make that happen. src/cmd/ksh93/include/shell.h, src/cmd/ksh93/bltins/trap.c, src/cmd/ksh93/sh/subshell.c, src/cmd/ksh93/sh/xec.c: - Introduce a sh.st.trapnofree bitmask array, stored in the current scope sh.st, which contains a bit for every possible signal. By default, all those bits are zero and traps are freed as normal. - Use the NSIG macro (which indicates the number of signals supported by the system; non-standard but available on all major systems), to determine the size of the bitmask array if possible. - sh_subshell(), sh_funscope(), sh_exec(): When saving the array of trap action pointers, set all trapnofree bits to one. - b_trap(): Whenever the trap built-in would free a previous (potentially parent) trap, skip the free call if the corresponding bit is set, but then (whether a free was actually executed or not) clear that bit, so that if the same signal trap is overwritten within the same scope, the free is executed as normal. This fixes the use after free while avoiding the introduction of another memory leak. While we're here, let's fix another, more minor problem: the shell unnecessarily forks when setting an EXIT trap, as EXIT is the one pseudosignal that is handled in the same array as the signal traps. src/cmd/ksh93/bltins/trap.c: b_trap(): - Since EXIT == 0, check for sig > 0 before forking. (re: 3d1a472) Resolves: #947
1 parent ae5a6ff commit a0a56a4

7 files changed

Lines changed: 68 additions & 81 deletions

File tree

‎NEWS‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@ This documents significant changes in the dev branch of ksh 93u+m.
22
For full details, see the git log at: https://github.com/ksh93/ksh
33
Uppercase BUG_* IDs are shell bug IDs as used by the Modernish shell library.
44

5+
2026-03-20:
6+
7+
- Fixed a memory leak that occurred when any subshell or ksh function with
8+
'function name' syntax is invoked while at least one signal or EXIT trap
9+
is set in the invoking environment.
10+
11+
- Fixed an issue, introduced on 2022-06-13, causing a subshell to fork
12+
unnecessarily if an EXIT pseudosignal trap is set within it.
13+
514
2026-03-16:
615

716
- Fixed a bug in the join(1) built-in command (bound to /opt/ast/bin) that

‎src/cmd/ksh93/bltins/trap.c‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,20 +164,25 @@ int b_trap(int argc,char *argv[],Shbltin_t *context)
164164
}
165165
else
166166
{
167+
const int index = sig / 8;
168+
const uint8_t sigbit = (uint8_t)1 << sig % 8;
167169
/*
168-
* Trap or ignore a real signal. A virtual subshell needs to fork in
169-
* order to receive signals correctly and (because other commands
170+
* Trap or ignore EXIT (0) or a signal. A virtual subshell must fork
171+
* in order to receive signals correctly and (because other commands
170172
* may cause a virtual subshell to fork) to ensure a persistent PID.
171173
*/
172-
if(sh.subshell && !sh.subshare)
174+
if(sig > 0 && sh.subshell && !sh.subshare)
173175
sh_subfork();
174176
if(sig >= sh.st.trapmax)
175177
sh.st.trapmax = sig+1;
176178
arg = sh.st.trapcom[sig];
177179
sh_sigtrap(sig);
178180
sh.st.trapcom[sig] = (sh.sigflag[sig]&SH_SIGOFF) ? Empty : sh_strdup(action);
179-
if(arg && arg != Empty)
181+
/* free unless nofree bit is set */
182+
if(arg && arg != Empty && !(sh.st.trapnofree[index] & sigbit))
180183
free(arg);
184+
/* clear nofree bit to avoid memory leak if trap is overwritten in same scope */
185+
sh.st.trapnofree[index] &= ~sigbit;
181186
}
182187
}
183188
/*

‎src/cmd/ksh93/include/shell.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ typedef struct sh_scope
194194

195195
#if _BLD_ksh
196196

197+
#if NSIG
198+
#define KSH_NSIG (NSIG)
199+
#else
200+
#define KSH_NSIG 128 /* no UN*X system is currently known to have more than 128 signals */
201+
#endif
202+
197203
/* Private interface to shell scope. The first members must match the public interface. */
198204
struct sh_scoped
199205
{
@@ -225,6 +231,7 @@ struct sh_scoped
225231
char **otrapcom; /* save parent EXIT and signals for v=$(trap) */
226232
void *timetrap; /* for the 'alarm' built-in */
227233
struct Ufunction *real_fun; /* current 'function name' function */
234+
uint8_t trapnofree[(KSH_NSIG+7)/8]; /* bitmask to stop b_trap() freeing trapcom entries */
228235
};
229236

230237
struct limits

‎src/cmd/ksh93/include/version.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
#include
1919
#include "git.h"
2020

21-
#define SH_RELEASE_DATE "2026-03-16" /* must be in this format for $((.sh.version)) */
21+
#define SH_RELEASE_DATE "2026-03-20" /* must be in this format for $((.sh.version)) */
2222
/*
2323
* This comment keeps SH_RELEASE_DATE a few lines away from SH_RELEASE_SVER to avoid
2424
* merge conflicts when cherry-picking dev branch commits onto a release branch.

‎src/cmd/ksh93/sh/subshell.c‎

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -525,11 +525,12 @@ Sfio_t *sh_subshell(Shnode_t *t, volatile int flags, int comsub)
525525
{
526526
struct subshell sub_data;
527527
struct subshell *sp = &sub_data;
528-
int jmpval,isig,nsig=0,fatalerror=0,saveerrno=0;
528+
int n, jmpval, fatalerror = 0, saveerrno = 0;
529529
unsigned int savecurenv = sh.curenv;
530530
int savejobpgid = job.curpgid;
531531
int *saveexitval = job.exitval;
532532
char **savsig;
533+
size_t nsig = 0;
533534
Sfio_t *iop=0;
534535
struct checkpt checkpoint;
535536
struct sh_scoped savst;
@@ -597,7 +598,7 @@ Sfio_t *sh_subshell(Shnode_t *t, volatile int flags, int comsub)
597598
}
598599
if(sp->pwdfd<0)
599600
{
600-
int n = sh_open(e_dot,O_SEARCH|O_cloexec);
601+
n = sh_open(e_dot,O_SEARCH|O_cloexec);
601602
if(n>=0)
602603
{
603604
sp->pwdfd = n;
@@ -618,33 +619,17 @@ Sfio_t *sh_subshell(Shnode_t *t, volatile int flags, int comsub)
618619
#endif /* _lib_openat */
619620
sp->mask = sh.mask;
620621
sh_stats(STAT_SUBSHELL);
621-
/* save trap table */
622-
sh.st.otrapcom = 0;
623-
sh.st.otrap = savst.trap;
624-
if((nsig=sh.st.trapmax)>0 || sh.st.trapcom[0])
625-
{
626-
savsig = sh_malloc(nsig * sizeof(char*));
627-
/*
628-
* the data is, usually, modified in code like:
629-
* tmp = buf[i]; buf[i] = sh_strdup(tmp); free(tmp);
630-
* so sh.st.trapcom needs a "deep copy" to properly save/restore pointers.
631-
*/
632-
for (isig = 0; isig < nsig; ++isig)
633-
{
634-
if(sh.st.trapcom[isig] == Empty)
635-
savsig[isig] = Empty;
636-
else if(sh.st.trapcom[isig])
637-
savsig[isig] = sh_strdup(sh.st.trapcom[isig]);
638-
else
639-
savsig[isig] = NULL;
640-
}
641-
/* this is needed for var=$(trap) */
642-
sh.st.otrapcom = (char**)savsig;
643-
}
644622
sp->cpid = sh.cpid;
645623
sp->coutpipe = sh.coutpipe;
646624
sp->cpipe = sh.cpipe[1];
647625
sh.cpid = 0;
626+
/* save trap table */
627+
memset(sh.st.trapnofree, 0xFF, sizeof sh.st.trapnofree);
628+
sh.st.otrap = savst.trap;
629+
if((nsig = sh.st.trapmax * sizeof(char**)) > 0)
630+
savsig = sh.st.otrapcom = memcpy(stkalloc(sh.stk, nsig), sh.st.trapcom, nsig);
631+
else
632+
sh.st.otrapcom = NULL;
648633
if(sh_isoption(SH_FUNCTRACE) && sh.st.trap[SH_DEBUGTRAP] && *sh.st.trap[SH_DEBUGTRAP])
649634
save_debugtrap = sh_strdup(sh.st.trap[SH_DEBUGTRAP]);
650635
sh_sigreset(0);
@@ -805,7 +790,6 @@ Sfio_t *sh_subshell(Shnode_t *t, volatile int flags, int comsub)
805790
sh.bckpid = sp->bckpid;
806791
if(!sh.subshare) /* restore environment if saved */
807792
{
808-
int n;
809793
struct rand *rp;
810794
sh.options = sp->options;
811795
/* Clean up subshell hash table. */
@@ -862,13 +846,7 @@ Sfio_t *sh_subshell(Shnode_t *t, volatile int flags, int comsub)
862846
sh.st = savst;
863847
sh.st.otrap = 0;
864848
if(nsig)
865-
{
866-
for (isig = 0; isig < nsig; ++isig)
867-
if (sh.st.trapcom[isig] && sh.st.trapcom[isig]!=Empty)
868-
free(sh.st.trapcom[isig]);
869-
memcpy((char*)&sh.st.trapcom[0],savsig,nsig*sizeof(char*));
870-
free(savsig);
871-
}
849+
memcpy(sh.st.trapcom, savsig, nsig);
872850
sh.options = sp->options;
873851
#if _lib_openat
874852
if(sh.pwdfd != sp->pwdfd)
@@ -957,10 +935,10 @@ Sfio_t *sh_subshell(Shnode_t *t, volatile int flags, int comsub)
957935
}
958936
sh_sigcheck();
959937
sh.trapnote = 0;
960-
nsig = sh.savesig;
938+
n = sh.savesig;
961939
sh.savesig = 0;
962-
if(nsig>0)
963-
kill(sh.current_pid,nsig);
940+
if(n > 0)
941+
kill(sh.current_pid, n);
964942
if(sp->subpid)
965943
job_wait(sp->subpid);
966944
sh.comsub = sp->comsub;

‎src/cmd/ksh93/sh/xec.c‎

Lines changed: 18 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,22 +1718,21 @@ int sh_exec(const Shnode_t *t, int flags)
17181718
flags &= ~ARG_OPTIMIZE;
17191719
if(!sh.subshell && !sh.st.trapdontexec && (flags&sh_state(SH_NOFORK)))
17201720
{
1721-
/* This is the last command, so avoid creating a subshell */
1722-
char *savsig;
1723-
int nsig,jmpval;
1721+
/* This is the last command, so avoid creating a subshell, but still act like one */
1722+
size_t nsig;
1723+
int jmpval;
17241724
struct checkpt *buffp = stkalloc(sh.stk,sizeof(struct checkpt));
1725-
sh.st.otrapcom = 0;
1726-
if((nsig=sh.st.trapmax*sizeof(char*))>0 || sh.st.trapcom[0])
1727-
{
1728-
nsig += sizeof(char*);
1729-
savsig = sh_malloc(nsig);
1730-
memcpy(savsig,(char*)&sh.st.trapcom[0],nsig);
1731-
sh.st.otrapcom = (char**)savsig;
1732-
}
1733-
/* Still act like a subshell: reseed $RANDOM and increment ${.sh.subshell} */
1725+
/* Save traps for printing, then reset them */
1726+
memset(sh.st.trapnofree, 0xFF, sizeof sh.st.trapnofree);
1727+
if ((nsig = sh.st.trapmax * sizeof(char*)) > 0)
1728+
sh.st.otrapcom = memcpy(stkalloc(sh.stk, nsig), sh.st.trapcom, nsig);
1729+
else
1730+
sh.st.otrapcom = NULL;
1731+
sh_sigreset(0);
1732+
/* Reseed $RANDOM and increment ${.sh.subshell} */
17341733
sh_invalidate_rand_seed();
17351734
sh.realsubshell++;
1736-
sh_sigreset(0);
1735+
/* Execute the last command and exit normally, except for SH_JMPSCRIPT */
17371736
sh_pushcontext(buffp,SH_JMPEXIT);
17381737
jmpval = sigsetjmp(buffp->buff,0);
17391738
if(jmpval==0)
@@ -2900,16 +2899,16 @@ Sfdouble_t sh_mathfun(void *fp, int nargs, Sfdouble_t *arg)
29002899
int sh_funscope(int argn, char *argv[],int(*fun)(void*),void *arg,int execflg)
29012900
{
29022901
char *trap;
2903-
int nsig;
29042902
struct dolnod *argsav=0,*saveargfor;
29052903
struct sh_scoped *savst = stkalloc(sh.stk,sizeof(struct sh_scoped));
29062904
struct sh_scoped *prevscope = sh.st.self;
29072905
struct argnod *envlist=0;
2908-
int isig,jmpval;
2906+
int jmpval;
29092907
volatile int r = 0;
29102908
int posix_fun = 0, save_loopcnt = sh.st.loopcnt;
29112909
char save_invoc_local;
29122910
char **savsig;
2911+
size_t nsig;
29132912
struct funenv *fp = 0;
29142913
struct checkpt *buffp = stkalloc(sh.stk,sizeof(struct checkpt));
29152914
Namval_t *nspace = sh.namespace;
@@ -2987,24 +2986,9 @@ int sh_funscope(int argn, char *argv[],int(*fun)(void*),void *arg,int execflg)
29872986
}
29882987
sh.st.cmdname = argv[0];
29892988
/* save trap table */
2990-
if((nsig=sh.st.trapmax)>0 || sh.st.trapcom[0])
2991-
{
2992-
savsig = sh_malloc((size_t)nsig * sizeof(char*));
2993-
/*
2994-
* the data is, usually, modified in code like:
2995-
* tmp = buf[i]; buf[i] = sh_strdup(tmp); free(tmp);
2996-
* so sh.st.trapcom needs a "deep copy" to properly save/restore pointers.
2997-
*/
2998-
for (isig = 0; isig < nsig; ++isig)
2999-
{
3000-
if(sh.st.trapcom[isig] == Empty)
3001-
savsig[isig] = Empty;
3002-
else if(sh.st.trapcom[isig])
3003-
savsig[isig] = sh_strdup(sh.st.trapcom[isig]);
3004-
else
3005-
savsig[isig] = NULL;
3006-
}
3007-
}
2989+
memset(sh.st.trapnofree, 0xFF, sizeof sh.st.trapnofree);
2990+
if((nsig = sh.st.trapmax * sizeof(char**)) > 0)
2991+
savsig = memcpy(stkalloc(sh.stk, nsig), sh.st.trapcom, nsig);
30082992
if(!fun && sh_isoption(SH_FUNCTRACE) && sh.st.trap[SH_DEBUGTRAP] && *sh.st.trap[SH_DEBUGTRAP])
30092993
save_debugtrap = sh_strdup(sh.st.trap[SH_DEBUGTRAP]);
30102994
sh_sigreset(-1);
@@ -3119,13 +3103,7 @@ int sh_funscope(int argn, char *argv[],int(*fun)(void*),void *arg,int execflg)
31193103
sh.topscope = (Shscope_t*)prevscope;
31203104
nv_getval(sh_scoped(IFSNOD));
31213105
if(nsig)
3122-
{
3123-
for (isig = 0; isig < nsig; ++isig)
3124-
if (sh.st.trapcom[isig] && sh.st.trapcom[isig]!=Empty)
3125-
free(sh.st.trapcom[isig]);
3126-
memcpy((char*)&sh.st.trapcom[0],savsig,nsig*sizeof(char*));
3127-
free(savsig);
3128-
}
3106+
memcpy(sh.st.trapcom, savsig, nsig);
31293107
sh.trapnote=0;
31303108
sh.options = save_options;
31313109
sh.last_root = last_root;

‎src/cmd/ksh93/tests/leaks.sh‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,5 +456,15 @@ DO
456456
command -px true
457457
DONE
458458

459+
# ======
460+
TEST title='trap and command substitution'
461+
trap ': long-enough trap action to detect the leak' USR1
462+
DO
463+
v=`echo a`
464+
v=$(echo a)
465+
(echo a)
466+
DONE >/dev/null
467+
trap - USR1
468+
459469
# ======
460470
exit $((Errors<125?Errors:125))

0 commit comments

Comments
 (0)