Details
-
Bug
-
Status: In Review (View Workflow)
-
Critical
-
Resolution: Unresolved
-
N/A
Description
Summary
A single-row table is read once during optimization and its row kept in
record[0] for the rest of the statement, on the understanding that a const
table's value is a constant. For a MEMORY table with a BLOB column that row is
not self-contained: hp_read_blobs() answers the read by pointing
record[0] at the blob data inside HP_SHARE rather than copying it.
make_join_statistics() then releases the lock on every const table. From
that moment the blob data the statement is still reading belongs to no one, and
another connection is free to overwrite or free it. The statement goes on
reading it for as long as it runs.
The result is a const table whose value changes in the middle of the statement
that is using it, and a read of freed memory.
How to repeat
CREATE TABLE t1 (a INT, b BLOB) ENGINE=MEMORY;
|
INSERT INTO t1 VALUES (1, REPEAT('x', 4000));
|
|
|
CREATE TABLE t2 (a INT) ENGINE=MEMORY;
|
INSERT INTO t2 VALUES (1),(2),(3),(4);
|
|
|
connect (writer,localhost,root,,test);
|
|
|
connection default;
|
--send SELECT LEFT(t1.b, 16) AS head, LENGTH(t1.b) AS len FROM t1, t2 WHERE SLEEP(0.5) = 0
|
|
|
connection writer;
|
--sleep 1
|
UPDATE t1 SET b = REPEAT('y', 4000) WHERE a = 1;
|
INSERT INTO t1 VALUES (2, REPEAT('z', 4000)), (3, REPEAT('w', 4000));
|
|
|
connection default;
|
--reap
|
t1 is a const table: one row, read during optimization, unlocked
immediately after. t2 supplies the four rows of the join, and the
SLEEP() keeps the statement running long enough for the writer to reach it.
Result
head len
|
xxxxxxxxxxxxxxxx 4000
|
xxxxxxxxxxxxxxxx 4000
|
zzzzzzzzzzzzzzzz 4000
|
zzzzzzzzzzzzzzzz 4000
|
Four rows of the same const table, and the last two return the blob of a row
that a different connection inserted after this statement began. The
UPDATE freed the chain the const row still pointed at, and the INSERT
of (2, REPEAT('z', 4000)) was handed those exact records.
LENGTH() stays 4000 throughout, because the length is stored in
record[0] and only the data moved. There is no error and no warning.
Result, second variant
Replacing the writer's statements with
DELETE FROM t1;
|
INSERT INTO t1 VALUES (2, REPEAT('z', 9000));
|
frees the blocks outright rather than recycling them, and the const row reads
back the debug build's freed-memory fill pattern (shown as ? below; the
bytes are non-printable):
head len
|
xxxxxxxxxxxxxxxx 4000
|
xxxxxxxxxxxxxxxx 4000
|
???????????????? 4000
|
???????????????? 4000
|
This is a use-after-free read. No crash was observed in either variant, but
that is a property of where the allocator happened to put the freed block, not
of the code: the same free through munmap faults, and ASAN reports
heap-use-after-free unconditionally.
CHECK TABLE t1 reports OK in both variants. The table is undamaged – the
corruption exists only in the reading connection's record[0].
Analysis
hp_read_blobs() classifies each blob's storage into three cases:
- Case A (HP_ROW_SINGLE_REC), single-record run: the record already holds
the right pointer, and it points into HP_BLOCK - Case B (HP_ROW_CONT_ZEROCOPY), single run over several records: the
pointer is adjusted past the run header, and still points into HP_BLOCK - Case C (HP_ROW_MULTIPLE_REC), multi-run chain: the blob is reassembled
into info->blob_buff, which belongs to the handler
Only cases A and B are exposed. A case C blob lives in blob_buff until
heap_reset() frees it at the end of the statement, so it survives the
unlock; a 4MB blob in the test above returns correct data for that reason. The
exposed window is therefore small blobs, which is the common case.
The unlock is reached through mysql_unlock_some_tables() ->
mysql_unlock_tables() -> unlock_external() -> thr_multi_unlock().
The comment on the call justifies it with
It's safe to ignore result code as all tables where opened for read only.
which holds for a handler that answers a read with a copy of the row. MEMORY
with a BLOB does not.
Sequences are already excluded from this unlock, via
GET_LOCK_SKIP_SEQUENCES, because accessing them may still require table
updates. Non-transactional TEMPORARY tables never reach it at all –
get_lock_data() leaves them out of the lock set entirely – so a temporary
MEMORY table can be neither early-unlocked nor raced.
Scope
BLOB support in the MEMORY engine was added by MDEV-38975 and is not present in
any released version, so this does not affect released servers. It reproduces
on the current HEAP blob tree.
Every other early-unlock path in the server was checked and none retains a row
across the unlock: JOIN::join_free() closes all table cursors before
calling mysql_unlock_read_tables(), and every mysql_lock_remove() call
site closes the handler immediately afterwards.
Attachments
Issue Links
- is caused by
-
MDEV-38975 BLOBs in MEMORY (HEAP) Engine
-
- In Testing
-