Skip to content

Fix creating a first-class callable from FFI #12916

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: PHP-8.2
Choose a base branch
from

Conversation

nielsdos
Copy link
Member

@nielsdos nielsdos commented Dec 9, 2023

This causes a NULL pointer access because the scope field is being accessed in zend_closure_call_magic. We get to that function because of the ZEND_ACC_CALL_VIA_TRAMPOLINE fn_flag that is checked in zend_closure_from_frame. The zend_closure_from_frame function does not take into account the reserved data or the internal handler function, causing us to end up in zend_closure_call_magic instead of the FFI trampoline.

Get rid of the flag, and store the temporary internal function in another reserved slot so we can clean that up at the end of the FFI trampoline's execution.

This causes a NULL pointer access because the scope field is being
accessed in `zend_closure_call_magic`. We get to that function because
of the ZEND_ACC_CALL_VIA_TRAMPOLINE fn_flag that is checked in
`zend_closure_from_frame`. The `zend_closure_from_frame` function does
not take into account the reserved data or the internal handler
function, causing us to end up in `zend_closure_call_magic` instead of
the FFI trampoline.

Get rid of the flag, and store the temporary internal function in
another reserved slot so we can clean that up at the end of the FFI
trampoline's execution.
@nielsdos nielsdos marked this pull request as ready for review December 9, 2023 21:12
@nielsdos nielsdos requested a review from dstogov December 9, 2023 21:12
Copy link
Member

@dstogov dstogov left a comment

Choose a reason for hiding this comment

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

I'm not sure if this is a right approach.

By design CALL_VIA_TRAMPOLINE wasn't limited to usage by __call, __callstatic and __invoke. The limitation was introduced in zend_closure_from_frame(), that also misses the relevant checks, that were in zend_create_closure_from_callable().

I think we should fix zend_closure_from_frame() itself:

diff --git a/Zend/zend_closures.c b/Zend/zend_closures.c
index f1b29af7967..c7c306013bd 100644
--- a/Zend/zend_closures.c
+++ b/Zend/zend_closures.c
@@ -841,6 +841,15 @@ ZEND_API void zend_create_fake_closure(zval *res, zend_function *func, zend_clas
 /* __call and __callStatic name the arguments "$arguments" in the docs. */
 static zend_internal_arg_info trampoline_arg_info[] = {ZEND_ARG_VARIADIC_TYPE_INFO(false, arguments, IS_MIXED, false)};
 
+static ZEND_NAMED_FUNCTION(zend_closure_call_trampoline_handler) /* {{{ */ {
+	zend_function *mptr = EX(func)->internal_function.reserved[0];
+
+	EX(func) = mptr;
+	mptr->internal_function.handler(execute_data, return_value);
+	zend_free_trampoline(mptr);
+}
+/* }}} */
+
 void zend_closure_from_frame(zval *return_value, zend_execute_data *call) { /* {{{ */
 	zval instance;
 	zend_internal_function trampoline;
@@ -861,14 +870,19 @@ void zend_closure_from_frame(zval *return_value, zend_execute_data *call) { /* {
 		memset(&trampoline, 0, sizeof(zend_internal_function));
 		trampoline.type = ZEND_INTERNAL_FUNCTION;
 		trampoline.fn_flags = mptr->common.fn_flags & (ZEND_ACC_STATIC | ZEND_ACC_VARIADIC);
-		trampoline.handler = zend_closure_call_magic;
 		trampoline.function_name = mptr->common.function_name;
 		trampoline.scope = mptr->common.scope;
 		if (trampoline.fn_flags & ZEND_ACC_VARIADIC) {
 			trampoline.arg_info = trampoline_arg_info;
 		}
+		if (mptr->type == ZEND_INTERNAL_FUNCTION) {
+			trampoline.handler = zend_closure_call_trampoline_handler;
+			trampoline.reserved[0] = mptr;
+		} else {
+			trampoline.handler = zend_closure_call_magic;
+			zend_free_trampoline(mptr);
+		}
 
-		zend_free_trampoline(mptr);
 		mptr = (zend_function *) &trampoline;
 	}
 

I'm not copletely sre if I'm right, and if my patch covers all the problems.
@nielsdos could you please check my idea and take care about this.

@nielsdos
Copy link
Member Author

@dstogov Your patch looks fine and doesn't seem to cause problems.

However, I found an additional problem with both your patch and my patch:

$libc = FFI::cdef("int printf(const char *format, ...);", "libc.so.6");
$closure = $libc->printf(...);
$closure("1\n");
$closure("2\n");

This will crash. This is because the following code in ffi.c:

php-src/ext/ffi/ffi.c

Lines 2858 to 2863 in ab5edb6

exit:
zend_string_release(EX(func)->common.function_name);
if (EX(func)->common.fn_flags & ZEND_ACC_CALL_VIA_TRAMPOLINE) {
zend_free_trampoline(EX(func));
EX(func) = NULL;
}

This will release the string, and the trampoline. Freeing the trampoline sets EG(trampoline).common.function_name to NULL. The crash is because the name is NULL on the second $closure invocation. Also this could probably cause use-after-frees with non-interned strings.

Furthermore, because the name becomes NULL in zend_free_trampoline, the following check causes the trampoline to be overwritten if another trampoline is created in between the closure calls: if (EXPECTED(EG(trampoline).common.function_name == NULL)) {.

We would need to delay freeing the trampoline until the $closure variable is destroyed. But I don't think the code right now can handle that.
This problem I showed also happens in ext/com_dotnet and ext/zend_test.

@dstogov
Copy link
Member

dstogov commented Dec 18, 2023

@nielsdos How do you reproduce the problem with ext/zend_test? Or does it start fail only with my patch?

@nielsdos
Copy link
Member Author

@nielsdos How do you reproduce the problem with ext/zend_test? Or does it start fail only with my patch?

@dstogov

For the following code:

<?php
$test = new _ZendTestClass;
$closure = $test->test(...);
$closure(); // (1)
$closure(); // (2)

This gives the following error on 8.2:
Fatal error: Uncaught Error: Invalid callback , no array or string given in crash.php:4
This is thrown at // (1).

After applying your patch it becomes possible to call it through a closure.
Then you get the crash I described at // (2):

AddressSanitizer:DEADLYSIGNAL
=================================================================
==128985==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000004 (pc 0x55ebe3ebaf80 bp 0x7ffeaa01ac50 sp 0x7ffeaa01ac30 T0)
==128985==The signal is caused by a READ memory access.
==128985==Hint: address points to the zero page.
    #0 0x55ebe3ebaf80 in zif_zend_test_func /run/media/niels/MoreData/php-8.2/ext/zend_test/test.c:74
    #1 0x55ebe4285066 in zend_closure_call_trampoline_handler /run/media/niels/MoreData/php-8.2/Zend/zend_closures.c:833
    #2 0x55ebe4283f23 in zend_closure_internal_handler /run/media/niels/MoreData/php-8.2/Zend/zend_closures.c:701
    #3 0x55ebe40eefc1 in ZEND_DO_FCALL_SPEC_RETVAL_UNUSED_HANDLER /run/media/niels/MoreData/php-8.2/Zend/zend_vm_execute.h:1842
    #4 0x55ebe42337e7 in execute_ex /run/media/niels/MoreData/php-8.2/Zend/zend_vm_execute.h:56107
    #5 0x55ebe42455d8 in zend_execute /run/media/niels/MoreData/php-8.2/Zend/zend_vm_execute.h:60439
    #6 0x55ebe404c222 in zend_execute_scripts /run/media/niels/MoreData/php-8.2/Zend/zend.c:1838
    #7 0x55ebe3eda3cf in php_execute_script /run/media/niels/MoreData/php-8.2/main/main.c:2551
    #8 0x55ebe441ab47 in do_cli /run/media/niels/MoreData/php-8.2/sapi/cli/php_cli.c:964
    #9 0x55ebe441c83e in main /run/media/niels/MoreData/php-8.2/sapi/cli/php_cli.c:1333
    #10 0x7f282d28fccf  (/usr/lib/libc.so.6+0x27ccf) (BuildId: 8bfe03f6bf9b6a6e2591babd0bbc266837d8f658)
    #11 0x7f282d28fd89 in __libc_start_main (/usr/lib/libc.so.6+0x27d89) (BuildId: 8bfe03f6bf9b6a6e2591babd0bbc266837d8f658)
    #12 0x55ebe3603964 in _start (/run/media/niels/MoreData/php-8.2/sapi/cli/php+0x603964) (BuildId: f5ce3471ad559854e12206f1fb4f2668177bce0e)

This reproduces for FFI too.
With my patch, the zend-test code keeps throw the exception, but the FFI code experiences the same crash.

@dstogov
Copy link
Member

dstogov commented Dec 18, 2023

For the following code:

<?php
$test = new _ZendTestClass;
$closure = $test->test(...);
$closure(); // (1)
$closure(); // (2)

This gives the following error on 8.2: Fatal error: Uncaught Error: Invalid callback , no array or string given in crash.php:4

I think this is wrong. I don't see why this closure shouldn't be called.

Probably my patch should be extended with some thunk that must take care about keeping the trampoline (or its re-creation).

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