Skip to content

Fix use-after-free on re-entrant ftp_close() during a transfer#22400

Closed
iliaal wants to merge 1 commit into
php:PHP-8.4from
iliaal:ftp-reentrant-close-uaf
Closed

Fix use-after-free on re-entrant ftp_close() during a transfer#22400
iliaal wants to merge 1 commit into
php:PHP-8.4from
iliaal:ftp-reentrant-close-uaf

Conversation

@iliaal

@iliaal iliaal commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

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_t the 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 an in_use flag 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.

iliaal added a commit to iliaal/php-src that referenced this pull request Jun 22, 2026
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
@iliaal iliaal force-pushed the ftp-reentrant-close-uaf branch from 7102ae6 to b758b53 Compare June 22, 2026 15:22
@iliaal iliaal requested review from iluuu1994 and ndossche June 22, 2026 15:22
@ndossche

ndossche commented Jul 4, 2026

Copy link
Copy Markdown
Member

Does this cover all possible functions? (e.g. non-blocking ones, they can close the connection too)

@iliaal

iliaal commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

Yes. Every non-blocking transfer funnels through ftp_nb_continue_read/ftp_nb_continue_write, both the initial ftp_nb_get/ftp_nb_put and each ftp_nb_continue step, and both are guarded. Together with the blocking ftp_get/ftp_put/ftp_append, those are the only engine functions that touch the user stream, so a wrapper can't re-enter ftp_close anywhere else.

@ndossche

Copy link
Copy Markdown
Member

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

iliaal added a commit to iliaal/php-src that referenced this pull request Jul 11, 2026
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
@iliaal iliaal force-pushed the ftp-reentrant-close-uaf branch from b758b53 to 9d26581 Compare July 11, 2026 12:47
iliaal added a commit to iliaal/php-src that referenced this pull request Jul 11, 2026
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
@iliaal iliaal force-pushed the ftp-reentrant-close-uaf branch from 9d26581 to e0a9afb Compare July 11, 2026 12:50
@iliaal

iliaal commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Ah, you're right. The initiating calls reopen the data connection before reaching the guarded continue step, so in_use never fired: ftp_nb_get() and ftp_nb_put() close the active connection, and ftp_genlist() (nlist/rawlist/mlsd) reassigns ftp->data. Moved the guard to the entry of all three, so every engine function that opens a data connection now refuses while a transfer is in progress. Added your reproducer as ftp_nb_get_during_transfer.phpt.

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
@iliaal iliaal force-pushed the ftp-reentrant-close-uaf branch from e0a9afb to 2a27fcc Compare July 11, 2026 13:24
@iliaal

iliaal commented Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up: the C guard wasn't enough for the non-blocking path. The nb wrappers set ftp->direction and null ftp->stream around the C call, so a rejected re-entrant ftp_nb_get/ftp_nb_put still corrupted an outer nb transfer. Moved the busy check to the userland entry of all four nb functions, before that state mutation, and reordered the finished/failed cleanup to null ftp->stream before php_stream_close so a stream_close() calling ftp_close() can't double-close it. Added ftp_nb_get_during_nb_transfer.phpt for the nb-during-nb case.

@ndossche ndossche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final question: for ftp_close() you used an exception, but for the other cases you used warnings. Shouldn't all of these be exceptions?

@iliaal

iliaal commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

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.

@iliaal iliaal requested a review from ndossche July 12, 2026 14:17
@iliaal iliaal closed this in 1feb201 Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants