harden: fix five memory-safety/DoS bugs in database load paths

Audit of the DB load paths (flatfile reader, mail/malias loader, SQLite
attribute bulk-load) under the malicious/corrupt-database threat model --
the same class as #834-843. Five distinct bugs, all in code the June
hardening (#806/#808/#841/#843) did not reach; each is reachable only from
crafted or tampered database content, not from normal gameplay. Verified by
full rebuild + smoke suite (1264/1264, 0 crashes), which exercises flatfile
export/import and SQLite attribute reads end to end.

1. getstring_noalloc: static buffer overflow (lib/dbutil.cpp)
   The escaped-string reader mis-accumulated its output-byte count:
   `nOutput = pOutput - p` overwrote the count from prior escape emits
   instead of adding to it. On the multi-fgets refill path this
   under-decrements nBufferLeft, so the `nBufferLeft <= 0` guard never trips
   and the next fgets writes past the 2*LBUF_SIZE+20 static buffer.
   Legitimate attributes (<= LBUF) never take the refill path, so this only
   fires on a crafted quoted string > ~64KB. Fix: accumulate (+=).

2. make_numlist / malias_read: stack buffer overflow (modules/engine/mail.cpp)
   malias_read read the recipient count (numrecep) straight from the file
   and pushed that many dbrefs into m->list with no cap, making
   m->list.size() attacker-controlled. make_numlist then copied all of
   m->list into the fixed stack array aRecip[(LBUF_SIZE+1)/2] with no bound
   on nRecip -- a crafted mail.db with numrecep > 16384 overflows the stack
   (with attacker-chosen dbrefs) the next time any player mails the alias.
   Fix: clamp numrecep to (LBUF_SIZE+1)/2 at load (also bounds the reserve()
   that could otherwise exhaust memory on an INT_MAX count), and defensively
   bound the copy loop in make_numlist.

3. SQLite bulk-load attribute value: heap buffer overflow (modules/engine/sqlitedb.cpp)
   GetAllAttributes/GetBuiltinAttributes passed the raw column blob length to
   the cache with no clamp, unlike the write path (cache_put) and the
   standalone read path (GetAttribute), both of which clamp to LBUF_SIZE. A
   value blob written directly into the SQLite file therefore flows unclamped
   to atr_get_str_LEN's `memcpy(s, buff, (*pLen)+1)` into a fixed LBUF_SIZE
   buffer, overflowing the heap on first read of the attribute
   (Name/look/examine/get). Fix: clamp len to LBUF_SIZE in both bulk-load
   functions, mirroring the existing clamps.

4. get_list: infinite loop + unbounded log on truncated flatfile (modules/engine/db_rw.cpp)
   get_list had no EOF case: at end-of-file getc() returns EOF, falls to
   default, and calls getstring_noalloc(), which makes no progress at EOF
   (ungetc(EOF) is a no-op, fgets returns NULL). The for(;;) then spins
   forever, pegging a core and emitting log lines. Trigger: a flatfile whose
   last object's attribute list is truncated before its '<' terminator. Fix:
   add a case EOF that aborts the load.

5. getboolexp1: BOOLEXP subtree leak on malformed v2 lock (modules/engine/db_rw.cpp)
   Three error paths in the v2 lock parser returned TRUE_BOOLEXP without
   freeing the partially built node/subtree (bad connective, missing ')',
   EOF mid lock-string). Bounded and single-shot (db_read aborts the whole
   load on corruption; nesting capped at 1024), but still a leak on crafted
   input. Fix: free the appropriate node/subtree at each site, matching the
   partial-construction state.

Also traced and dropped a sixth candidate (SQLite attribute owner read
unvalidated): the attribute owner is never used to index db[] -- only
compared, read for its flags, or re-stored -- so a corrupt owner is harmless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stephen Dennis 2026-07-07 19:52:53 -06:00
parent 94d278f553
commit a056f67d0a
4 changed files with 60 additions and 2 deletions

View file

@ -224,7 +224,7 @@ void *getstring_noalloc(FILE *f, bool new_strings, size_t *pnBuffer)
*pOutput++ = ch;
ch = *pInput++;
} while (decode_table[static_cast<unsigned char>(ch)] == 0);
nOutput = pOutput - p;
nOutput += pOutput - p;
}
}
int iAction = action_table[iState][decode_table[static_cast<unsigned char>(ch)]];

View file

@ -293,6 +293,12 @@ static BOOLEXP *getboolexp1(FILE *f, int depth)
break;
default:
// Bad connective: b->sub1 (built above) and the node leak
// otherwise. b->type is not yet set, so free sub1 explicitly
// rather than via free_boolexp(b).
//
free_boolexp(b->sub1);
free_bool(b);
goto error;
}
b->sub2 = getboolexp1(f, depth + 1);
@ -304,6 +310,10 @@ static BOOLEXP *getboolexp1(FILE *f, int depth)
}
if (')' != d)
{
// Missing close paren: b->type and its subtree(s) are fully set
// here, so free_boolexp() unwinds them correctly.
//
free_boolexp(b);
goto error;
}
return b;
@ -395,6 +405,10 @@ static BOOLEXP *getboolexp1(FILE *f, int depth)
if (EOF == c)
{
// EOF mid lock-string: b->sub1 not yet allocated (StringClone
// below is unreached), so free only the node.
//
free_bool(b);
goto error;
}
*s = '\0';
@ -515,6 +529,16 @@ static bool get_list(FILE *f, dbref i)
}
return true;
case EOF:
// Truncated flatfile: the attribute list was cut off before its
// '<' terminator. Abort the load rather than spinning forever —
// getstring_noalloc() makes no progress at EOF, so the default
// case below would loop indefinitely emitting log lines.
//
Log.tinyprintf(T("Unexpected EOF when getting attributes on object %d" ENDLINE), i);
return false;
default:
Log.tinyprintf(T("Bad character \xE2\x80\x98%c\xE2\x80\x99 when getting attributes on object %d" ENDLINE), c, i);

View file

@ -2311,7 +2311,10 @@ static UTF8 *make_numlist(dbref player, UTF8 *arg, bool bBlind)
tprintf(T("MAIL: \xE2\x80\x98%s\xE2\x80\x99 is a badly-formed alias."), head));
return nullptr;
}
for (size_t i = 0; i < m->list.size(); i++)
for (size_t i = 0;
i < m->list.size()
&& nRecip < static_cast<int>(sizeof(aRecip)/sizeof(aRecip[0]));
i++)
{
aRecip[nRecip++] = m->list[i];
}
@ -3871,6 +3874,17 @@ static void malias_read(FILE *fp, bool bConvert)
if (numrecep > 0)
{
// Clamp a hostile/corrupt recipient count. A legitimate malias is
// bounded by the LBUF command string that creates it, which is why
// make_numlist's aRecip[] is sized (LBUF_SIZE+1)/2. Without this,
// m->list grows unbounded from file content and later overflows
// aRecip[] in make_numlist; reserve() below could also exhaust
// memory on an INT_MAX count.
//
if (numrecep > (LBUF_SIZE+1)/2)
{
numrecep = (LBUF_SIZE+1)/2;
}
m->list.reserve(numrecep);
for (j = 0; j < numrecep; j++)
{

View file

@ -1601,6 +1601,16 @@ bool CSQLiteDB::GetAllAttributes(dbref obj, AttrCallback cb)
dbref owner = static_cast<dbref>(sqlite3_column_int(m_stmtAttrGetObj, 2));
int flags = sqlite3_column_int(m_stmtAttrGetObj, 3);
// Clamp a tampered/corrupt value blob to LBUF_SIZE, mirroring the
// write-path clamp (cache_put) and the standalone read clamp
// (GetAttribute). The consumer (atr_get_str_LEN) memcpy's this length
// into a fixed LBUF_SIZE buffer; an oversized blob would overflow it.
//
if (len > LBUF_SIZE)
{
len = LBUF_SIZE;
}
// sqlite3_column_blob() returns NULL with bytes > 0 only under OOM; skip
// such a row rather than handing the consumer a NULL it will dereference.
if (NULL == value && 0 != len)
@ -1640,6 +1650,16 @@ bool CSQLiteDB::GetBuiltinAttributes(dbref obj, AttrCallback cb)
dbref owner = static_cast<dbref>(sqlite3_column_int(m_stmtAttrGetBuiltin, 2));
int flags = sqlite3_column_int(m_stmtAttrGetBuiltin, 3);
// Clamp a tampered/corrupt value blob to LBUF_SIZE, mirroring the
// write-path clamp (cache_put) and the standalone read clamp
// (GetAttribute). The consumer (atr_get_str_LEN) memcpy's this length
// into a fixed LBUF_SIZE buffer; an oversized blob would overflow it.
//
if (len > LBUF_SIZE)
{
len = LBUF_SIZE;
}
// sqlite3_column_blob() returns NULL with bytes > 0 only under OOM; skip
// such a row rather than handing the consumer a NULL it will dereference.
if (NULL == value && 0 != len)