Fix use-after-free on re-entrant ftp_close() during a transfer#22400
Fix use-after-free on re-entrant ftp_close() during a transfer#22400iliaal wants to merge 1 commit into
Conversation
A user stream wrapper passed to ftp_get(), ftp_put(), ftp_append() or the ftp_nb_* variants can call ftp_close() from its stream_read or stream_write handler while the engine still holds the freed ftpbuf_t and databuf_t on the C stack, so the transfer resumes on freed memory. Guard each engine transfer with an in_use flag and make ftp_close() throw while it is set. Closes phpGH-22400
7102ae6 to
b758b53
Compare
|
Does this cover all possible functions? (e.g. non-blocking ones, they can close the connection too) |
|
Yes. Every non-blocking transfer funnels through |
|
The following re-entrancy bug still triggers UAF: --TEST--
Re-entrant ftp_nb_get() from a stream wrapper during a blocking ftp_get() must not free the active data connection
--EXTENSIONS--
ftp
pcntl
--FILE--
<?php
require 'server.inc';
class NbGetDuringGet {
public $context;
public static $ftp;
public function stream_open($path, $mode, $options, &$opened_path) {
return true;
}
public function stream_write($data) {
@ftp_nb_get(self::$ftp, 'php://memory', 'a story.txt', FTP_BINARY);
return strlen($data);
}
public function stream_close() {}
public function stream_eof() {
return true;
}
}
stream_wrapper_register('reentrantget', NbGetDuringGet::class);
$ftp = ftp_connect('127.0.0.1', $port);
var_dump(ftp_login($ftp, 'user', 'pass'));
NbGetDuringGet::$ftp = $ftp;
var_dump(@ftp_get($ftp, 'reentrantget://sink', 'a story.txt', FTP_BINARY));
ftp_close($ftp);
echo "closed\n";
?>
--EXPECT--
bool(true)
bool(true)
closed |
A user stream wrapper passed to ftp_get(), ftp_put(), ftp_append() or the ftp_nb_* variants can re-enter the FTP engine from its stream_read or stream_write handler while the engine still holds the freed ftpbuf_t and databuf_t on the C stack, so the transfer resumes on freed memory. The re-entrant call is either ftp_close() or another transfer: ftp_nb_get() and ftp_nb_put() close the active data connection before opening a new one. Guard each engine transfer with an in_use flag; ftp_close() throws while it is set and the transfer functions refuse to start a second transfer. Closes phpGH-22400
b758b53 to
9d26581
Compare
A user stream wrapper passed to ftp_get(), ftp_put(), ftp_append() or the ftp_nb_* variants can re-enter the FTP engine from its stream_read or stream_write handler while the engine still holds the freed ftpbuf_t and databuf_t on the C stack, so the transfer resumes on freed memory. The re-entrant call is ftp_close(), a second transfer, or a directory listing: ftp_nb_get(), ftp_nb_put() and ftp_genlist() reopen the data connection while the outer transfer still uses it. Guard every engine operation that opens a data connection with an in_use flag; ftp_close() throws while it is set and the others refuse to run. Closes phpGH-22400
9d26581 to
e0a9afb
Compare
|
Ah, you're right. The initiating calls reopen the data connection before reaching the guarded continue step, so |
A user stream wrapper passed to a blocking or non-blocking FTP transfer can re-enter the engine from its stream_read/stream_write handler while the engine still holds the freed ftpbuf_t/databuf_t on the C stack, so the transfer resumes on freed memory. Guard every operation that opens a data connection with an in_use flag: ftp_close() throws while it is set, and the transfer and listing functions refuse to run. The non-blocking checks sit at the userland entry, before the wrapper mutates ftp->direction/stream, so a rejected re-entrant call cannot corrupt the outer transfer; the nb cleanup clears ftp->stream before closing the stream so a stream_close() that calls ftp_close() cannot double-close it. Closes phpGH-22400
e0a9afb to
2a27fcc
Compare
|
Pushed a follow-up: the C guard wasn't enough for the non-blocking path. The nb wrappers set |
ndossche
left a comment
There was a problem hiding this comment.
Final question: for ftp_close() you used an exception, but for the other cases you used warnings. Shouldn't all of these be exceptions?
|
Deliberate. The transfer guards fire from wrapper callbacks inside an active transfer, so a thrown Error stays pending through the failed stream write and unwinds the outer transfer too unless the wrapper catches it; your nb reproducer would abort the outer get instead of returning true, since @ doesn't suppress an Error. Warning+false keeps the refusal contained to the re-entrant call, matching ext/ftp's other transfer failures. ftp_close() throws because it can't proceed at all while the transfer owns the connection. |
A user stream wrapper's stream_read/stream_write handler can call ftp_close() mid-transfer (during ftp_get/ftp_put/ftp_append or the ftp_nb_* variants), freeing the
ftpbuf_tthe engine still holds on the C stack, so the transfer resumes on freed memory (valgrind: invalid reads in the ftp_get receive loop). The fix guards each engine transfer with anin_useflag and makes ftp_close() throw while a transfer is in progress. Reported privately as GHSA-vr2x-x6ff-4586, rejected per the security policy because the trigger is PHP code, not the FTP server.