After a web-server breach like PHP2Shell: patch, then hunt credential reuse in Entra ID
A compromised web server doesn’t show up in Entra ID sign-in logs. What shows up is the attacker using what the server held: app secrets, service account passwords, tokens, and on Windows hosts, domain credentials. So the hunt starts from an inventory of what that server could authenticate as, then looks for those identities being used from somewhere new.
This guide gives the inventory step and the queries for each place a stolen credential would surface. For PHP2Shell it also summarises what the PHP project says about patching and interim mitigations.
Short answer: update PHP to 8.5.12, 8.4.27, 8.3.36 or 8.2.35 (or your vendor’s patched package), assume any secret on an exposed server was read, rotate those secrets, then run the hunts below to see whether they were used.
Why this guide now? The PHP2Shell example
Section titled “Why this guide now? The PHP2Shell example”On 21 October 2026 the PHP project released emergency fixes for two critical, unauthenticated remote code execution flaws, CVE-2026-109618 and CVE-2026-109619, nicknamed PHP2Shell (the name Unit 42 reports; the PHP project uses only the CVE IDs). The fixed versions are PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35. The PHP advisory and the two GitHub advisories describe code execution as the web worker account, and say updating is the only complete (or reliable) mitigation. Read them for versions and workarounds. This page only covers what comes after.
Unit 42 reports exploitation in the wild, including a compromise of a European government entity where, within two hours, the attackers reached password hashes and MFA secrets and set up an NTLM relay tunnel into Active Directory. That is one vendor’s report of one incident. The point for you is the pattern: after code execution on a web server, the next move is credentials.
Any web-server RCE follows the same pattern. The steps below apply to the next one too.
How do I protect a PHP server against PHP2Shell?
Section titled “How do I protect a PHP server against PHP2Shell?”This section is a selection from the PHP advisory, the 8.5.12 release notes, the 23 October informational advisory and the two GitHub advisories. It is not the full list. Check them for changes before you act: the PHP project will publish a full advisory for a related issue, CVE-2026-115560, with the next release.
1. Update. Nothing else is a complete fix. The PHP release notes: “There are no complete
mitigations, updating is the only safe choice.”
One exception: the 23 October advisory
covers a related session upload progress issue, CVE-2026-115560, that PHP will handle in the next
regular release. Until then, keep session.upload_progress.enabled=0 (step 3) even after you
update.
| Branch | Fixed in |
|---|---|
| PHP 8.5 | 8.5.12 |
| PHP 8.4 | 8.4.27 |
| PHP 8.3 | 8.3.36 |
| PHP 8.2 | 8.2.35 (end of life 31 December 2026) |
| PHP 8.1 and older | No longer updated by the PHP project. The PHP security team published minimal source backports for 7.4, 8.0 and 8.1, and some distributions and LTS vendors backport fixes. |
2. Check which version actually serves web requests. The advisory says the risk applies to
all PHP installations serving web requests (PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and
LiteSpeed) and is highest for mod_php, where an unauthenticated request triggered the attack in a
demonstrated exploit. The command-line php -v can report a different build than the one your
web server loads (interpretation: common with several installed versions), so check the FPM
binary or a temporary phpinfo() page as well, then remove the page.
3. If you can’t update today, reduce exposure. The advisory says these steps “reduce exposure to specific attack paths but do not make an installation safe.”
- Set
session.upload_progress.enabled=0inphp.ini, the FPM pool or the server configuration, and restart every worker. Don’t rely onini_set()or.user.ini. This blocks the confirmed session paths of CVE-2026-109619, but not other variants. The 23 October advisory recommends the same setting for CVE-2026-115560 until the next bugfix release. The GitHub advisory suggests keeping it off after patching if you don’t need the feature. - On PHP 8.5, set
fatal_error_backtraces=0and restart every worker. The advisory says this blocks the confirmed fatal-backtrace path only. It does not replace the session setting above. - Remove invalid or deprecated per-request settings that emit diagnostics before initialization.
Hiding diagnostics with
error_reporting,display_errorsor@does not help. Fix fatal errors and execution timeouts that an attacker can reach. - Set a low
post_max_size(8M without uploads, 32M with uploads) and keepupload_max_filesizebelow it. - Enforce limits at the web server or proxy for decoded request size, input field count,
multipart part count, header count and nesting depth, including for requests without a usable
Content-Length. If the app doesn’t use multipart forms, block them at the frontend. memory_limit=-1removes the controllable memory-limit failure of CVE-2026-109618, but the advisory says an underlying allocation failure can still reach the vulnerable path. Availability warning (interpretation): without a PHP limit, one request can use all host memory, and the kernel may kill other services such as your database. Only consider it with a container or cgroup memory limit already in place, which the advisory describes as an “availability boundary, preferably with a policy that kills only the affected worker”. Set it withphp_admin_valueso apps can’t override it, and check that no pool, directory or application setting puts a finite limit back. Don’t useRLIMIT_ASorRLIMIT_DATA, which can cause the vulnerable allocation failure.
4. Assume the server’s secrets are exposed if it was reachable before you patched. Patching stops new exploitation, not reuse of what was already taken. That is the rest of this guide.
What does an attacker take from a compromised web server?
Section titled “What does an attacker take from a compromised web server?”It depends on what the server was configured with. Work out which rows apply to yours.
| What the server held | How it is used | Where it can show up |
|---|---|---|
| Client secret or certificate for an Entra app registration, in a config file or environment variable | The attacker requests tokens as that app | AADServicePrincipalSignInLogs |
| A user or service account password in config (for example for mail or Graph) | The attacker signs in as that account | SigninLogs, AADNonInteractiveUserSignInLogs |
| Cached tokens or session secrets | The attacker replays them | The same user sign-in tables, from a new IP |
| On a domain-joined Windows host: NTLM hashes or Kerberos tickets in memory | Lateral movement inside Active Directory | Defender for Identity (IdentityLogonEvents, alerts) |
PHP RCE itself leaves nothing in these tables. Anything on this page is a sign of use, not of the exploit.
How do I hunt it?
Section titled “How do I hunt it?”Replace the example IP addresses with the egress addresses of your web servers. The thresholds and time windows are starting points I chose, not Microsoft numbers. None of these queries has been run against a lab tenant yet, so test them in your own workspace.
Step 0: What has Entra ID Protection already flagged?
Section titled “Step 0: What has Entra ID Protection already flagged?”Before you build your own baseline, check what Microsoft already detects for app identities.
Entra ID Protection’s workload identity detections include Suspicious Sign-ins
(suspiciousSignins), which learns a service principal’s normal sign-ins over 2 to 60 days and
fires on unfamiliar properties such as IP address or ASN, target resource, user agent, IP country
or credential type, and Leaked Credentials (leakedCredentials), which matches app
credentials found on GitHub, paste sites or in breach data against valid ones. Microsoft says
managed identities aren’t in scope. If you send ID Protection data to your workspace:
AADServicePrincipalRiskEvents| where TimeGenerated > ago(30d)| summarize Detections = count(), IPs = make_set(IpAddress, 10), LastDetected = max(DetectedDateTime) by ServicePrincipalDisplayName, ServicePrincipalId, RiskEventType, RiskLevel| sort by LastDetected descTreat the steps below as a complement: they are targeted at one server’s identities and work without those detections.
Step 1: Which app identities did this server use?
Section titled “Step 1: Which app identities did this server use?”This is the rotation list. Every app that signed in from the server’s IP in the last 30 days had its secret or certificate available to an attacker on that host.
let webServerIPs = dynamic(["203.0.113.10", "203.0.113.11"]);AADServicePrincipalSignInLogs| where TimeGenerated > ago(30d)| where IPAddress in (webServerIPs)| summarize Signins = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by ServicePrincipalName, ServicePrincipalId, AppId, ResourceDisplayName| sort by LastSeen descApps that only call Microsoft Graph from a different host won’t appear. Check your app registrations for secrets that are stored on this server but used elsewhere.
Step 2: Are those apps signing in from somewhere else now?
Section titled “Step 2: Are those apps signing in from somewhere else now?”The same app identities, but from an IP that isn’t one of your servers, in the last two days.
let webServerIPs = dynamic(["203.0.113.10", "203.0.113.11"]);let apps = AADServicePrincipalSignInLogs | where TimeGenerated between (ago(30d) .. ago(2d)) | where IPAddress in (webServerIPs) | distinct ServicePrincipalId;AADServicePrincipalSignInLogs| where TimeGenerated > ago(2d)| where ServicePrincipalId in (apps)| where IPAddress !in (webServerIPs)| summarize Signins = count(), FirstSeen = min(TimeGenerated), Successes = countif(ResultType == "0"), Failures = countif(ResultType != "0") by ServicePrincipalName, ServicePrincipalId, IPAddress, ResourceDisplayName| sort by FirstSeen ascAn app that has only ever signed in from your server and now also signs in from a hosting provider or a residential IP is the signal. Apps that legitimately run in several places will be noisy. Compare against the 30-day baseline before you act.
Microsoft’s reference describes ResultType for this table as “The result of the sign-in
operation can be Success or Failure.” The user sign-in tables document it as the numeric error
code as a string. Look at a few rows and confirm that successes carry "0" before you rely on
the Successes and Failures columns. If they don’t, use ResultSignature, which holds the
error code.
Step 3: Who is trying an old secret after you rotated it?
Section titled “Step 3: Who is trying an old secret after you rotated it?”Once you rotate a secret, an attacker who still holds the old one will fail with AADSTS7000215. This is my reasoning, not a Microsoft statement. It turns the rotation into a trap that shows where the old secret is being tried.
AADServicePrincipalSignInLogs| where TimeGenerated > ago(7d)| where ResultType == "7000215"| summarize Failures = count(), IPs = make_set(IPAddress, 10) by ServicePrincipalName, ServicePrincipalId, bin(TimeGenerated, 1d)| sort by TimeGenerated descFailures from your own server’s IP after the rotation mean you missed updating the app’s config. Failures from other IPs are the interesting rows.
Step 4: Was a new MFA method registered?
Section titled “Step 4: Was a new MFA method registered?”An attacker with a stolen password wants a way back in. Registering their own authentication method does that. Microsoft lists these audit activities for registration: User registered security info, Admin registered security info and User registered all required security info.
AuditLogs| where TimeGenerated > ago(7d)| where OperationName in ("User registered security info", "Admin registered security info", "User registered all required security info")| extend Actor = tostring(InitiatedBy.user.userPrincipalName), Target = tostring(TargetResources[0].userPrincipalName)| project TimeGenerated, OperationName, Actor, Target, Result, ResultReason| sort by TimeGenerated descMost of these are real users. For each one, check the sign-in that came right before it (see password spraying for how to read that row). A registration from an IP the user has never signed in from is worth a call to the user.
Step 4b: Did someone add a secret to another app?
Section titled “Step 4b: Did someone add a secret to another app?”An attacker who controls one app identity, or a user who can manage apps, can add their own secret or certificate to other apps and keep access after you rotate. In the October 2026 ConnectWise case, Huntress reports that a ConnectWise management app was used to grant itself Global Administrator and add credentials to other applications. Microsoft lists the audit activities Add service principal credentials, Update application - Certificates and secrets management and Add member to role.
AuditLogs| where TimeGenerated > ago(30d)| where OperationName == "Add service principal credentials" or OperationName has "Certificates and secrets management" or OperationName == "Add member to role"| extend ActorApp = tostring(InitiatedBy.app.displayName), ActorUser = tostring(InitiatedBy.user.userPrincipalName), Target = tostring(TargetResources[0].displayName)| project TimeGenerated, OperationName, ActorApp, ActorUser, Target, Result| sort by TimeGenerated descRows where ActorApp is filled in were done by an app, not a person. Any of those you can’t
explain, and any credential added to an app from Step 1, is a priority.
For the full ConnectWise case, see
checking your tenant for a compromised vendor app.
Step 5: Did the server start authenticating inside Active Directory?
Section titled “Step 5: Did the server start authenticating inside Active Directory?”This applies only if the server is domain-joined and you run Defender for Identity. Start by
looking at what the host normally does. Microsoft doesn’t list the ActionType and Protocol
values on the table page, so run this once and read the real values before filtering on them.
let webHost = "web01.contoso.local";IdentityLogonEvents| where Timestamp > ago(7d)| where DeviceName =~ webHost| summarize Events = count(), Accounts = dcount(AccountName), Destinations = dcount(DestinationDeviceName) by ActionType, LogonType, Protocol| sort by Events descThen look at the same host by the hour. A web server that suddenly authenticates as many accounts, or to many destination devices, is not behaving like a web server.
let webHost = "web01.contoso.local";IdentityLogonEvents| where Timestamp > ago(7d)| where DeviceName =~ webHost| summarize Accounts = dcount(AccountName), Destinations = dcount(DestinationDeviceName), Events = count() by bin(Timestamp, 1h)| render timechartDefender for Identity has its own alerts for this stage. Microsoft documents Suspected identity theft (pass-the-hash), Suspected identity theft (pass-the-ticket) and Suspected NTLM relay attack (Exchange account). The last one is specific to Exchange server accounts being coerced into NTLM authentication, so it is not a general NTLM relay detector. Don’t assume a quiet alert list means a relay didn’t happen.
What should I do about it?
Section titled “What should I do about it?”- Patch first. See How do I protect a PHP server against PHP2Shell? and always check the vendor advisory for updates.
- Rotate every secret from Step 1, and anything else stored on that host: database passwords, API keys, certificates. Assume they were read.
- Revoke sessions for any user whose password or token was on the host. See revoking sessions and proving it worked.
- Review app consent and new credentials. An attacker with access to an app can add their own secret to it. See investigating consent phishing for the audit-log side.
- Restrict the apps to known IPs. Conditional Access for workload identities can block a service principal when the token request comes from outside the IP ranges you allow. It needs Workload Identities Premium licences, and Microsoft says it covers single-tenant service principals in your tenant, not multitenant apps or managed identities.
What can’t this guide tell me?
Section titled “What can’t this guide tell me?”- Whether the server was compromised. Nothing here detects the exploit.
- That you are clean. A quiet result for these queries is the absence of one kind of evidence.
- What a stolen app token did. Sign-in logs show the token request, not the calls made with it,
and Huntress notes that directory reads through Microsoft Graph leave no audit trail.
Microsoft Graph activity logs
(
MicrosoftGraphActivityLogs, Entra ID P1 or P2, enabled with a diagnostic setting) record each Graph request withAppId,ServicePrincipalIdandIPAddress, but only from the moment you turn them on. Turn them on before you need them. - When the server was hit. That evidence is on the host. For PHP2Shell, Datadog Security Labs lists request patterns and log lines to look for. Use the earliest date you find to set the time windows in Steps 2 and 3.
- How your servers sign in. If your apps use managed identities, their sign-ins are in a different log, and Microsoft says managed identities aren’t covered by workload identity Conditional Access.
The queries are based on Microsoft’s schema documentation and have not been reproduced in a lab tenant yet. Until they have, the page is marked as lab test pending.
Sources
- PHP security advisory: critical remote code execution vulnerabilities, update now (2026-10-21)
- PHP informational advisory: session upload progress, CVE-2026-115560 (2026-10-23)
- PHP 8.5.12 release notes (2026-10-21)
- GHSA-w8hw-w9fx-j79w: CVE-2026-109618, RCE via OOM corruption
- GHSA-6wmf-4rv5-p3rp: CVE-2026-109619, RCE via stale global state
- Unit 42 threat brief: critical vulnerabilities in PHP (CVE-2026-109618, CVE-2026-109619)
- Service principal sign-in logs (Microsoft Entra ID)
- AADServicePrincipalSignInLogs table reference (Azure Monitor)
- Microsoft Entra audit log activities
- IdentityLogonEvents table in the advanced hunting schema
- Microsoft Defender for Identity classic alerts
- Workload identity risk detections (Microsoft Entra ID Protection)
- AADServicePrincipalRiskEvents table reference (Azure Monitor)
- Microsoft Graph activity logs overview
- Huntress: vendor credential enables unauthorized access (ConnectWise, October 2026)
- Datadog Security Labs: PHP2Shell critical vulnerabilities in PHP
- Conditional Access for workload identities