pay-uz unauthenticated executable-hook write boundary¶
GHSA-m5wg-cjgh-223j turns a package-specific issue into a reusable Laravel assessment workflow: map package-owned control-panel routes, verify the middleware actually applied at runtime, trace user-controlled editor content into executable hook files, and prove the later load edge without a shell payload. In goodoneuz/pay-uz through 2.2.24, the default control-panel group used only Laravel's web middleware, Route::any() exposed the editor update action, and file_put_contents() replaced an existing .php hook selected by request data. Normal payment-service paths later loaded those hooks with require.
Sources:
- GHSA-m5wg-cjgh-223j / CVE-2026-31843
- pay-uz 2.2.24 route definitions
- pay-uz 2.2.24
ApiController::file_put - pay-uz 2.2.24 payment-service hook loads
- Upstream hardening pull request #73
- pay-uz 3.0.0 release
The reviewed advisory lists versions through 2.2.24 as affected and 3.0.0 as the first patched release. Confirm the installed package, published configuration, route cache, and runtime middleware rather than inferring exposure from a dependency name alone.
Authorized validation only
Use a disposable Laravel application, fake payment records, published canary hooks, and an isolated PHP runtime. Never replace production payment logic, submit real transactions, write a command-execution payload, touch credentials, or trigger a live payment callback. On a customer deployment, stop at dependency, route, middleware, and non-mutating response evidence unless explicit approval covers a lab clone.
Why this pattern matters¶
Framework packages can silently add privileged routes to a host application. A feature described as an “editor” is especially sensitive when its output is PHP, a template, a job definition, or another artifact loaded by the runtime.
The boundary has four independent edges:
| Edge | Question | Safe evidence |
|---|---|---|
| package reachability | is goodoneuz/pay-uz installed and are its routes registered? | lockfile entry and route-list output |
| authorization | does an anonymous or low-privilege request reach the action? | status/body and middleware trace with no write fields |
| write confinement | can request data select or replace an existing hook? | before/after hash of one disposable canary hook |
| execution | does a normal package path later load that hook? | instrumented require trace or no-op counter |
Do not collapse these into “RCE” from package presence. The strongest bounded finding demonstrates all four in a disposable environment.
1. Establish package and route reachability¶
Start offline where possible:
grep -n 'goodoneuz/pay-uz' composer.lock
composer show goodoneuz/pay-uz --locked
php artisan route:list --path=payment --columns=Method,URI,Name,Action,Middleware
Record:
- exact installed version and lockfile source reference;
- whether package route registration is enabled;
- the effective URI prefix after application customization;
- accepted methods for the editor update action;
- controller action and complete middleware chain;
- whether Laravel's route/config caches match the files on disk.
In 2.2.24, the package route file placed /payment/api/editable/update in a group whose empty/default configuration became ['web']. Laravel's web middleware supplies session and CSRF behavior but is not authentication. Test actual runtime behavior: integrations may add a reverse-proxy control, custom middleware, or a published config/payuz.php override.
2. Use a non-mutating authorization matrix first¶
Send requests without content and file_name; the goal is to identify routing and authorization, not to modify a hook.
| Principal | Method | CSRF state | Expected secure result |
|---|---|---|---|
| anonymous | GET | none | authentication or authorization rejection |
| anonymous | POST | absent | rejection before controller side effects |
| authenticated ordinary user | accepted method | valid | authorization rejection |
| authorized control-panel admin | accepted method | valid | controller-level validation error for missing fields |
Capture the route name, status, redirect destination, response signature, applied middleware, and controller-entry trace. Because the affected route used Route::any(), include method variation: a CSRF rejection on POST does not prove that GET, PUT, or another accepted method is protected.
A meaningful pre-write result is untrusted principal -> package route -> controller validation response, while a secure control is the same request rejected by authentication/authorization before controller entry.
3. Prove the write boundary only in a disposable application¶
- Install 2.2.24 in a throwaway Laravel application and publish only synthetic payment hook files.
- Put unique comments and known hashes in the intended hook directory. Place a separate marker outside that directory for a no-write negative control.
- Snapshot file paths, real paths, owners, modes, hashes, and modification times.
- Select one existing synthetic hook. Preserve its expected PHP signature and replace its body only with a no-op return value or test-local counter; do not invoke a shell, network client, filesystem API, or dynamic evaluator.
- Submit the editor request as an anonymous client using the method that reached the controller in the matrix.
- Verify the response and compare the single hook's before/after hash. Confirm all other hooks and the outside marker are unchanged.
- Repeat with a nonexistent hook name, an omitted field, an ordinary authenticated user, and 3.0.0.
- Restore the lab snapshot before testing the load edge.
The 2.2.24 controller checks that the computed file already exists, but that is not authorization. Its request-selected name is concatenated into a path and the supplied content is passed to file_put_contents(). The fixed implementation adds an exact hook-name allowlist, verifies the resolved target remains directly in the intended directory, and places control-panel routes behind authentication by default.
Do not publish a traversal string. The ordinary named-hook overwrite is sufficient to establish the executable-file boundary, and an outside-directory marker can remain an untouched negative control.
4. Separate write from execution¶
The affected payment service loads named hooks such as before_pay.php, after_pay.php, paying.php, and before_response.php with require. Prove that edge without a command payload:
- Restore a syntactically valid canary hook that increments only an in-memory test counter or returns a unique inert value.
- Invoke the corresponding service method directly in a unit/integration test with fake models; do not call a payment provider.
- Trace the loaded absolute path and assert the canary counter/value exactly once.
- Run an untouched-hook control and a fixed-version control.
- Tear down the application and delete all synthetic files.
Report the edges separately:
- write primitive: untrusted request changes an existing PHP hook;
- load primitive: a package service later
requires that hook; - combined impact: executable hook replacement reaches PHP evaluation under the application worker identity.
A changed hash alone is arbitrary content replacement, not proof of execution. A source-code require alone is reachability evidence, not proof that the tested deployment invokes that path.
Fixed-version decision table¶
| Control | Affected expectation | 3.0.0 expectation |
|---|---|---|
| anonymous request, no fields | controller may be reached depending on method/CSRF | auth rejection before controller |
| ordinary user with valid session | package configuration dependent | reject unless explicitly authorized |
| allowed hook name in authorized admin lab | existing hook may be updated | confined update only after authorization |
| unknown hook name | file-existence-dependent response | reject exact-name allowlist |
| symlinked allowed hook in lab | test only as a no-write boundary | resolved target must remain in hook directory |
| canary hook service path | hook loads in payment service | loads only administrator-authorized, confined content |
Also inspect published configuration after upgrade. Package defaults do not override every application-local configuration choice, and stale route/config caches can preserve surprising behavior.
Reporting checklist¶
Include:
- package version, source reference, Laravel/PHP version, and deployment mode;
- route URI, accepted methods, route cache state, and effective middleware order;
- principal/session/CSRF state for every authorization row;
- target lexical path, resolved path, and before/after hash from the disposable app;
- exact service method and instrumented hook-load trace;
- affected-versus-fixed decision table and negative controls;
- confirmation that no live payment, external callback, credential, shell command, or production hook was used.
A precise impact statement is: an unauthenticated package route can replace an existing application PHP payment hook, and a normal package service path later loads that hook under the application worker identity. State any missing edge explicitly instead of upgrading route reachability or source inspection into an unverified remote-code-execution claim.