Skip to content

Commit a6d3f67

Browse files
Gate closures over named callables behind an allow_named_closures option
A by-name closure payload (a first-class callable over a named function or method) lets deepclone_from_array() mint a Closure over any function or method of that name, with no visibility or staticness restriction and including internal functions such as system() -- a stronger primitive than anything unserialize() exposes, and a one-step gadget when paired with an object whose __wakeup/__unserialize/destructor invokes a stored callback. Both deepclone_to_array() and deepclone_from_array() gain a third parameter, bool $allow_named_closures = false, that both ends must enable: with it off (the default) to_array refuses to encode such a closure, and from_array rejects any payload carrying a by-name closure marker before instantiating anything. So the attribute-cache use case does not regress, first-class callables over a method of their own declaring class declared in a constant expression (e.g. #[When(self::isStrict(...))]) are now encoded as a declaration-site reference (mask LONG 1), like anonymous const-expr closures, instead of by name. They resolve only to a closure the named class itself declares and round-trip without the opt-in. References whose declaring class cannot be derived from the closure (cross-class or global-function callables, inherited methods, runtime-created callables) keep the by-name form and need the opt-in. allowed_classes still gates Closure for both forms.
1 parent 2e9eef8 commit a6d3f67

17 files changed

Lines changed: 386 additions & 90 deletions

CHANGELOG.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `deepclone_to_array()` and `deepclone_from_array()` gained a third
13+
parameter, `bool $allow_named_closures = false`, gating the by-name
14+
encoding of closures over named callables (first-class callables such as
15+
`strlen(...)`, `$obj->method(...)`, `Cls::method(...)`, and
16+
`Closure::fromCallable()`). Both ends must enable it: with it off (the
17+
default) `deepclone_to_array()` refuses to encode such a closure and
18+
`deepclone_from_array()` rejects any payload carrying a by-name closure
19+
marker, before instantiating anything. A by-name payload can mint a
20+
`Closure` over any function or method of that name, including internal
21+
functions like `system()`, so it is restricted to ends that trust each
22+
other. See SECURITY.md.
23+
- On PHP 8.5+, first-class callables over a method of their own declaring
24+
class declared in a constant expression (e.g.
25+
`#[When(self::isStrict(...))]`) now serialize as a reference to their
26+
declaration site (the same code-free, `allowed_classes`-gated payload as
27+
anonymous const-expr closures), instead of by name. They round-trip
28+
without `$allow_named_closures`, restoring the attribute-cache use case for
29+
first-class-callable arguments. References whose declaring class cannot be
30+
derived from the closure (cross-class or global-function callables,
31+
inherited methods, runtime-created callables) still take the by-name path
32+
and require the opt-in.
33+
34+
### Changed
35+
36+
- **BC break.** Closures over named callables no longer serialize or resolve
37+
by default; they now require `$allow_named_closures` on both
38+
`deepclone_to_array()` and `deepclone_from_array()` (see Added). Code that
39+
relied on the previous unconditional by-name behavior must pass the flag.
40+
Closures declared in constant expressions are unaffected.
41+
1242
- On PHP 8.4+, `deepclone_from_array()` now creates object nodes whose
1343
payload slots or replayed `__unserialize` state carry a named-closure or
1444
const-expr-closure marker as

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,30 @@ deepclone_hydrate($existingUser, ['name' => 'Bob']);
7878
## API
7979

8080
```php
81-
function deepclone_to_array(mixed $value, ?array $allowed_classes = null): array;
82-
function deepclone_from_array(array $data, ?array $allowed_classes = null): mixed;
81+
function deepclone_to_array(mixed $value, ?array $allowed_classes = null, bool $allow_named_closures = false): array;
82+
function deepclone_from_array(array $data, ?array $allowed_classes = null, bool $allow_named_closures = false): mixed;
8383
function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object;
8484
```
8585

8686
`$allowed_classes` restricts which classes may be serialized or deserialized
8787
(`null` = allow all, `[]` = allow none). Case-insensitive, matching
8888
`unserialize()`'s `allowed_classes` option.
8989

90+
`$allow_named_closures` controls the by-name encoding of closures over named
91+
callables (first-class callables such as `strlen(...)`, `$obj->method(...)`
92+
or `Cls::method(...)`, and `Closure::fromCallable()`). It defaults to
93+
`false`, and **both ends must enable it**: `deepclone_to_array()` refuses to
94+
encode such a closure unless it is set, and `deepclone_from_array()` refuses
95+
to resolve a by-name closure payload unless it is set. The reason is that a
96+
by-name payload can mint a `Closure` over *any* function or method of that
97+
name, including internal functions like `system()`, so it should only travel
98+
between ends that trust each other. Closures declared in constant
99+
expressions (anonymous static closures and first-class callables over a
100+
method of their own declaring class, e.g. `#[When(self::isStrict(...))]`)
101+
are **not** affected: they serialize as a reference to their declaration
102+
site, resolvable only to what the named class itself declares, and round-trip
103+
without this option.
104+
90105
### Lazy hydration of closure-bearing nodes (PHP 8.4+)
91106

92107
`deepclone_from_array()` creates the object nodes that are expensive to
@@ -257,7 +272,12 @@ $s->__unserialize([[$obj1, 'info1', $obj2, 'info2'], []]);
257272
- Cycles in the object graph
258273
- Private/protected properties across inheritance
259274
- `__serialize` / `__unserialize` / `__sleep` / `__wakeup` semantics
260-
- Named closures (first-class callables like `strlen(...)`)
275+
- Closures declared in constant expressions (anonymous static closures and
276+
first-class callables over a method of their declaring class, as found in
277+
attribute arguments and parameter defaults), as a reference to their
278+
declaration site
279+
- Closures over named callables (first-class callables like `strlen(...)`),
280+
by name, when `$allow_named_closures` is enabled on both ends
261281
- Enum values
262282
- Copy-on-write for strings and scalar arrays
263283

SECURITY.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,25 @@ and throws `\ValueError` on malformed input. It instantiates classes named in
3030
the payload via the standard PHP class loader. Treat its input the same way
3131
you would treat input to `unserialize()`: only call it on payloads from
3232
trusted sources.
33+
34+
### Closures
35+
36+
A by-name closure payload (a first-class callable over a named function or
37+
method, e.g. `strlen(...)` or `Cls::method(...)`) is a stronger primitive
38+
than anything `unserialize()` exposes: resolving it mints a `Closure` over
39+
the named callable, with no visibility or staticness restriction, including
40+
internal functions such as `system()`. Creating the closure does not call it,
41+
but a payload pairing such a closure with an object whose `__wakeup`,
42+
`__unserialize` or destructor invokes a stored callback turns it into a
43+
one-step gadget. This encoding is therefore **off by default** and gated by
44+
the `$allow_named_closures` flag, which both `deepclone_to_array()` and
45+
`deepclone_from_array()` must set: a default `deepclone_from_array()` call
46+
rejects any payload carrying a by-name closure before instantiating anything.
47+
Enable it only between ends that trust each other.
48+
49+
Closures declared in constant expressions (the attribute-cache use case)
50+
carry no such risk and need no opt-in: they serialize as a reference to their
51+
declaration site and resolve only to a closure the named class itself
52+
declares, the same bounded capability as unserializing a class or enum-case
53+
name. The `$allowed_classes` filter still applies to both forms: omit
54+
`Closure` from the list and no closure resolves at all.

deepclone.c

Lines changed: 133 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ struct _dc_ctx {
265265
uint32_t next_obj_id;
266266
uint32_t objects_count;
267267
bool is_static;
268+
bool allow_named_closures; /* opt-in: encode closures over named callables by name */
268269
HashTable *allowed_ht; /* allowed class names set (or NULL = all) */
269270

270271
/* Output structures built incrementally during traversal */
@@ -311,6 +312,7 @@ static void dc_ctx_init(dc_ctx *ctx) {
311312
ctx->next_obj_id = 0;
312313
ctx->objects_count = 0;
313314
ctx->is_static = 1;
315+
ctx->allow_named_closures = false;
314316
ctx->allowed_ht = NULL;
315317
zend_hash_init(&ctx->scope_cache, 4, NULL, ZVAL_PTR_DTOR, 0);
316318
zend_hash_init(&ctx->class_info, 4, NULL, NULL, 0);
@@ -1782,10 +1784,54 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst)
17821784
}
17831785
}
17841786

1785-
/* ── Named closure ──────────────────────────── */
1787+
/* ── Closures ───────────────────────────────── */
17861788
if (Z_OBJCE_P(src) == zend_ce_closure) {
17871789
const zend_function *func = zend_get_closure_method_def(Z_OBJ_P(src));
1790+
1791+
#if PHP_VERSION_ID >= 80500
1792+
/* Const-expr declaration-site reference. This covers anonymous static
1793+
* closures and first-class callables over a method of their own
1794+
* declaring class (e.g. #[When(self::isStrict(...))]). It is attempted
1795+
* before the by-name encoding so that such closures serialize as a
1796+
* declaration-site reference — resolvable only to what the class
1797+
* itself declares — and therefore round-trip without requiring the
1798+
* allow_named_closures opt-in. The allow-list is checked first so that
1799+
* disallowing Closure is reported before any const-expr of the scope
1800+
* class is evaluated. */
1801+
if (func && func->type == ZEND_USER_FUNCTION && func->common.scope) {
1802+
zval *this_ptr = zend_get_closure_this_ptr(src);
1803+
if (!this_ptr || Z_TYPE_P(this_ptr) != IS_OBJECT) {
1804+
if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) {
1805+
zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed");
1806+
return;
1807+
}
1808+
zval payload;
1809+
ZVAL_UNDEF(&payload);
1810+
if (dc_cexpr_locate(func, &payload)) {
1811+
ZVAL_COPY_VALUE(dst, &payload);
1812+
DC_MASK_CONSTEXPR_CLOSURE(mask_dst);
1813+
goto handle_value;
1814+
}
1815+
if (UNEXPECTED(EG(exception))) {
1816+
return;
1817+
}
1818+
}
1819+
}
1820+
#endif
1821+
1822+
/* Named closure: a first-class callable that is not addressable as a
1823+
* declaration-site reference (one created at runtime, or whose target
1824+
* lives outside its declaring class, or over an internal/global
1825+
* function). Encoding it stores the callable by name, which lets
1826+
* deepclone_from_array() mint a Closure over any function or method of
1827+
* that name — including internal functions such as system(). It is
1828+
* therefore gated behind the allow_named_closures opt-in, which both
1829+
* ends must enable. */
17881830
if (func && (func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) {
1831+
if (!ctx->allow_named_closures) {
1832+
zend_value_error("deepclone_to_array(): serializing a closure over the named callable \"%s\" requires enabling the allow_named_closures option", ZSTR_VAL(func->common.function_name));
1833+
return;
1834+
}
17891835
if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) {
17901836
zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed");
17911837
return;
@@ -1846,32 +1892,8 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst)
18461892
DC_MASK_NAMED_CLOSURE(mask_dst);
18471893
goto handle_value;
18481894
}
1849-
1850-
#if PHP_VERSION_ID >= 80500
1851-
/* Anonymous closure: reference its const-expr declaration site.
1852-
* The allow-list is checked first so that disallowing Closure is
1853-
* reported before any const-expr of the scope class is evaluated. */
1854-
if (func && func->type == ZEND_USER_FUNCTION && func->common.scope) {
1855-
zval *this_ptr = zend_get_closure_this_ptr(src);
1856-
if (!this_ptr || Z_TYPE_P(this_ptr) != IS_OBJECT) {
1857-
if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) {
1858-
zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed");
1859-
return;
1860-
}
1861-
zval payload;
1862-
ZVAL_UNDEF(&payload);
1863-
if (dc_cexpr_locate(func, &payload)) {
1864-
ZVAL_COPY_VALUE(dst, &payload);
1865-
DC_MASK_CONSTEXPR_CLOSURE(mask_dst);
1866-
goto handle_value;
1867-
}
1868-
if (UNEXPECTED(EG(exception))) {
1869-
return;
1870-
}
1871-
}
1872-
}
1873-
#endif
1874-
/* Other anonymous closure — fall through to regular object handling */
1895+
/* Other closure (runtime anonymous, arrow fn) — fall through to
1896+
* regular object handling, which refuses it as non-instantiable. */
18751897
}
18761898

18771899
/* ── Regular object processing ──────────────── */
@@ -2722,11 +2744,13 @@ PHP_FUNCTION(deepclone_to_array)
27222744
{
27232745
zval *value;
27242746
HashTable *allowed_ht = NULL;
2747+
bool allow_named_closures = false;
27252748

2726-
ZEND_PARSE_PARAMETERS_START(1, 2)
2749+
ZEND_PARSE_PARAMETERS_START(1, 3)
27272750
Z_PARAM_ZVAL(value)
27282751
Z_PARAM_OPTIONAL
27292752
Z_PARAM_ARRAY_HT_OR_NULL(allowed_ht)
2753+
Z_PARAM_BOOL(allow_named_closures)
27302754
ZEND_PARSE_PARAMETERS_END();
27312755

27322756
/* Reject resources at the top level just like the walker does mid-tree.
@@ -2755,6 +2779,7 @@ PHP_FUNCTION(deepclone_to_array)
27552779

27562780
dc_ctx ctx;
27572781
dc_ctx_init(&ctx);
2782+
ctx.allow_named_closures = allow_named_closures;
27582783
if (allowed_ht) {
27592784
ctx.allowed_ht = dc_build_allowed_set(allowed_ht, "deepclone_to_array");
27602785
if (!ctx.allowed_ht) {
@@ -3767,11 +3792,77 @@ static bool dc_mask_has_closure(zval *mask)
37673792
return false;
37683793
}
37693794

3795+
/* Like dc_mask_has_closure() but matches only the named-closure marker
3796+
* (LONG(0)), ignoring const-expr-closure references (LONG(1)). */
3797+
static bool dc_mask_has_named_closure(zval *mask)
3798+
{
3799+
if (mask == NULL) {
3800+
return false;
3801+
}
3802+
if (DC_MASK_IS_NAMED_CLOSURE(mask)) {
3803+
return true;
3804+
}
3805+
if (Z_TYPE_P(mask) != IS_ARRAY) {
3806+
return false;
3807+
}
3808+
zval *v;
3809+
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(mask), v) {
3810+
if (dc_mask_has_named_closure(v)) {
3811+
return true;
3812+
}
3813+
} ZEND_HASH_FOREACH_END();
3814+
return false;
3815+
}
3816+
3817+
/* Scan the four payload regions that can carry closure markers — the top
3818+
* mask, the resolve table, the reference masks and the replayed state masks —
3819+
* for a named-closure marker. Mirrors the region set used by the
3820+
* allowed_classes "Closure" gate below. */
3821+
static bool dc_payload_has_named_closure(zval *zmask, zval *zresolve, zval *zref_masks, zval *zstates)
3822+
{
3823+
if (dc_mask_has_named_closure(zmask)) {
3824+
return true;
3825+
}
3826+
if (zresolve) {
3827+
zval *scope;
3828+
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zresolve), scope) {
3829+
if (Z_TYPE_P(scope) != IS_ARRAY) continue;
3830+
zval *name;
3831+
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(scope), name) {
3832+
if (dc_mask_has_named_closure(name)) {
3833+
return true;
3834+
}
3835+
} ZEND_HASH_FOREACH_END();
3836+
} ZEND_HASH_FOREACH_END();
3837+
}
3838+
if (zref_masks) {
3839+
zval *rmask;
3840+
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zref_masks), rmask) {
3841+
if (dc_mask_has_named_closure(rmask)) {
3842+
return true;
3843+
}
3844+
} ZEND_HASH_FOREACH_END();
3845+
}
3846+
if (zstates) {
3847+
zval *state;
3848+
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zstates), state) {
3849+
if (Z_TYPE_P(state) == IS_ARRAY) {
3850+
zval *smask = zend_hash_index_find(Z_ARRVAL_P(state), 2);
3851+
if (smask && dc_mask_has_named_closure(smask)) {
3852+
return true;
3853+
}
3854+
}
3855+
} ZEND_HASH_FOREACH_END();
3856+
}
3857+
return false;
3858+
}
3859+
37703860
PHP_FUNCTION(deepclone_from_array)
37713861
{
37723862
HashTable *data_ht;
37733863
HashTable *allowed_ht = NULL;
37743864
HashTable *allowed_set = NULL;
3865+
bool allow_named_closures = false;
37753866
HashTable refs_local;
37763867
HashTable *refs = NULL;
37773868
zend_string **class_names = NULL;
@@ -3795,10 +3886,11 @@ PHP_FUNCTION(deepclone_from_array)
37953886
* any early-exit path. Only one is live at a time. */
37963887
zend_string *numeric_prop_tmp = NULL;
37973888

3798-
ZEND_PARSE_PARAMETERS_START(1, 2)
3889+
ZEND_PARSE_PARAMETERS_START(1, 3)
37993890
Z_PARAM_ARRAY_HT(data_ht)
38003891
Z_PARAM_OPTIONAL
38013892
Z_PARAM_ARRAY_HT_OR_NULL(allowed_ht)
3893+
Z_PARAM_BOOL(allow_named_closures)
38023894
ZEND_PARSE_PARAMETERS_END();
38033895

38043896
/* Static value: return data['value'] */
@@ -3914,6 +4006,18 @@ PHP_FUNCTION(deepclone_from_array)
39144006
}
39154007
}
39164008

4009+
/* Named closures (the by-name marker, LONG(0)) let a payload mint a
4010+
* Closure over any function or method by name; they resolve only when the
4011+
* caller opts in via allow_named_closures, which the producer must also
4012+
* have set. Const-expr-closure references (LONG(1)) are unaffected: they
4013+
* resolve only to closures the named class itself declares. The scan runs
4014+
* before any object is instantiated, so a payload carrying a named closure
4015+
* is rejected wholesale rather than failing mid-hydration. */
4016+
if (!allow_named_closures
4017+
&& dc_payload_has_named_closure(zmask, zresolve, zref_masks, zstates)) {
4018+
DC_INVALID("deepclone_from_array(): resolving a closure over a named callable requires enabling the allow_named_closures option");
4019+
}
4020+
39174021
/* ── Build objectMeta ── */
39184022
if (Z_TYPE_P(zobject_meta) == IS_LONG) {
39194023
zend_long n = Z_LVAL_P(zobject_meta);

deepclone.stub.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ private function hydrate(object $object): void {}
4747
*/
4848
const DEEPCLONE_HYDRATE_PRESERVE_REFS = UNKNOWN;
4949

50-
function deepclone_to_array(mixed $value, ?array $allowed_classes = null): array {}
50+
function deepclone_to_array(mixed $value, ?array $allowed_classes = null, bool $allow_named_closures = false): array {}
5151

52-
function deepclone_from_array(array $data, ?array $allowed_classes = null): mixed {}
52+
function deepclone_from_array(array $data, ?array $allowed_classes = null, bool $allow_named_closures = false): mixed {}
5353

5454
function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object {}
5555
}

deepclone_arginfo.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
/* This is a generated file, edit deepclone.stub.php instead.
2-
* Stub hash: bde61513c175dd9130f054c427cfaad2e233ff4f */
2+
* Stub hash: ec11b6f3b9b69f77cbddece18176eab2ffd1007a */
33

44
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_deepclone_to_array, 0, 1, IS_ARRAY, 0)
55
ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0)
66
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, allowed_classes, IS_ARRAY, 1, "null")
7+
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, allow_named_closures, _IS_BOOL, 0, "false")
78
ZEND_END_ARG_INFO()
89

910
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_deepclone_from_array, 0, 1, IS_MIXED, 0)
1011
ZEND_ARG_TYPE_INFO(0, data, IS_ARRAY, 0)
1112
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, allowed_classes, IS_ARRAY, 1, "null")
13+
ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, allow_named_closures, _IS_BOOL, 0, "false")
1214
ZEND_END_ARG_INFO()
1315

1416
ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_deepclone_hydrate, 0, 1, IS_OBJECT, 0)

0 commit comments

Comments
 (0)