Repository navigation
Expand file tree
/
Copy pathmagecheck.sh
More file actions
1187 lines (1095 loc) · 59.2 KB
/
Copy pathmagecheck.sh
File metadata and controls
1187 lines (1095 loc) · 59.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
#
# magecheck.sh v7.1 (was check-stylesmuggler.sh)
# Magento 2 patch-level and compromise check, run ON the server.
#
# Covers: the September 2026 bulletin (VULN-39341 / APSB26-146 for CVE-2026-75650
# "StyleSmuggler", plus the separate 2026-09-001 bundle), the July and August
# bulletins, host implants and persistence, poisoned logs and reports, access-log
# evidence, database persistence, a code-change audit, and mitigation status.
#
# v5: Adobe shipped the official fix on 7 Sep 2026 (APSB26-146, hotfix VULN-39341).
# The script now detects that patch directly from its code markers, treats it as
# the authoritative control, and reports the temporary stop-gaps (DI-scanner CLI
# guard, hardening modules, nginx rules) as removable rather than required.
# A patched store is not automatically a clean store: Adobe requires rotating the
# encryption key and every credential encrypted with it. See the summary.
#
# v7.1: runs streamed from a pipe (curl | bash) with no install; patch-wiring
# checks follow extra.patches-file instead of assuming composer.json; the
# vendor/ mtime audit separates files a detected patch is supposed to touch
# from genuinely unexplained ones.
#
# v7: renamed. This is no longer a single-CVE checker — it covers the September
# 2026 bulletin as a whole (VULN-39341 plus the 2026-09-001 bundle), the
# July and August bulletins, host implants, logs, database persistence and
# the code audit. Keep a symlink if muscle memory wants the old name:
# ln -s magecheck.sh check-stylesmuggler.sh
#
# v6: reconciled against Sansec's IOC list (article of 5 Sep 2026):
# - exact fake-kernel-thread name [kworker/u:8:0], checked for ANY user
# - loose X_TRACE_ marker in var/report (Sansec's own grep has no hex suffix)
# - access-log: log-poisoning via /customer/section/load/ with a PHP tag in the
# User-Agent, and the PayPal transparent-response webshell
# - Payment Transaction Failed Reminder bursts, the stage-2 trigger
# - patch-status is not evidence: victim #1 was fully patched and clean
#
# Refs: https://sansec.io/research/stylesmuggler-0day
# https://helpx.adobe.com/security/products/magento/apsb26-146.html
# https://experienceleague.adobe.com/en/docs/commerce-knowledge-base/kb/announcements/commerce-apsb26-146
# https://sansec.io/research/stylesmuggler
# https://github.com/disrex-group/stylesmuggler-mitigation (IOC, sink patch)
# https://github.com/muench-dev/StyleSmuggler (fc-cache variant, C2)
# https://github.com/graycoreio/magento2-style-smuggler-patch (gadget hardening)
# https://github.com/brideo/stylesmuggler-patch (entry-side guard)
#
# Usage:
# ./check-stylesmuggler.sh [-r ROOT] [-u URL] [-p] [-d] [-l] [-c N] [-q]
#
# -r Magento root (default: current dir, walk up, then Hypernode defaults)
# -u Base URL — live canary test of the edge rules
# -p Extended persistence hunt (dotfiles, timers, ssh keys, auto_prepend_file)
# -d Database checks (CMS, config, admin users, email templates)
# -l Access log analysis
# -c Code-change audit window in days (default 30, 0 disables)
# -q Quiet: only print findings
#
# Exit codes:
# 0 official patch applied, no IOCs
# 1 indicators of compromise found
# 2 unpatched (this CVE or another bulletin) and no effective mitigation
# 3 unpatched, stop-gap mitigation in place
# 4 patch partially applied — some hunks missing, treat as unpatched
#
# Read-only. The only outbound action is the optional canary GET (-u).
#
# IOC strings are assembled from fragments so scanners like eComscan don't flag
# THIS file. Run it from OUTSIDE your webroot.
#
# Re-exec under bash for [[ ]] and process substitution — but only when $0 is a
# real file. Piped from curl, $0 is "sh" and there is nothing to re-exec, so the
# caller must pipe to bash directly: curl -sSL <url> | bash -s -- -r <root>
if [ -z "${BASH_VERSION:-}" ]; then
if [ -r "$0" ] && [ "$0" != "sh" ] && [ "$0" != "-sh" ]; then
exec bash "$0" "$@"
fi
echo "magecheck: needs bash. Pipe to bash, not sh:" >&2
echo " curl -sSL <url> | bash -s -- -r /data/web/magento2" >&2
exit 64
fi
set -uo pipefail
ROOT="."; BASE_URL=""; CHECK_DB=0; CHECK_LOGS=0; CHECK_PERSIST=0; QUIET=0; CODE_DAYS=30
# Adobe fixed CVE-2026-75650 with hotfix VULN-39341, not with a version bump, so
# there is no version that is inherently safe. Detection is by code marker below.
# If a future release folds the fix in, set e.g. PATCHED_MIN="2.4.9-p1".
PATCHED_MIN=""
while getopts ":r:u:c:pdlqh" opt; do
case "$opt" in
r) ROOT="$OPTARG" ;; u) BASE_URL="${OPTARG%/}" ;; c) CODE_DAYS="$OPTARG" ;;
p) CHECK_PERSIST=1 ;; d) CHECK_DB=1 ;; l) CHECK_LOGS=1 ;; q) QUIET=1 ;;
h) if [ -r "$0" ]; then sed -n '2,40p' "$0"
else
echo "magecheck — Magento 2 patch level & compromise check (run on the server)"
echo " -r <path> Magento root -c <days> code-audit window"
echo " -p extended persistence -d database -l access logs"
echo " -q quiet (warn and worse) -u <url> canary probe target"
echo "Exit: 0 patched 1 IOC 2 unpatched 3 stop-gap only 4 partial patch"
fi
exit 0 ;;
*) echo "unknown option -$OPTARG"; exit 64 ;;
esac
done
if [ -t 1 ]; then
R=$'\e[31m'; Y=$'\e[33m'; G=$'\e[32m'; B=$'\e[1m'; N=$'\e[0m'
else R=""; Y=""; G=""; B=""; N=""; fi
IOC=0; CODE_VULN=0; EDGE=-1; SINK=0; GADGET=0; GAPS=0; LAYERS=""
PATCHED=0; PARTIAL=0; OTHER_VULN=0; STOPGAPS=""
hdr() { [ "$QUIET" -eq 1 ] || printf '\n%s== %s ==%s\n' "$B" "$1" "$N"; }
ok() { [ "$QUIET" -eq 1 ] || printf '%s [ok]%s %s\n' "$G" "$N" "$1"; }
info() { [ "$QUIET" -eq 1 ] || printf ' [--] %s\n' "$1"; }
warn() { printf '%s [warn]%s %s\n' "$Y" "$N" "$1"; GAPS=1; }
bad() { printf '%s [BAD]%s %s\n' "$R" "$N" "$1"; IOC=1; }
# vuln(): red, but a missing patch is a vulnerability, NOT an indicator of
# compromise. v4 used bad() here, so any unpatched store reported COMPROMISED.
vuln() { printf '%s [VULN]%s %s\n' "$R" "$N" "$1"; CODE_VULN=1; }
list() { printf ' %s\n' $1; }
# --- indicators, fragmented ---------------------------------------------------
IOC_DOM="cdnfla""re"
IOC_HOST="247.${IOC_DOM}"".""xyz"
C2_1="99.84.""67.186"; C2_2="209.141.""43.95"
SRC_1="88.216.""72.181"; SRC_2="5.181.""86.133"
C2_IPS="${C2_1}|${C2_2}|${SRC_1}|${SRC_2}"
# NTP/UDP-123 tunnelled C2 domains — not visible in socket state, only DNS logs
NTP_DOMS="windwsecur""ity.run|ntp.time""sync.to|ntp.sync""time.to|ntp.timesys""nc.net|time.micro""sft.run"
H1="e315687a1dfe61ef4a5a5642214db6d3b2b05d81""391285eebc2af664641a26a7"
H2="8334b434fa3fe9f59cebe9609b11e0b1fd19d102""12c45c705adec1902a1d06ef"
H3="251fabd50d7b18a8b5e1b3ef5d64e7198c172447""78f6461fb1ab07f6169bf220"
HASHES="$H1|$H2|$H3"
TRACE_RX='X[_-]TRACE[_-][0-9A-Fa-f]{10}'
IMPLANT_RX='gvfsd-user|fontconfig/fc-cache|/tmp/\.kw|/tmp/\.fc|\.kw_'
ME="$(id -un)"
# Used to exclude this script from its own webroot scan. When streamed from a
# pipe there IS no file on disk, and $0 is "bash" — excluding the string "bash"
# would silently drop real findings, so use a sentinel that matches nothing.
if [ -r "$0" ]; then SELF="$(basename "$0")"; else SELF="__magecheck_not_on_disk__"; fi
# Where a patch declaration can legitimately live. composer.json is only one of
# them: both patch plugins support extra.patches-file pointing at a separate
# JSON, and cweagans v2 records what it applied in patches.lock.json. Grepping
# composer.json alone reports "applied by hand" for a correctly wired project.
patch_decl_files() {
FILES=""
for f in composer.json patches.lock.json patches.json; do
[ -f "$f" ] && FILES="$FILES $f"
done
# follow extra.patches-file wherever it points
if [ -f composer.json ]; then
PF=$(grep -oE '"patches-file"[[:space:]]*:[[:space:]]*"[^"]+"' composer.json 2>/dev/null \
| sed 's/.*"\([^"]*\)"$/\1/')
for f in $PF; do
case " $FILES " in *" $f "*) ;; *) [ -f "$f" ] && FILES="$FILES $f" ;; esac
done
fi
printf '%s' "$FILES"
}
# --- locate Magento root ------------------------------------------------------
CAND="$(cd "$ROOT" 2>/dev/null && pwd)"
if [ -n "$CAND" ]; then
while [ ! -f "$CAND/app/etc/env.php" ] && [ ! -f "$CAND/bin/magento" ] && [ "$CAND" != "/" ]; do
CAND="$(dirname "$CAND")"
done
fi
if [ ! -f "$CAND/app/etc/env.php" ] && [ ! -f "$CAND/bin/magento" ]; then
CAND=""
for c in /data/web/magento2 /data/web/public /data/web "$HOME/magento2" "$HOME/public" /app; do
[ -f "$c/app/etc/env.php" ] && { CAND="$c"; break; }
done
fi
ROOT="$CAND"
hdr "magecheck v7 — $(date)"
if [ -n "$ROOT" ]; then cd "$ROOT" || exit 64; info "Magento root: $ROOT"
else warn "No Magento root found — host checks still run, webroot checks skipped."; fi
WEBSERVER="unknown"
if pgrep -x nginx >/dev/null 2>&1 || [ -d /etc/nginx ]; then WEBSERVER="nginx"; fi
if pgrep -x apache2 >/dev/null 2>&1 || pgrep -x httpd >/dev/null 2>&1 \
|| [ -d /etc/apache2 ] || [ -d /etc/httpd ]; then
[ "$WEBSERVER" = "nginx" ] && WEBSERVER="nginx+apache" || WEBSERVER="apache"
fi
info "Web server: $WEBSERVER user: $ME"
# --- 1. version ---------------------------------------------------------------
hdr "1. Version & patch status"
info "Sansec's first confirmed victim ran 2.4.6-p15 with the July AND August 2026"
info " patches applied and 'bin/magento security:patch-status' reporting clean."
info " A clean patch status is not evidence of anything here. Section 1b is."
VERSION=""
if [ -n "$ROOT" ] && [ -f composer.lock ] && command -v php >/dev/null 2>&1; then
VERSION=$(php -r '
$l = json_decode(file_get_contents("composer.lock"), true);
foreach (array_merge($l["packages"] ?? [], $l["packages-dev"] ?? []) as $p) {
if (in_array($p["name"], ["magento/product-community-edition","magento/product-enterprise-edition"], true)) {
echo $p["version"]; exit; } }' 2>/dev/null)
fi
CLEANVER="${VERSION#v}"; NUMVER="$(printf '%s' "$CLEANVER" | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+')"
if [ -z "$NUMVER" ]; then CODE_VULN=1; warn "Could not determine Magento version"
else info "Detected version: $CLEANVER"; fi
if [ -z "$PATCHED_MIN" ]; then
info "No release contains the fix — VULN-39341 is a hotfix patch, so the version"
info " string proves nothing either way. Checked by code marker in 1b below."
elif [ -n "$NUMVER" ] && [ "$(printf '%s\n%s\n' "$NUMVER" "$PATCHED_MIN" | sort -V | head -1)" = "$PATCHED_MIN" ]; then
ok "Version $CLEANVER >= $PATCHED_MIN — contains the fix"
else CODE_VULN=1; info "Version $CLEANVER predates $PATCHED_MIN"; fi
# --- 1b. official fix: APSB26-146 / VULN-39341 (CVE-2026-75650) ---------------
hdr "1b. Official patch — APSB26-146 / VULN-39341 (StyleSmuggler)"
# Released 7 Sep 2026, after in-the-wild exploitation. It is a hotfix patch, not
# a release, so composer.lock cannot tell you anything: these markers are read
# straight out of the patched files. The patch spans four composer packages plus
# one root file, and the root file is the one most often forgotten.
if [ -n "$ROOT" ]; then
P_OK=0; P_TOTAL=0; P_MISS=""
pmark() { # pmark <file> <grep-pattern> <label>
[ -f "$1" ] || { info " $3: file not present — skipped"; return; }
P_TOTAL=$((P_TOTAL+1))
if grep -q "$2" "$1" 2>/dev/null; then P_OK=$((P_OK+1))
else P_MISS="${P_MISS}${3}|"; fi
}
# framework — the two that matter most: block instantiation and report writing
pmark vendor/magento/framework/View/Element/BlockFactory.php \
'getInstanceType' 'framework/BlockFactory (type checked before create)'
pmark vendor/magento/framework/Webapi/ErrorProcessor.php \
'REPORT_EXECUTION_GUARD' 'framework/Webapi ErrorProcessor (report guard)'
pmark vendor/magento/framework/View/Layout/Generator/Block.php \
'LogicException' 'framework/Layout Generator (catches LogicException)'
# backend — validate class name before the object manager builds it
pmark vendor/magento/module-backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php \
'is_a(\$generatorClassName' 'module-backend/UrlGeneratorFactory'
# email / newsletter — admin ACL on previews, string-only template setters
pmark vendor/magento/module-email/Model/AbstractTemplate.php \
'function setTemplateStyles' 'module-email/AbstractTemplate (typed setters)'
pmark vendor/magento/module-email/Block/Adminhtml/Template/Preview.php \
'ADMIN_RESOURCE' 'module-email/Template Preview (ACL)'
pmark vendor/magento/module-newsletter/Block/Adminhtml/Template/Preview.php \
'ADMIN_RESOURCE' 'module-newsletter/Template Preview (ACL)'
# root file — NOT a composer package, so composer-patches cannot apply it
pmark pub/errors/processor.php \
'REPORT_EXECUTION_GUARD' 'pub/errors/processor.php (root file)'
if [ "$P_TOTAL" -eq 0 ]; then
CODE_VULN=1; warn "VULN-39341: none of the target files found — check by hand"
elif [ "$P_OK" -eq "$P_TOTAL" ]; then
PATCHED=1
ok "VULN-39341 applied — all ${P_OK}/${P_TOTAL} markers present"
ok " CVE-2026-75650 is fixed in code. Patching does not undo a compromise:"
ok " see the rotation checklist in the summary."
elif [ "$P_OK" -eq 0 ]; then
CODE_VULN=1
vuln "VULN-39341 NOT applied — CVE-2026-75650, unauth RCE, exploited in the wild"
info " Hotfix: https://repo.magento.com/patch/VULN-39341-composer-patches.zip"
info " Bulletin: APSB26-146 (7 Sep 2026)"
else
CODE_VULN=1; PARTIAL=1
vuln "VULN-39341 only PARTIALLY applied (${P_OK}/${P_TOTAL}) — treat as unpatched"
info " Missing:"
printf '%s' "$P_MISS" | tr '|' '\n' | grep . | while read -r m; do info " - $m"; done
info " A part-applied patch is the worst state: it looks done and is not."
case "$P_MISS" in
*pub/errors*) info " pub/errors/processor.php is a ROOT file. cweagans/composer-patches"
info " only patches vendor packages — apply that hunk with git and commit it." ;;
esac
fi
# wiring: does it survive composer install / a vendor rebuild?
if [ "$P_OK" -gt 0 ]; then
DECL=$(patch_decl_files)
if [ -n "$DECL" ] && grep -qiE 'VULN-39341|APSB26-146' $DECL 2>/dev/null; then
ok " Wired into$DECL — survives composer install"
else
warn " Patch markers present but nothing wires them into composer.json."
warn " composer install will silently revert the vendor hunks. Add them to"
warn " extra.patches (cweagans/composer-patches) before your next deploy."
fi
fi
# stale pre-patch reports: written before the guard existed, still executable
if [ -d var/report ]; then
OLDREP=$(grep -rLsF '<?php exit' var/report 2>/dev/null | grep -v '/\.' | head -20)
OLDN=$(printf '%s\n' "$OLDREP" | grep -c . )
if [ "${OLDN:-0}" -gt 0 ] && [ "$PATCHED" -eq 1 ]; then
warn "$OLDN report file(s) in var/report predate the patch and carry no execution"
warn " guard. The patch does not retro-fix files already on disk — delete them:"
warn " find var/report -type f -delete"
fi
fi
fi
# Adobe's isolated patches do NOT change the version string, so composer.lock
# cannot tell you whether they are applied. They are also non-cumulative and
# order-dependent: each is built against one exact patch level, and August
# stacks on July rather than replacing it.
#
# Markers below are taken from the CE patch contents for the 2.4.8-p5 base
# (248p5-2026-07-001-CE.patch and 248p5-2026-08-001-CE.patch):
# July — adds a new file, Quote/Model/GuestCart/GetGuestCart.php
# Aug — Customer Controller/Account/Edit.php gains an allowed-attribute
# filter (the CVE-2026-71362 account-takeover fix), and underscore.js
# is bumped 1.13.6 -> 1.13.8
# Other base versions patch the same areas but not always the same files, so
# treat a NOT-APPLIED result on a non-2.4.8 store as "check by hand".
if [ -n "$ROOT" ]; then
JUL=-1; AUG=-1
JULF="vendor/magento/module-quote/Model/GuestCart/GetGuestCart.php"
[ -e "$JULF" ] && JUL=1 || JUL=0
AUGF="vendor/magento/module-customer/Controller/Account/Edit.php"
if [ -f "$AUGF" ]; then
grep -q 'getAllowedAttributes' "$AUGF" 2>/dev/null && AUG=1 || AUG=0
fi
USCORE=""
for u in lib/web/underscore.js vendor/magento/magento2-base/lib/web/underscore.js; do
[ -f "$u" ] && USCORE="$USCORE $(grep -m1 -oE 'Underscore\.js [0-9.]+' "$u" 2>/dev/null)"
done
if [ "$JUL" -eq 1 ]; then ok "APSB26-73 (Jul) appears applied — GetGuestCart.php present"
else
CODE_VULN=1
OTHER_VULN=1
vuln "APSB26-73 (Jul) NOT applied — CVE-2026-48358, unauth RCE, CVSS 10.0"
info " Marker missing: $JULF"
fi
case "$AUG" in
1) ok "APSB26-92 (Aug) appears applied — Account/Edit.php filters allowed attributes" ;;
0) CODE_VULN=1
OTHER_VULN=1
vuln "APSB26-92 (Aug) NOT applied — CVE-2026-71362, unauth account takeover"
info " Adobe rated it no-known-exploits; that held for about two days."
info " Marker missing in: $AUGF" ;;
*) info "APSB26-92: $AUGF not found — check by hand" ;;
esac
[ -n "$USCORE" ] && info "underscore.js:$USCORE (Aug patch bumps 1.13.6 -> 1.13.8)"
if [ "$JUL" -eq 0 ] && [ "$AUG" -eq 0 ]; then
info " Both are isolated .patch files from Adobe, not composer releases."
info " They stack: July must go on before August. Both target exactly 2.4.8-p5."
elif [ "$JUL" -eq 0 ] && [ "$AUG" -eq 1 ]; then
warn " August applied without July — these are non-cumulative and ordered."
warn " Verify the July hunks really landed; a forced out-of-order apply is fragile."
fi
# how they were wired in, which decides whether they survive composer install
if grep -rqi 'samjuk/m2-meta-security-patches' composer.json composer.lock 2>/dev/null; then
ok "Patches managed by samjuk/m2-meta-security-patches — survives composer install"
elif [ -n "$(patch_decl_files)" ] && \
grep -qiE '2026-0[78]-001|APSB26-(73|92)' $(patch_decl_files) 2>/dev/null; then
ok "Isolated patches wired into$(patch_decl_files) — survives composer install"
elif [ "$JUL" -eq 1 ] || [ "$AUG" -eq 1 ]; then
warn "Patch markers present but nothing wires them into composer.json."
warn " Applied by hand? composer install will silently revert them."
fi
fi
# --- 1c. September bundle: 2026-09-001 ----------------------------------------
hdr "1c. September bundle — 2026-09-001 (8 packages)"
# Shipped the same day as VULN-39341 but a SEPARATE, non-overlapping patch set:
# backup rollback ACL, GraphQL customer context, export path traversal, instant
# purchase IDOR, PayPal quote binding, admin XSS, and Escaper entity decoding.
# Applying one does not apply the other, and VULN-39341 gets all the attention —
# so this is the set that quietly goes missing.
if [ -n "$ROOT" ]; then
B_OK=0; B_TOTAL=0; B_MISS=""
bmark() { # bmark <file> <pattern> <label>
[ -f "$1" ] || { info " $3: file not present — skipped"; return; }
B_TOTAL=$((B_TOTAL+1))
if grep -qF "$2" "$1" 2>/dev/null; then B_OK=$((B_OK+1))
else B_MISS="${B_MISS}${3}|"; fi
}
bmark vendor/magento/framework/Escaper.php \
'decodeHtmlEntitiesToFixedPoint' 'framework/Escaper (entity decode loop)'
bmark vendor/magento/module-backup/Controller/Adminhtml/Index/Rollback.php \
"Magento_Backup::rollback" 'module-backup/Rollback (admin ACL)'
bmark vendor/magento/module-customer-graph-ql/Model/Context/AddUserInfoToContext.php \
'HttpRequest' 'module-customer-graph-ql/AddUserInfoToContext'
bmark vendor/magento/module-import-export/Controller/Adminhtml/Export/File/Delete.php \
'FileInfo' 'module-import-export/Export File Delete (traversal)'
bmark vendor/magento/module-instant-purchase/Model/InstantPurchaseOptionLoadingFactory.php \
'getCustomerId() !== $customerId' 'module-instant-purchase (address IDOR)'
bmark vendor/magento/module-paypal/Controller/Express/AbstractExpress.php \
'isQuoteAllowedForUser' 'module-paypal/AbstractExpress (quote binding)'
bmark vendor/magento/module-sales/view/adminhtml/web/order/create/scripts.js \
'escapedVat' 'module-sales/order create scripts.js (admin XSS)'
if [ "$B_TOTAL" -eq 0 ]; then
info "None of the 2026-09-001 target files are present — skipped"
elif [ "$B_OK" -eq "$B_TOTAL" ]; then
ok "2026-09-001 applied — all ${B_OK}/${B_TOTAL} markers present"
elif [ "$B_OK" -eq 0 ]; then
OTHER_VULN=1
vuln "2026-09-001 (Sep bundle) NOT applied — ${B_TOTAL} package(s) unpatched"
info " Separate from VULN-39341 and easy to miss: same day, different set."
info " https://github.com/qoliber/magento-open-source-VULN-39341"
else
OTHER_VULN=1
vuln "2026-09-001 only PARTIALLY applied (${B_OK}/${B_TOTAL})"
info " Missing:"
printf '%s' "$B_MISS" | tr '|' '\n' | grep . | while read -r m; do info " - $m"; done
fi
if [ "$B_OK" -gt 0 ]; then
DECL=$(patch_decl_files)
if [ -n "$DECL" ] && grep -qiE '2026-09-001' $DECL 2>/dev/null; then
ok " Wired into$DECL — survives composer install"
else
warn " 2026-09-001 markers present but no declaration found in$DECL"
warn " Applied by hand? composer install will silently revert it."
fi
fi
# scripts.js is a static asset: patching vendor/ is not enough, the deployed
# copy under pub/static keeps serving the old file until a redeploy.
if [ "$B_OK" -gt 0 ] && [ -d pub/static/adminhtml ]; then
DEPLOYED=$(grep -rlF 'escapedVat' pub/static/adminhtml 2>/dev/null | head -1)
STALE=$(find pub/static/adminhtml -name 'scripts.js' -path '*order/create*' 2>/dev/null | head -1)
if [ -z "$DEPLOYED" ] && [ -n "$STALE" ]; then
warn "vendor/ is patched but the DEPLOYED admin scripts.js still has the old code."
warn " 2026-09-001 patches a JS asset — run: bin/magento setup:static-content:deploy"
fi
fi
fi
# --- 2. host persistence & implants -------------------------------------------
hdr "2. Host persistence & implants"
# 2a. cron: own crontab, own spool, /etc/cron.d, and other users' spools if root
CRONSRC=$( { crontab -l 2>/dev/null; cat /etc/cron.d/* 2>/dev/null; } )
CRONHITS=$(printf '%s\n' "$CRONSRC" | grep -cE "$IMPLANT_RX")
SPOOLHITS=0; SPOOLNOTE=""
if [ -r "/var/spool/cron/crontabs/$ME" ]; then
SPOOLHITS=$(grep -cE "$IMPLANT_RX" "/var/spool/cron/crontabs/$ME" 2>/dev/null)
elif sudo -n true 2>/dev/null; then
SPOOLHITS=$(sudo -n grep -rcE "$IMPLANT_RX" /var/spool/cron/crontabs/ 2>/dev/null | awk -F: '{s+=$2} END{print s+0}')
SPOOLNOTE=" (all users, via sudo)"
else SPOOLNOTE=" (not readable — ask your host)"; fi
if [ "${CRONHITS:-0}" -gt 0 ] || [ "${SPOOLHITS:-0}" -gt 0 ]; then
bad "cron persistence: ${CRONHITS} in crontab//etc/cron.d, ${SPOOLHITS} in spool${SPOOLNOTE}"
printf '%s\n' "$CRONSRC" | grep -E "$IMPLANT_RX" | sort | uniq -c | sed 's/^/ /'
info " The implant re-appends this within seconds while running."
info " Order: clean cron -> kill processes -> remove files -> clean cron AGAIN."
else
ok "No implant cron entry (crontab, /etc/cron.d, spool${SPOOLNOTE})"
fi
# 2b. dropped files — gvfsd AND fc-cache variants
FOUND=$(ls -d "$HOME/.local/share/.gvfsd" "$HOME/.local/share/.gvfsd/gvfsd-user" \
"$HOME"/.local/share/.gvfsd/.gvfsd_*.lock /tmp/.gvfsd_*.lock /tmp/.gvfsd-* \
/tmp/.kw_* "$HOME/.cache/fontconfig/fc-cache" /tmp/fc-cache /tmp/.fc* 2>/dev/null)
if [ -n "$FOUND" ]; then
bad "Implant paths present:"
ls -lad --time-style=full-iso $FOUND 2>/dev/null | sed 's/^/ /'
else
ok "No implant files (gvfsd or fc-cache variants) in ~ or /tmp"
fi
# the legitimate fc-cache lives in /usr/bin; one in a home dir is the implant
if [ -e "$HOME/.cache/fontconfig/fc-cache" ]; then
bad "fc-cache binary in the user font cache — the real one is /usr/bin/fc-cache"
fi
# 2c. processes wearing kernel-thread names, plus named implant variants
FAKEK=$(ps -eo pid,user,rss,args --no-headers 2>/dev/null \
| awk '$4 ~ /^\[/ && $2 != "root" {print}')
if [ -n "$FAKEK" ]; then
bad "Non-root process wearing a kernel-thread name (pid user rss args):"
printf ' %s\n' "$FAKEK"
info " Hash the RUNNING binary before killing it: cp /proc/<pid>/exe /tmp/sample.bin"
else ok "No non-root process with a bracketed kernel-thread name"; fi
# The implant disguises itself as [kworker/u:8:0]. Real kernel threads are named
# [kworker/u8:0] — no colon after the 'u'. That extra colon is the tell, and it
# is checked for every user because 2c above only catches non-root processes.
KW=$(ps -eo pid,user,args --no-headers 2>/dev/null \
| grep -E '\[kworker/u:[0-9]+:[0-9]+\]' | grep -v ' grep ')
if [ -n "$KW" ]; then
bad "Process disguised as a kernel thread with the implant's exact name:"
printf ' %s\n' "$KW"
info " Real kernel threads have no colon after 'u' ([kworker/u8:0])."
info " Capture it before killing: cp /proc/<pid>/exe /tmp/sample.bin"
else ok "No [kworker/u:N:N] impostor process"; fi
NAMED=$(ps -eo pid,user,args --no-headers 2>/dev/null \
| grep -E 'gvfsd-user|\.cache/fontconfig/fc-cache' | grep -v ' grep ')
if [ -n "$NAMED" ]; then
bad "Named implant process running:"; printf ' %s\n' "$NAMED"
else ok "No gvfsd-user or user-space fc-cache process"; fi
# 2d. processes whose binary was deleted from disk — classic evasion
DEL=$(ls -l /proc/*/exe 2>/dev/null | grep -i 'deleted' | grep -vE '/(usr|lib)/' | head -10)
if [ -n "$DEL" ]; then
warn "Process(es) running from a deleted binary — inspect each:"
printf ' %s\n' "$DEL"
else ok "No processes running from deleted binaries"; fi
# --- 3. extended persistence hunt (-p) ----------------------------------------
if [ "$CHECK_PERSIST" -eq 1 ]; then
hdr "3. Extended persistence hunt"
DOT=$(grep -lE "$IMPLANT_RX|kworker|base64 -d" \
"$HOME"/.bashrc "$HOME"/.bash_profile "$HOME"/.profile \
"$HOME"/.zshrc "$HOME"/.bash_login 2>/dev/null)
if [ -n "$DOT" ]; then bad "Suspicious lines in shell dotfiles:"; list "$DOT"
else ok "Shell dotfiles clean"; fi
if [ -s "$HOME/.ssh/authorized_keys" ]; then
warn "authorized_keys present — confirm every key is yours:"
awk '{print " " $1, $3}' "$HOME/.ssh/authorized_keys" 2>/dev/null
else ok "No authorized_keys"; fi
TIM=$(ls -1 "$HOME"/.config/systemd/user/ 2>/dev/null)
if [ -n "$TIM" ]; then warn "User systemd units present:"; list "$TIM"
else ok "No user systemd units"; fi
AT=$(atq 2>/dev/null)
if [ -n "$AT" ]; then warn "at jobs queued:"; printf ' %s\n' "$AT"
else ok "No at jobs"; fi
if [ -n "$ROOT" ]; then
AP=$(grep -rns 'auto_prepend_file\|auto_append_file' .user.ini .htaccess app/etc/ 2>/dev/null)
if [ -n "$AP" ]; then bad "auto_prepend/append_file — runs on EVERY PHP request:"
printf ' %s\n' "$AP"
else ok "No auto_prepend_file in the webroot"; fi
MP=$(find pub/media -type f -name '*.ph*' 2>/dev/null | head -20)
if [ -n "$MP" ]; then bad "PHP files under pub/media:"; list "$MP"
else ok "No PHP files under pub/media"; fi
fi
if [ -e /etc/ld.so.preload ]; then bad "/etc/ld.so.preload exists"
else ok "No /etc/ld.so.preload"; fi
fi
# --- 4. poisoned log & report files -------------------------------------------
hdr "4. Poisoned log & report files"
if [ -n "$ROOT" ]; then
TARGETS=""
# var/report is scanned recursively, which covers var/report/api — where the
# Web API fatal-error reports land. Don't list it separately or hits double up.
for d in var/report var/log; do [ -d "$d" ] && TARGETS="$TARGETS $d"; done
if [ -n "$TARGETS" ]; then
TR=$(grep -rlIE "$TRACE_RX" $TARGETS 2>/dev/null | head -20)
if [ -n "$TR" ]; then bad "X-TRACE trigger header written into:"; list "$TR"
else ok "No X-TRACE marker in$TARGETS"; fi
# Sansec greps for a bare X_TRACE_ with no hex suffix. The strict pattern
# above misses a renamed or shorter trigger, so run the loose one too and
# report it separately — it has a higher false-positive rate.
TRL=$(grep -rlIE 'X_TRACE_' $TARGETS 2>/dev/null | head -20)
# drop files the strict check already reported, without an empty-pattern
# grep -f, which would filter out everything when $TR is empty
if [ -n "$TR" ]; then
TRL=$(printf '%s\n' "$TRL" | grep -vxF "$TR" 2>/dev/null)
fi
if [ -n "$TRL" ]; then
bad "Loose X_TRACE_ marker (no hex suffix) in:"; list "$TRL"
fi
# '<?' generically: the Web API report poisoning uses a bare open tag
PH=$(grep -rlI -e '<?' -e 'eval(base64_decode' -e 'base64_decode' $TARGETS 2>/dev/null | head -20)
if [ -n "$PH" ]; then
bad "PHP open tag or eval payload in log/report files (stage 1):"
for f in $PH; do info " $f — $(grep -acE '<\?' "$f" 2>/dev/null) line(s) with an open tag"; done
else ok "No PHP open tags in$TARGETS"; fi
# exploitation leaves a distinctive fatal in system.log
TE=$(grep -cE 'TypeError.*array_merge' var/log/system.log 2>/dev/null)
if [ "${TE:-0}" -gt 0 ]; then
bad "${TE} array_merge TypeError(s) in system.log — exploitation attempt signature"
grep -E 'TypeError.*array_merge' var/log/system.log 2>/dev/null | head -3 | cut -c1-160 \
| sed 's/^/ /'
else ok "No array_merge TypeError signature in system.log"; fi
else info "No var/report or var/log — skipped"; fi
# Stage 2 fires while Magento RENDERS the "Payment Transaction Failed Reminder"
# email. Nobody has to open it, and the attack still succeeds if delivery fails
# — so absence of these messages proves nothing. A burst is worth investigating;
# genuine declined payments produce the same mail.
MAILLOG=""
for m in /var/log/mail.log /var/log/maillog /data/web/var/log/mail.log; do
[ -r "$m" ] && MAILLOG="$MAILLOG $m"
done
if [ -n "$MAILLOG" ]; then
PF=$(grep -haic 'payment transaction failed' $MAILLOG 2>/dev/null | awk '{s+=$1} END{print s+0}')
if [ "${PF:-0}" -gt 20 ]; then
warn "$PF 'Payment Transaction Failed' messages in the mail log — check whether"
warn " they cluster in time. Stage 2 triggers this mail deliberately."
else info "Payment-failure mails in mail log: ${PF:-0} (no burst)"; fi
else
info "No readable mail log. Ask the merchant whether they saw a burst of"
info " 'Payment Transaction Failed Reminder' mails — that is the stage-2 trigger."
fi
fi
# --- 5. payload hashes & webshells --------------------------------------------
hdr "5. Payload hashes & webshells"
if command -v sha256sum >/dev/null 2>&1; then
M=$(find /tmp "$HOME/.local/share" "$HOME/.cache" ${ROOT:+"$ROOT/var"} ${ROOT:+"$ROOT/pub"} \
-type f -size -20M 2>/dev/null | xargs -r sha256sum 2>/dev/null | grep -iE "$HASHES")
if [ -n "$M" ]; then bad "File matches a known payload hash:"; printf ' %s\n' "$M"
else ok "No file on disk matches a known payload hash"; fi
for pid in $(ps -eo pid,user,args --no-headers 2>/dev/null \
| awk '$4 ~ /^\[/ && $2 != "root" {print $1}'); do
RH=$(sha256sum "/proc/$pid/exe" 2>/dev/null | awk '{print $1}')
[ -z "$RH" ] && continue
if printf '%s' "$RH" | grep -qiE "$HASHES"; then
bad "Running pid $pid matches a known payload hash: $RH"
else warn "Running pid $pid (bracketed, non-root) hashes $RH — preserve it"; fi
done
fi
if [ -n "$ROOT" ]; then
DROP=$(find pub app/code var/tmp generated -type f \( -name '*.php' -o -name '*.phtml' \) \
-mtime -30 2>/dev/null | xargs -r grep -lI -e 'eval(base64_decode' -e 'eval($_' \
-e 'assert($_' -e 'preg_replace(.*/e' 2>/dev/null | grep -v "$SELF" | head -20)
if [ -n "$DROP" ]; then bad "Recently modified PHP with backdoor patterns:"; list "$DROP"
else ok "No recently modified PHP with backdoor patterns"; fi
C2F=$(grep -rlI --include='*.php' --include='*.phtml' --include='*.js' --include='*.html' \
--include='*.xml' -e "$IOC_DOM" -e "$C2_1" -e "$C2_2" -e "$SRC_1" -e "$SRC_2" . 2>/dev/null \
| grep -v "$SELF" | head -20)
if [ -n "$C2F" ]; then bad "C2 host/IP referenced in webroot files:"; list "$C2F"
else ok "No C2 references in webroot files"; fi
# Scan scripts carry IOC strings by design. Inside the webroot they get
# flagged as malware by eComscan and friends, and they are web-adjacent
# files that have no business being there.
OWN=$(ls -1 ./*check*.sh ./*polyshell*.sh ./*smuggler*.sh 2>/dev/null | sort -u | head -10)
if [ -n "$OWN" ]; then
warn "Scan script(s) inside the webroot — malware scanners will flag their IOCs:"
list "$OWN"
info " Move them out: mkdir -p ~/ir-tools && mv <files> ~/ir-tools/"
fi
fi
# --- 6. network ---------------------------------------------------------------
hdr "6. Network"
if command -v ss >/dev/null 2>&1; then
CONN=$(ss -tanp 2>/dev/null | grep -E "$C2_IPS")
if [ -n "$CONN" ]; then bad "Active connection to known C2:"; printf ' %s\n' "$CONN"
else ok "No active TCP connection to known C2 IPs"; fi
# The newer variant tunnels C2 over UDP/123, disguised as NTP. Only an
# ESTABLISHED socket with a real peer counts — a listening socket with peer
# 0.0.0.0:* is just your NTP daemon, and flagging those is noise.
U123=$(ss -uanp 2>/dev/null | awk '$1=="ESTAB" && $6 ~ /:123$/ {print}' \
| grep -vE '(^|[^0-9])127\.0\.0\.1:' | head -5)
if [ -n "$U123" ]; then
warn "Established UDP/123 connection — one variant tunnels C2 over fake NTP:"
printf ' %s\n' "$U123"
info " Compare the peers against your real NTP servers before concluding."
else ok "No established UDP/123 connections to unexpected peers"; fi
fi
info "TCP C2: $C2_1, $C2_2 sources: $SRC_1, $SRC_2 host: $IOC_HOST"
info "NTP-tunnelled C2 domains are not visible in socket state. If you have DNS"
info " resolver logs, grep them for: $(printf '%s' "$NTP_DOMS" | tr '|' ' ')"
# --- 7. access logs (-l) ------------------------------------------------------
if [ "$CHECK_LOGS" -eq 1 ]; then
hdr "7. Access log evidence"
LOGS=""
for d in /var/log/nginx /data/web/var/log /var/log/apache2 ${ROOT:+"$ROOT/var/log"}; do
[ -d "$d" ] && LOGS="$LOGS $(ls "$d"/*access.log "$d"/*access.log.1 2>/dev/null)"
done
LOGS=$(printf '%s' "$LOGS" | tr ' ' '\n' | grep -v '^$' | head -10)
if [ -z "$LOGS" ]; then
warn "No access logs found — point the grep at your own log path"
else
info "Scanning: $(echo $LOGS | tr '\n' ' ')"
# Hypernode and many hosts ship JSON access logs. Field-position parsing
# (awk $1, "status" between quotes) silently produces garbage on those, so
# detect the format first and parse accordingly.
LOGFMT="combined"
FIRST=$(head -1 $LOGS 2>/dev/null | head -1)
case "$FIRST" in '{'*) LOGFMT="json" ;; esac
info "Log format: $LOGFMT"
RX='styles(\[|%5B|%255B)|generatorClass|with_resolved|'"$IOC_DOM"'|'"$TRACE_RX"
# Two more signatures from Sansec's IOC list. /customer/section/load/ is one
# of the most requested URLs on any storefront, so it is only meaningful when
# the SAME line carries a PHP open tag — the payload rides in the User-Agent.
RX_UA='/customer/section/load/.*(<\?|%3C%3F|%253C%253F)'
RX_SHELL='/paypal/transparent/response/.*(eval|base64_decode|<\?|%3C%3F)'
for pair in "log-poisoning via section/load (payload in User-Agent):$RX_UA" \
"PayPal transparent-response webshell:$RX_SHELL"; do
lbl=${pair%%:*}; rgx=${pair#*:}
HITS=$(grep -hacE "$rgx" $LOGS 2>/dev/null | awk '{s+=$1} END{print s+0}')
if [ "${HITS:-0}" -gt 0 ]; then
bad "$HITS request(s) matching $lbl"
grep -haE "$rgx" $LOGS 2>/dev/null | tail -3 | cut -c1-200 | sed 's/^/ /'
else ok "No requests matching $lbl"; fi
done
# extract "<ip>\t<status>\t<request>" from whichever format this is
extract() { # $1 = grep -E pattern
if [ "$LOGFMT" = "json" ]; then
grep -haE "$1" $LOGS 2>/dev/null | python3 -c '
import sys, json, re
# Schema-agnostic: field NAMES differ between hosts, so prefer a key that looks
# like an address field and fall back to matching the VALUE. The strict IPv6
# pattern matters: an ISO timestamp is full of colons and would otherwise win.
ip4 = re.compile(r"^(\d{1,3}\.){3}\d{1,3}$")
ip6 = re.compile(r"^[0-9a-fA-F:]+$")
reqre = re.compile(r"^(GET|POST|PUT|HEAD|PATCH|DELETE|OPTIONS) ")
stre = re.compile(r"^[1-5]\d\d$")
IPKEYS = ("remote_addr", "client_ip", "remote-addr", "clientip", "ip", "client", "x_forwarded_for")
def isip(s):
return bool(ip4.match(s)) or (bool(ip6.match(s)) and s.count(":") >= 2)
for line in sys.stdin:
line = line.strip()
if not line.startswith("{"):
continue
try:
d = json.loads(line)
except Exception:
continue
ip = st = rq = "-"
for k in IPKEYS:
v = d.get(k)
if v and isip(str(v).split(",")[0].strip()):
ip = str(v).split(",")[0].strip()
break
for k, v in d.items():
s = str(v)
if ip == "-" and isip(s):
ip = s
elif st == "-" and stre.match(s):
st = s
elif rq == "-" and reqre.match(s):
rq = s
print("\t".join([ip, st, rq]))
' 2>/dev/null
else
grep -haE "$1" $LOGS 2>/dev/null \
| sed -nE 's/^([^ ]+) .*"((GET|POST|PUT|HEAD|PATCH|DELETE)[^"]*)" ([0-9]{3}) .*/\1\t\4\t\2/p'
fi
}
HITS=$(extract "$RX")
ATT=$(printf '%s' "$HITS" | grep -c . )
if [ "${ATT:-0}" -gt 0 ]; then
warn "${ATT} request(s) matching StyleSmuggler signatures — response codes:"
printf '%s\n' "$HITS" | cut -f2 | sort | uniq -c | sed 's/^/ HTTP /'
THRU=$(printf '%s\n' "$HITS" | cut -f2 | grep -cE '^(200|500)$')
[ "${THRU:-0}" -gt 0 ] && bad "${THRU} reached the application (200/500) — not blocked at the time"
info " Top sources:"
printf '%s\n' "$HITS" | cut -f1 | sort | uniq -c | sort -rn | head -8 | sed 's/^/ /'
else
ok "No StyleSmuggler request signatures in the scanned logs"
fi
# stream wrappers: the template-path sink is reached with phar:// php:// data://
SWH=$(extract '(phar|php|data|zip|expect)(://|%3A%2F%2F)')
SW=$(printf '%s' "$SWH" | grep -c .)
if [ "${SW:-0}" -gt 0 ]; then
warn "${SW} request(s) containing a PHP stream wrapper — top sources and codes:"
printf '%s\n' "$SWH" | cut -f1,2 | sort | uniq -c | sort -rn | head -5 | sed 's/^/ /'
info " Look at the URLs before concluding; a redirect param can carry one."
else
ok "No stream wrappers in request URLs"
fi
UAH=$(grep -haE '<\?(php|=)' $LOGS 2>/dev/null | wc -l | tr -d ' ')
if [ "${UAH:-0}" -gt 0 ]; then
bad "${UAH} log line(s) with a raw PHP open tag (User-Agent variant)"
else
ok "No raw PHP open tags in the logs"
fi
# who actually calls GraphQL — decides whether you can block the endpoint
GQH=$(extract '"?(POST|GET) /(index\.php/)?graphql')
GQ=$(printf '%s' "$GQH" | grep -c .)
if [ "${GQ:-0}" -gt 0 ]; then
info "${GQ} GraphQL request(s) in the scanned logs — top sources:"
printf '%s\n' "$GQH" | cut -f1 | sort | uniq -c | sort -rn | head -8 | sed 's/^/ /'
info " Browser traffic from your own storefront is expected. A server-to-"
info " server caller here will break if you block /graphql."
else
ok "No GraphQL traffic in the scanned logs"
fi
info "Rotated .gz logs are not scanned — use zgrep for full history"
fi
fi
# --- 8. database (-d) ---------------------------------------------------------
if [ "$CHECK_DB" -eq 1 ]; then
hdr "8. Database persistence"
if [ -z "$ROOT" ]; then warn "DB check needs a Magento root"
elif ! command -v php >/dev/null 2>&1 || ! command -v mysql >/dev/null 2>&1; then
warn "php or mysql client missing — skipping"
else
eval "$(php -r '
$c = include "app/etc/env.php"; $d = $c["db"]["connection"]["default"];
$host = $d["host"]; $port = 3306;
if (strpos($host, ":") !== false) { [$host,$port] = explode(":", $host, 2); }
printf("DBH=%s; DBP=%s; DBU=%s; DBW=%s; DBN=%s; PFX=%s;",
escapeshellarg($host), escapeshellarg($port), escapeshellarg($d["username"]),
escapeshellarg($d["password"]), escapeshellarg($d["dbname"]),
escapeshellarg($c["db"]["table_prefix"] ?? ""));' 2>/dev/null)"
if [ -z "${DBN:-}" ]; then warn "Could not read DB credentials from app/etc/env.php"
else
q(){ MYSQL_PWD="$DBW" mysql -h "$DBH" -P "$DBP" -u "$DBU" "$DBN" -N -B -e "$1" 2>/dev/null; }
RX="${IOC_DOM}|atob|fromCharCode|base64_decode"
SQL=""
for t in "${PFX}cms_page:content" "${PFX}cms_block:content" "${PFX}core_config_data:value"; do
TBL="${t%%:*}"; COL="${t##*:}"
SQL="$SQL SELECT '$TBL', COUNT(*) FROM \`$TBL\` WHERE \`$COL\` REGEXP '$RX';"
done
OUT=$(q "$SQL")
if [ -z "$OUT" ]; then warn "DB query failed"
else while read -r tbl hits; do
if [ "${hits:-0}" -gt 0 ]; then bad "${hits} suspicious row(s) in ${tbl}"
else ok "${tbl} clean"; fi
done <<< "$OUT"; fi
# THE ENTRY VECTOR: the gadget rides a {{block}} directive through an email
# template. A stored template naming a backend/adminhtml block is not normal.
ET=$(q "SELECT template_code FROM \`${PFX}email_template\`
WHERE template_text REGEXP '\\\\{\\\\{block'
AND (template_text LIKE '%Adminhtml%' OR template_text LIKE '%Magento\\\\\\\\Backend%'
OR template_text LIKE '%Widget%Grid%');")
if [ -n "$ET" ]; then
bad "Email template(s) instantiate a backend/adminhtml block — entry vector:"
printf ' %s\n' "$ET"
else ok "No email template references a backend block"; fi
ETS=$(q "SELECT template_code FROM \`${PFX}email_template\`
WHERE template_styles REGEXP '\\\\{\\\\{|<\\\\?|\\\\\\\\\\\\\\\\';")
if [ -n "$ETS" ]; then
bad "Email template styles contain directives, PHP tags or namespaces:"
printf ' %s\n' "$ETS"
info " Legitimate template_styles are always plain CSS."
else ok "All email template styles are plain CSS"; fi
for t in "admin_user:username, created:created" "integration:name, created_at:created_at"; do
T="${t%%:*}"; REST="${t#*:}"; COLS="${REST%:*}"; DCOL="${REST##*:}"
RES=$(q "SELECT $COLS FROM \`${PFX}${T}\` WHERE $DCOL >= NOW() - INTERVAL 30 DAY;")
if [ -n "$RES" ]; then warn "New rows in ${T} (30d) — confirm each is yours:"
printf ' %s\n' "$RES"
else ok "No new rows in ${T}"; fi
done
# cron_schedule: every scheduled job is normally DECLARED in some
# crontab.xml. Listing recent rows is useless — a busy store schedules
# thousands of legitimate core jobs. What matters is a job_code that no
# module declares anywhere in the codebase.
DECLARED=$(grep -rhoE '<job[^>]+name="[^"]+"' vendor app/code --include=crontab.xml 2>/dev/null \
| grep -oE 'name="[^"]+"' | cut -d'"' -f2 | sort -u)
SCHEDULED=$(q "SELECT DISTINCT job_code FROM \`${PFX}cron_schedule\`
WHERE scheduled_at >= NOW() - INTERVAL 30 DAY;")
if [ -z "$DECLARED" ]; then
info "cron_schedule: no crontab.xml found to compare against — skipped"
elif [ -z "$SCHEDULED" ]; then
ok "cron_schedule: no jobs scheduled in the last 30 days"
else
UNDECL=$(comm -23 <(printf '%s\n' "$SCHEDULED" | sort -u) <(printf '%s\n' "$DECLARED"))
if [ -n "$UNDECL" ]; then
bad "Scheduled job code(s) that no crontab.xml declares:"
list "$UNDECL"
else
NJOBS=$(printf '%s\n' "$SCHEDULED" | wc -l | tr -d ' ')
ok "cron_schedule: all ${NJOBS} scheduled job codes are declared by a module"
fi
fi
fi
fi
fi
# --- 9. code change audit -----------------------------------------------------
if [ -n "$ROOT" ] && [ "${CODE_DAYS:-0}" -gt 0 ]; then
hdr "9. Code change audit (last ${CODE_DAYS} days)"
if [ -d .git ]; then
GD=$(git status --short 2>/dev/null | head -25)
if [ -n "$GD" ]; then
warn "Working tree differs from git — this is the authoritative answer:"
printf ' %s\n' "$GD"
else ok "git working tree clean"; fi
UNT=$(git ls-files --others --exclude-standard 2>/dev/null | grep -E '\.(php|phtml)$' | head -10)
[ -n "$UNT" ] && { bad "Untracked PHP files:"; list "$UNT"; }
else
info "Not a git repo — falling back to mtimes, which are weaker evidence."
fi
for d in app/code app/design vendor generated/code; do
[ -d "$d" ] || continue
C=$(find "$d" -type f -name '*.php' -mtime -"$CODE_DAYS" 2>/dev/null | wc -l | tr -d ' ')
T=$(find "$d" -type f -name '*.php' 2>/dev/null | wc -l | tr -d ' ')
if [ "${C:-0}" -gt 0 ]; then
info "$d: ${C} of ${T} PHP files changed in the last ${CODE_DAYS}d"
else
ok "$d: no PHP file changed in the last ${CODE_DAYS}d (${T} total)"
fi
done
echo
info "Most recently changed files under app/code (newest first):"
if find app/code -maxdepth 0 >/dev/null 2>&1; then
find app/code -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.xml' \) \
-printf '%TY-%Tm-%Td %TH:%TM %p\n' 2>/dev/null | sort -r | head -20 \
| sed 's/^/ /' \
|| ls -lt --time-style=long-iso $(find app/code -type f -name '*.php' 2>/dev/null | head -200) 2>/dev/null \
| head -20 | sed 's/^/ /'
fi
# A file newer than composer.lock in vendor/ was probably hand-edited.
# vendor/composer/* is regenerated by dump-autoload on every deploy, so it is
# always newer and always benign — excluding it is what makes this check useful.
if [ -f composer.lock ]; then
HAND=$(find vendor -type f -name '*.php' -newer composer.lock \
-not -path 'vendor/composer/*' 2>/dev/null | head -15)
if [ -n "$HAND" ]; then
# Split the list: a file a patch we detected is SUPPOSED to touch is not
# evidence of anything. Only the remainder is worth your attention.
PATCHED_FILES='View/Element/BlockFactory.php|Webapi/ErrorProcessor.php|View/Layout/Generator/Block.php|Widget/Grid/Row/UrlGeneratorFactory.php|module-email/Model/AbstractTemplate.php|module-email/Block/Adminhtml/Template/Preview.php|module-newsletter/Block/Adminhtml/Template/Preview.php|module-newsletter/Block/Adminhtml/Queue/Preview.php|pub/errors/processor.php|framework/Escaper.php|module-backup/Controller/Adminhtml/Index/Rollback.php|module-customer-graph-ql/Model/Context/AddUserInfoToContext.php|module-import-export/Controller/Adminhtml/Export/File/Delete.php|module-instant-purchase/Model/InstantPurchaseOptionLoadingFactory.php|module-paypal/Controller/Express/AbstractExpress.php|module-sales/view/adminhtml/web/order/create/scripts.js|module-customer/Controller/Account/Edit.php|module-translation/Model/Inline/Parser.php|module-quote/Model/GuestCart/|module-gift-message/Model/Guest|module-checkout/Model/Guest|vendor/autoload.php|composer/autoload_'
EXPECTED=$(printf '%s\n' "$HAND" | grep -E "$PATCHED_FILES")
UNEXPECTED=$(printf '%s\n' "$HAND" | grep -vE "$PATCHED_FILES")
NE=$(printf '%s\n' "$EXPECTED" | grep -c .)
if [ -n "$UNEXPECTED" ]; then
warn "vendor/ files newer than composer.lock and NOT explained by a known patch:"
printf ' %s\n' $UNEXPECTED
info " Could be a community or vendor patch you applied — check first:"
info " grep -rl <filename> patches/ composer.json patches.lock.json"
info " If nothing declares it, diff the file against pristine upstream."
else
ok "Every modified vendor/ file is one a detected patch is supposed to touch"
fi
[ "${NE:-0}" -gt 0 ] && info " ${NE} further modified file(s) match known patch targets — expected."
else ok "No hand-edited vendor/ file (composer's own autoload files excluded)"; fi
fi
info "mtimes are attacker-controllable with touch(1). Treat a clean result here"
info " as weak evidence, and a dirty one as strong evidence."
fi
# --- 10. mitigations & stop-gaps ----------------------------------------------
if [ "$PATCHED" -eq 1 ]; then
hdr "10. Stop-gap mitigations (removable now the patch is applied)"
info "The official patch supersedes these. They are no longer required; each one"
info "listed below is a candidate for removal once you have smoke-tested."
else
hdr "10. Mitigations"
fi
# 10a. sink guard: hand-applied, or via composer patch
GUARD_DIR="setup/src/Magento/Setup/Module/Di/Code"
GUARD_FILES="$GUARD_DIR/Scanner/ArrayScanner.php
$GUARD_DIR/Reader/ClassesScanner.php
$GUARD_DIR/Scanner/XmlInterceptorScanner.php"
if [ -n "$ROOT" ] && [ -d "$GUARD_DIR" ]; then
NG=0; NT=0; MISSING=""
for f in $GUARD_FILES; do
[ -f "$f" ] || continue; NT=$((NT+1))
if grep -q 'PHP_SAPI' "$f" 2>/dev/null; then NG=$((NG+1)); else MISSING="$MISSING $f"; fi
done
if [ "$NT" -gt 0 ] && [ "$NG" -eq "$NT" ]; then
SINK=1; LAYERS="$LAYERS sink"
if [ "$PATCHED" -eq 1 ]; then
info "DI-scanner CLI guard present (${NG}/${NT}) — redundant now, safe to revert:"
info " git checkout -- $GUARD_DIR"
STOPGAPS="$STOPGAPS di-guard"
else
ok "Sink: DI scanners are CLI-only (${NG}/${NT}) — not bypassable via POST body"
if grep -q 'stylesmuggler' composer.json 2>/dev/null; then
ok " Applied via composer patch — survives composer install"
else
warn " Applied by hand: composer install REVERTS this. Move it to a"
warn " cweagans/composer-patches entry so your pipeline reapplies it."
fi
fi
elif [ "$NG" -gt 0 ]; then
if [ "$PATCHED" -eq 1 ]; then
info "DI-scanner guard partially present (${NG}/${NT}) — revert the remainder:"
list "$MISSING"; STOPGAPS="$STOPGAPS di-guard"
else
warn "Sink guard only partially applied (${NG}/${NT}). Unguarded:"; list "$MISSING"
fi
elif [ "$PATCHED" -eq 1 ]; then
ok "No DI-scanner guard — not needed, the official patch fixes the sink upstream"
else
warn "Sink: DI scanners are NOT CLI-only — reachable over HTTP"