VULNARCHIVE

[0day-rubbish] Maian Pal 2.1 product image upload with no extension allow-list to PHP webshell and www-data uid 33 code execution (7.2, PR:H)

disclosure via Fulldisclosure · · Full Disclosure

Original source

0day Rubbish Research Team is publicly disclosing a vulnerability in Maian Pal
2.1, the self-hosted PHP shopping-cart script from Maian Media (United Kingdom),
oriented at PayPal checkout and distributed as a commercial closed-source archive,
verified end to end against a real installation of that version in three
independent reproductions.

Type: unrestricted upload of a file with a dangerous type (CWE-434 primary, with
CWE-94 and CWE-693 contributing - the stored object is subsequently interpreted as
code, and the served upload directory lacks the execution restriction that
equivalent directories in the same product carry), realised as OS command
execution (CWE-78 describes what the demonstration payload does once it runs and
is not scored separately). The product-image handling of the administration
product create and update operations, `ajax-ops=add` and `ajax-ops=add-edit`,
passes the multipart field fm[ifile][] to products::pictures() at
`class.products.php:333` through `class.products.php:410`. The stored name is
chosen from the client-supplied filename by a single ternary:

    // class.products.php:372  - no extension allow-list anywhere on this path
    $picPath = ($cfg['rename'] == 'yes'
                ? $cfg['imgprf'] . $ID . '-' . $nextPic . strrchr($name, '.')
                : $name);

    // class.products.php:~390  - written before any validation
    move_uploaded_file($temp, $picFldr . '/' . $picPath);

With $cfg supplied by thumbcfg() at `class.products.php:615`, whose shipped
default is 'rename' => 'no', that assignment reduces to $picPath = $name, so the
client controls the entire stored name including the extension and a .php file
lands on disk intact as content/products/<ID>/<name>.php. There is no extension
allow-list and no deny-list, no finfo or MIME verification, no re-encoding to
strip an embedded payload, no server-generated filename with a type-derived
extension, and no rollback of the write when later validation fails. strtolower()
appears only in the non-default rename='yes' branch and is cosmetic.

The root cause has two independently fixable halves, and this advisory keeps them
separate because fixing one without the other leaves the chain standing.

The first half is the handler: what may be written. The second is the directory:
whether what is written may execute. That second half lines up in this product
because the destination sits inside the document root and the front-controller
rewrite claims only paths that do not exist on disk - `RewriteCond !-f` guarding
`RewriteRule ^([^?]*) index.php?_msw_=$1 [L,QSA]` - so a file that exists is handed
straight to the PHP handler, and requesting it with a query string executes it.
Nothing in content/products/ disables that handler. Our review of the shipped tree
found an Apache deny file carrying `Deny from all` present in admin/import/, and
found content/products/ carrying no such file.

The contrast that makes this an omission rather than a design decision is inside
one product and one shipped version: two handlers accept files from a browser, and
one of them checks the extension and writes into a deny-protected directory while
the other does neither. We are precise about the evidentiary weight of each half
of that comparison, because it matters. That admin/import/ carries `Deny from all`
and that content/products/ carries nothing are both anchored artefacts of the
shipped tree. The statement that the product's attachment upload path applies an
extension allow-list into a deny-protected directory is a source-level reading of
the shipped PHP that we did not reduce to a line citation, and we label it as
such. It is offered here the same way we put it to the seller: as a contrast worth
confirming, since part of the argument that this is an omission rests on it.

The ordering inside the handler makes the failure path worse rather than safer,
and this is the detail most likely to be missed by a fix that only adds a check
downstream. move_uploaded_file() runs before any thumbnail inspection, and the
validation that does run is not fatal. getimagesize() inside thumbnail() runs
after the write and is used only to select a thumbnail strategy, never to accept
or reject the upload; for a PHP file it fails, the switch falls through to a
suppressed @copy(), and the original is never unlinked. So a non-image upload
leaves two objects in a served directory - the original plus a tmb_-prefixed
sibling - deletes neither, and still reports {"status":"ok"}. The failure path
yields two files rather than zero, silently. Any cleanup that removes only the
obviously named object leaves the second one behind.

Turning rename on would not fix this either, and it deserves emphasis because it
is the obvious operator-facing knob: in the rename='yes' branch the stem is
server-generated but the extension is still taken from the client filename via
strrchr(), so a .php upload produces a server-named file that is still
executable. We derived that from the assignment expression and did not run the
rename='yes' configuration, so it is an argument from code rather than a dynamic
result.

Commands execute as the web server service account, observed as
`uid=33(www-data) gid=33(www-data) groups=33(www-data)` in three independent
reproductions with distinct random filenames, plus a marker written to an absolute
path outside the document root and read back afterwards from the host. No
privilege escalation of any kind was attempted or observed: no kernel exploit, no
sudo right, no writable service unit, no container escape. Root was not reached
and is not claimed; the ceiling of this finding is code execution as the web
server user.

How confirmation works. The chain is two requests. One POST creates the object
and the create response itself embeds `?p=add&id=<ID>`, so the attacker learns
the numeric product directory without guessing and the filename is one they
chose - predictability of the final URL is total. One GET to
`/content/products/<ID>/<name>.php?cmd=<command>` executes it and returns the
output in the response body, prefixed by a marker that proves the body came from
PHP execution rather than from static file serving. Both operations report
{"status":"ok"}. What was deliberately not reproduced: no data theft, no database
modification, no persistence beyond the uploaded file, no lateral movement and no
exploitation of a live third-party system - all verification targeted our own
instance, and the payload's default command is a non-destructive identity query.

Scoring. One score is published: 7.2 High,
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H, the CVSS 3.1 base arithmetic for
which is shown in full in the advisory and was re-checked against an independent
implementation of that formula. AV:N because the sink sits on the product's
ordinary HTTP(S) listener, with no separate management port, no RPC endpoint and
no background listener, and nothing in the chain depends on source address, user
agent or session age beyond validity. AC:L because one POST creates the object and
one GET executes it, so there is no race window, no multi-step state to prepare and
no unusual configuration requirement: the chain depends only on shipped defaults
(thumbcfg() 'rename' => 'no', no deny file in content/products/), and the licence
restriction RESTR_PRODUCTS = 50 does not obstruct the first product create. UI:N
because no victim browser or user action is involved - the attacker authenticates
with their own credentials and there is no forgery step.

PR:H is published because only an authenticated administrator session was
demonstrated. The credential pair is the one the installer provisions (LG_USER
and LG_PASS in `control/_cfg.php`), mswPerms() gates every administration
operation except login and logout, and no graduated back-end role was shown to
reach ajax-ops=add. Four named alternative readings are published against that
7.2 so that each rejection is auditable, and one of them is conditional rather
than rejected:

  - Scope Changed, AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H = 9.1. Rejected. The
      vulnerable component is the administration back-end and the impacted
      resources are that application's own files, its own database reached with
      the very credentials the application holds, and the operating-system
      context of the web server process it runs inside. The achieved identity is
      the same service account the application always ran as, on the same host,
      and nothing outside its reach before the upload came into reach
      afterwards. Publishing S:C would require a database-server compromise
      independent of the application's credentials, a container escape or a
      distinct host, and none was demonstrated.
  - Unauthenticated, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H = 9.8. Rejected, and
      this is the tempting headline for a public-facing shopping cart that we
      decline to print as ours. The storefront dispatcher's 56 unauthenticated
      AJAX operations contain no file-upload, command-execution, code-evaluation
      or deserialization primitive - only include() of language and class files -
      the administration login was reviewed on six specific bypass axes with no
      success, and the product ships no factory credential and no usable
      SECRET_KEY.
  - Lower-privileged back-end role, AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H = 8.8.
      Published as a conditional reading, labelled as such and never as the
      headline. The dispatcher exposes users, users-edit, permissions and
      permissions-edit, so the product may model back-end accounts narrower than
      the single installer-provisioned administrator; mswPerms() was established
      in our review only as an authentication gate over the dispatch, not as a
      per-operation authorization matrix; and no principal short of full
      administrator was demonstrated reaching ajax-ops=add. If a restricted
      back-end account can create or update products, that account is the
      least-privileged principal reaching the sink and PR:L is the faithful
      metric, at Impact 5.87311872 plus Exploitability 2.83525473, base
      8.70837345, rounded up. We did not round our rating up to it, because
      doing so would assert a privilege model we did not observe.
  - Elevated complexity, AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H = 6.6, and user
      interaction, AV:N/AC:L/PR:H/UI:R/S:U/C:H/I:H/A:H = 6.8. Both printed only
      so the rejections are inspectable: there is no race, no ordering constraint
      beyond one POST and one GET, no requirement that the target be in an
      unusual state, and no victim action at any point in the chain.

Contributing weaknesses are reported and deliberately not scored into the vector:
the single-round SHA-1 mswEnc() credential transform, loose `==` comparisons on
hash and token strings, the absence of any enforced rotation of the
installer-provisioned secret, and the inconsistency between upload handlers inside
one shipped version. None of them lowers the privilege required to reach the sink
and none raises the impact of the upload once it is reached. There is also no
numeric base score in our own record against this vector that could disagree with
it, so no correction notice accompanies the figure.

Authentication: post-authentication administrator plus the session CSRF token, and
both gates were treated as things to attack rather than as configuration to trust.
There are no factory credentials in Maian Pal 2.1: the installer forces LG_USER,
LG_PASS and SECRET_KEY to be set, `control/_cfg-sample.php:62` ships only the
placeholder text "Your secret key value goes here", and this is not a
default-credential report. The login path was reviewed for a missing or permissive
permission configuration, a route that skips the gate, a predictable CSRF token, a
pre-computable credential hash, a factory-credential fallback, a
comparison-timing difference and a secondary authentication branch, and none was
found; there is no administrator password-reset flow whose token could be
predicted, since changing the credential means editing control/_cfg.php. The
storefront dispatcher is genuinely unauthenticated - it has no mswPerms() call at
all, because customers browse and buy without logging in - and it reaches no
upload handler and no command-execution primitive. The 56 unauthenticated
operations were each classed by the primitives they reach, covering basket and
checkout handling, customer self-service, catalogue reads, a file download,
comments, wishlists and subscriptions; the only file-inclusion sinks found on that
side are include() of language files and of class files, with no
move_uploaded_file(), exec(), system(), eval() or unserialize() anywhere on those
paths. Two limits on that negative result are stated so it is not oversold: it
addresses code execution and does not assert that those endpoints are free of
every other defect class - access-control, injection, information-disclosure and
business-logic defects in the payment-gateway transitions, customer registration,
password reset, order viewing and the dl-file download were not systematically
audited, and dl-file in particular remains an untested path-traversal candidate;
and "no code-execution sink" was established by enumerating primitives on a
plain-text codebase, a method exhaustive for direct calls which does not by itself
prove the absence of a dynamically constructed callback.

One honesty point about the CSRF gate, because we would rather state it than imply
a tested negative. The token comparison sits at `control/_ajax.php:17` and the add
case dispatches at `control/_ajax.php:686`, so the gate is structurally ahead of
the handler and a request reaching the upload has necessarily been evaluated by
it; empirically, the working exploit sends X-CSRF-Token on the login, the category
create and the product create alike and all three were accepted. What we did not
run is the inverse experiment - the same product create with the header omitted -
so enforcement on ajax-ops=add rests on that code position plus a consistently
supplied token rather than on an observed rejection. The token itself is
bin2hex(openssl_random_pseudo_bytes(50)), 100 hexadecimal characters from 50
cryptographically strong bytes, and is not the weak link. Holding credentials
implies holding the token, so it adds no privilege barrier to an authenticated
attacker.

Impact: arbitrary command execution as the web server service account on a
shopping-cart host, which is a high-value identity precisely because the
application is already trusted with everything a shop needs. The service account
must read `control/_cfg.php` to function, so it yields the database credentials,
SECRET_KEY and the administrator credential material the login path transforms -
everything the login gate protects is obtainable directly from disk, together with
any other secret the application stores: payment-gateway keys, PayPal
configuration, mail credentials, third-party API keys. With those database
credentials the 32 installed tables are open, including customer accounts in
shop_accounts, orders and addresses, so the confidentiality impact falls on the
store's customers and not only on its operator. Financial integrity becomes
writable: prices, stock levels, discount rules and order contents are database
rows, and a silent price or order rewrite is invisible in the HTTP access log
because it travels over the application's own database connection. Persistence
and defacement follow from the upload itself: the webshell is an ordinary file in
a directory the application serves, surviving session expiry, service restarts,
administrator logouts and application updates, with additional objects placeable
in theme directories or reachable by modifying an existing storefront template,
and a cleanup that removes only the obviously named object leaving the
tmb_-prefixed sibling behind - deleting the product row through the interface is
not guaranteed to remove the image directory at all. Availability is destroyable:
storefront content, product images, configuration files and the database itself
can be deleted or corrupted with the application's own credential, taking the shop
offline and losing its catalogue. The service account's local view - environment
variables, readable system files, other applications on localhost, outbound
network access from the host - is available for further activity; we pursued none
of it and make no claim that it leads anywhere in a given deployment. The ceiling
is stated as clearly as the impact: no root, no host compromise, no
database-server compromise beyond what the application's own credentials grant, no
container escape and no evidence about other hosts.

Verification boundary, stated plainly. The verification instance was a container
built from the shipped 2.1 archive: an upstream php:8.3-apache image with mysqli
and gd, a separate MySQL container on a private container network, the listener
bound to loopback only, Apache with mod_rewrite enabled and AllowOverride All,
session.save_path on a writable temporary directory, the installer run to
completion with 32 tables created and the installer directory removed afterwards,
the administrator credential chosen by us at installation time because the product
has none to inherit, and a pure-standard-library Python client driven from the
container host. Two of those are configuration choices of ours rather than product
behaviour and are named as such. AllowOverride All is the state in which the
product's own shipped Apache deny files function, so it reproduces the intended
protection model rather than defeating it. The loopback binding is a safety
property of our host: the same chain works identically against a listener on any
interface, since nothing in the flow depends on source address.

The recorded verification runs did not include the shipped control mode, and the
published advisory says so rather than presenting a transcript it does not have.
The proof-of-concept carries a --control mode that repeats the category and product
steps with an ordinary one-pixel GIF named with an image extension and asserts
three things: the stored object is served back as image bytes, the execution
marker never appears in the response, and no .php sibling with the same basename
exists (HTTP 404). A control matters here because the claim is specifically that
the extension governs execution, and if an ordinary image uploaded through the
identical flow were also executed the diagnosis and the fix would both be
different. The mode is shipped so a reader can confirm that distinction on their
own instance in one command; it was not part of the recorded runs. What was run:
three independent executions with distinct random filenames plus the
out-of-web-root marker check, which establish reproducibility and are not
controls, and are not presented as such.

Not verified at all: any nginx or PHP-FPM deployment, any IIS or Windows
deployment, the rename='yes' configuration (argued from the assignment expression
only), extensions other than .php, case variants of the extension, the storefront
download operation's path handling, the un-normalised filename concatenation, the
backup / restore / data-import operations, whether the product's installer ever
writes a deny file into content/products/ on some platform we did not observe, any
non-Apache serving path, and any privilege escalation beyond the service account.
One further structural observation is flagged as untested and is not part of the
finding: $picPath is concatenated directly into the destination, so nothing we
read obviously rejects a crafted filename containing ../, and whether PHP's
multipart parsing or the filesystem layer preserves such separators in practice
was not tested.

Detection notes, including the one indicator that carries most of the weight.
Detection is difficult because the traffic is legitimate: both requests are
operations an administrator is entitled to perform and both return {"status":"ok"},
so the application audit trail is the weakest of four signals and no suspicious
filename is recorded anywhere in it. The highest-value network indicator is a
`GET /content/products/<ID>/<name>.php` - a script-extension request under a
product image directory, almost never legitimate, and stronger still with a query
string such as ?cmd= - preceded by one `POST /admin/index.php?ajax-ops=add` whose
request line is indistinguishable from a normal product create. On the filesystem,
look for non-image extensions inside content/products/*/ and especially for paired
objects where a tmb_-prefixed file shares its original's non-image extension; both
halves must be removed. In the database, product and category rows created outside
the operator's own activity, typically with generated-looking slugs, are worth
correlating - and deleting them does not necessarily remove the image directory.

Mitigation summary: close the handler by resolving the extension from the client
filename in products::pictures(), normalising its case and comparing it against a
fixed permitted set (jpg, jpeg, png, gif, webp) before anything is written,
rejecting everything else with a validation error, and applying that one shared
validator to every file-accepting handler in the product so the attachment path and
the product path cannot drift apart again - note that the existing rename='yes'
option is not a substitute, because that branch still takes the extension from
strrchr($name, '.'); close the directory as a second and equally required change by
making the served upload tree non-executing at server-configuration level rather
than by .htaccess alone, using a vhost FilesMatch denial with SetHandler none,
php_admin_flag engine off, RemoveHandler and RemoveType for the script extensions
and Options -ExecCGI -Indexes, with the equivalent nginx location rule documented,
since a hardened deployment that sets AllowOverride None silently disables every
protection shipped in deny-file form and a patch addressing only one half leaves
the other standing; stop trusting the client filename and derive the stored name
entirely server-side from a random or sequential token plus a fixed extension
chosen from the validated type, normalising away path separators; move uploads out
of the served tree, which removes the second half of the root cause by construction
rather than by configuration; validate before writing and roll back on failure by
moving move_uploaded_file() to after getimagesize() or a finfo check succeeds,
deleting the object when any later step fails, and replacing the suppressed @copy()
fallback in thumbnail() with an explicit error plus cleanup so an object whose type
could not be determined is never duplicated. Reduce the credential blast radius as
a separate change - replace the single-round SHA-1 mswEnc() transform with
password_hash() and PASSWORD_ARGON2ID storing a hash rather than a plaintext
constant, compare with hash_equals() and strict equality, keep SECRET_KEY for
session and cookie signing only, and add a second factor to the back-end login
given that the whole product surface is one administrator credential away from
operating-system command execution. Ship regression tests for both halves: a
behaviour test asserting that a .php upload through the product-image operation is
never served as code, and a packaging test asserting that every writable directory
under the document root able to receive uploads carries an execution restriction -
the second is the one that generalises, because it asserts the consistency property
this finding is really about and a newly added upload directory then cannot ship
unprotected.

Until a fixed build exists the highest-value action needs no vendor patch: add the
execution restriction to your own web server configuration for content/products/,
and for consistency admin/attachments/, admin/import/ and any theme cache
directory; confirm AllowOverride actually applies before relying on any deny file
the product ships and enforce the rule in the vhost where possible; add an edge
rule rejecting script extensions under product-content paths, matched on
^/content/products/.*\.ph(p[3457]?|t|ml|ar)$ and logged as a security event; and
restrict reachability of admin/index.php by source-IP allow-list, authenticating
reverse proxy or VPN so credential possession is not the only barrier. Inventory
the product image directories now for non-image files including tmb_-prefixed
objects and remove both halves of any pair; after suspected abuse treat the
contents of control/_cfg.php - the administrator secret, SECRET_KEY and the
database credentials - and every stored payment-gateway or API credential as
disclosed, rotate all of them, and assume the database contents were readable. Do
not assume the rename thumbnail option protects you.

The proof of concept, maian_pal_product_image_upload_webshell_rce.py in the
exploit directory of this advisory's repository tree, is pure standard-library
Python, takes every environment-specific value as a command-line argument, and
removes the uploaded object by default, printing the exact web path left behind
if --keep is passed; it creates one category row and one product row, deletes no
product data, contacts only the target named on the command line, and refuses to
run without a real administrator password since the product ships no factory
credential. It offers three modes: the exploit chain, --control (an ordinary
single-pixel GIF carrying an image extension, plus three assertions that nothing
executed), and an optional read-only egress callback issued from the target
through the payload to demonstrate outbound reach without modifying anything.

Full technical analysis and a reproducible proof-of-concept:
  https://0day-rubbish.com/blog/maian-pal-product-image-upload-webshell-rce

Project archive (ongoing disclosure series):
  https://github.com/Exploit-Garbage/0day-Rubbish

Maian Media has been notified through the support mailbox it publishes for its
products, which is the only channel we could verify: we found no RFC 9116
security.txt and no product security page or vulnerability-disclosure policy for
this product, so there was no pre-publication intake for us to use. A separate
notice about a different product from the same seller, Maian Guardian, was sent to
the same mailbox at the same time; the two are unrelated defects in unrelated
products, and we asked that both be routed to whoever maintains the software. This
disclosure is unconditional. No vulnerability identifier has been assigned to this
finding yet.

--
0day Rubbish Research Team
disclosure () 0day-rubbish com
https://0day-rubbish.com
_______________________________________________
Sent through the Full Disclosure mailing list
https://nmap.org/mailman/listinfo/fulldisclosure
Web Archives & RSS: https://seclists.org/fulldisclosure/