~/index / cross-tenant-dns-forwarding.md

Cross-tenant DNS forwarding in Azure: plumbing that should not work?

My last post on cross-tenant DNS zones naturally leads to the question if other Azure DNS resources have the same visibility issue. One resource of great interest: Private DNS resolvers. DNS resolvers are basically the DNS plumbing Azure provides. You deploy a private one if you have inbound or outbound DNS queries requiring a more complex setup. For example from your on-premise datacenter to an IP in a delegated inbound endpoint subnet, which allows your on-prem infrastructure to resolve names in your Azure private zones. Outbound endpoints in combination with a resource called forwarding ruleset allow you to send Azure DNS queries to on-prem or 3rd party DNS servers. Forwarding rulesets basically define which queries get sent where.

A DNS Private Resolver deployed into a hub VNet with delegated inbound and outbound endpoint subnets, a forwarding ruleset that references the outbound endpoint and carries a forwarding rule, and a virtualNetworkLink child object under the ruleset that references a separate spoke VNet holding a workload VM
fig 1 — a resolver, its ruleset and the objects that hold each relationship

One of the first things I read in the docs was this part:

Cross-tenant linking of Rulesets isn’t supported.

You can’t link rulesets across tenants.

This sounds to me that the setup I had in mind won’t work:

  • private DNS resolver deployed into a Vnet (with inbound and outbound endpoint subnets) + forwarding ruleset attached to the outbound endpoint subnet in my attacker tenant B.
  • a Vnet in tenant A
  • And a link between the forwarding ruleset and the Vnet

So all in we’d have 3 different links:

  • Resolver → Attacker VNet is a required property of the resolver itself, fixed at creation and documented as unchangeable afterwards. One resolver, one VNet, and both endpoint subnets delegated to Microsoft.Network/dnsResolvers.
  • Ruleset → resolver is a property of the ruleset, and it points at the outbound endpoint, not at the resolver as a whole.
  • Ruleset → VNet is neither a property of the ruleset nor of the VNet. It is its own child resource under the ruleset, one per VNet the rules should apply to. And the only cross tenant setup here. Notice the asymetry we had in the last DNS zones post? All the visible infrstructure parts are in the attacker tenant. The spoke on the right of the diagram holds no pointer of its own in the other direction.

So despite what the docs say, I tried to set it up anyways.

two tokens

ARM has a documented mechanism for a request that touches two tenants: the primary bearer token authorises the operation, and x-ms-authorization-auxiliary carries a token for the other tenant so ARM can validate the reference. It is the deployment method I used in the DNS zone post as well:

PUT /subscriptions/<ruleset-sub>/resourceGroups/<ruleset-rg>/providers/Microsoft.Network/dnsForwardingRulesets/<ruleset>/virtualNetworkLinks/<link>?api-version=2025-05-01 HTTP/1.1
Host: management.azure.com
Authorization: Bearer <ruleset-tenant ARM token>
x-ms-authorization-auxiliary: Bearer <vnet-tenant ARM token>
Content-Type: application/json
{
  "properties": {
    "virtualNetwork": {
      "id": "/subscriptions/<vnet-sub>/resourceGroups/<vnet-rg>/providers/Microsoft.Network/virtualNetworks/<vnet>"
    }
  }
}

The primary token belongs to the ruleset tenant because that is where the object is being written. The auxiliary token is what makes the cross-tenant reference resolvable. Both sides authorise. Only one side gets told.

first fails

My first attempt at this concluded that the resource provider enforced a hard tenant boundary. That conclusion was wrong, and it is worth showing why, because every one of the mistakes is easy to make against an asynchronous Azure resource provider.

  • Concurrent attempts. Several attempts to deploy the link were made in a short timespan, competing for the same exclusive link lock. Some requests came back with lock contention and a circuit breaker, which reads a lot like a policy refusal.
  • 202 Accepted treated as a terminal answer. It is not an answer at all. ARM accepted the request, but the worker had not run yet.
  • Absence of telemetry read as presence of enforcement. Nothing in the logs said “blocked”. We inferred it.
  • no positive control I noticed that I don’t know how a standard successfull write looks like because I never tried to set up a forwarding ruleset link in a scenario that is officially supported.

the control, then one clean request

First, a same-tenant, cross-subscription ruleset link, driven to terminal Succeeded with the child resource confirmed to exist. That establishes the topology, the regions, the parent resources and the resource-provider path all work before the tenant boundary is introduced at all.

Then one fresh link name, one request, no overlap:

PUT  → 202 Accepted
LRO  → Succeeded
GET  → provisioningState: Succeeded
       virtualNetwork.id:  exact expected foreign vnet

visibility gaps

ruleset tenantvnet tenant
link write in the Activity Logfull lifecycle: started → accepted → succeeded-
target VNet named in the eventno — the event names the ruleset-side child only-
the link’s own existenceinventoriable as its own child resource-

So like in the DNS zone case: nothing. A potential victim of this tenant doesn’t see the deployment in their Azure Activity logsand neither in their infrastructure itself.

damn control plane, the data plane is cooler anyway

Everything so far is control plane. So I deployed data plane logging to see if we can catch a potential attack using this scenario there. Maybe we have more luck than with the DNS zone in the last post.

What we deployed to log queries: a DNS resolver policy with its own virtualNetworkLink to the VNet, a diagnostic setting on that policy sending allLogs to a Log Analytics workspace, and the DNSQueryLogs table that lands in.

What we deployed for the test: two VMs. One in the victim Vnet sending queries, one responder in the attacker tenant where the forwarding ruleset points to.

Tenant A's workload VM queries Azure DNS; tenant A's resolver policy, diagnostic setting and Log Analytics workspace record the query; the VNet's link to tenant B's forwarding ruleset sends the namespace out through the outbound endpoint in tenant B's snet-outbound subnet to a responder VM in its own subnet, which answers 192.0.2.53
fig 3 — what logged the query, what forwarded it, and the answer that came back

The DNS flow is five steps and crosses the tenant boundary once:

  1. The VM resolves a unique nonce hostname under the test namespace against Azure-provided DNS at 168.63.129.16.
  2. Because tenant B’s ruleset is linked to this VNet, the namespace matches its forwarding rule and the query leaves through the resolver’s outbound endpoint.
  3. The responder VM answers.
  4. The VM gets 192.0.2.53 — the answer chosen on the other side of the boundary.
  5. Tenant A’s policy diagnostic setting writes the row.

The decisive row in tenant A’s own DNSQueryLogs, sanitised:

fieldobservedwhat it gives you
ResolutionPathPrivateResolverResolutionthe query was forwarded, not resolved locally
DnsForwardingRulesetIdfull foreign ruleset resource IDsubscription, resource group, type, name
DnsForwardingRulesetDomainemptynot which rule matched
DestinationIpAddressthe controlled upstream resolverwhere it went
QueryNamethe nonce under the test namespacewhich query
Answer192.0.2.53what the workload was told
SourceIpAddressworkload private IPwho asked
ResolverPolicyIdtenant A’s logging policywhich instrument produced the row
ResolverPolicyRuleActionNoneno traffic rule was needed to log this
ResponseCode0no DNS error — not the same as an answer

Looks like we finally caught our attacker! On the other hand: looks like an attacker can use this (unsuported?) feature to forward DNS queries to their infrastructure!

DNSQueryLogs.TenantId in this dataset is the Log Analytics workspace customer ID. It is not the foreign Entra tenant ID.

DnsForwardingRulesetId gives you a foreign Azure resource path, including a subscription ID. Mapping that subscription back to an Entra tenant needs external inventory or enrichment. So better create another Sentinel watchlist.

Here a quick KQL query draft:

let ApprovedRulesets = dynamic([
  "/subscriptions/<sub>/resourcegroups/<rg>/providers/microsoft.network/dnsforwardingrulesets/<ruleset>"
]);
DNSQueryLogs
| where ResolutionPath == "PrivateResolverResolution"
| where isnotempty(DnsForwardingRulesetId)
| extend RulesetId = tolower(DnsForwardingRulesetId)
| where RulesetId !in~ (ApprovedRulesets)
| project TimeGenerated, QueryName, QueryType, Answer, SourceIpAddress,
          DestinationIpAddress, RulesetId, ResolverPolicyId,
          ResolverPolicyRuleAction, ResponseCode, Transport, VirtualNetworkId
| order by TimeGenerated desc

Of course this only detects the use of attacker infrastructure, so it still can be dormant and stay undetected.

post scriptum

I still think that Microsoft needs about the lack of backlinks between infrastructure parts. Not only for this but as a general tool to improve visibility and build on. Having to iterate everything to get a clear view of what is connected or has permissions on which resources is bad even when no attacker is in the picture. I found no way to see the link creation between tenants. Maybe I missed something and there is a way to get insight into this from the victim tenant.

The DNS Query logs are genuinely usefull in this case, maybe it is possible to add a column about the DNS zone that was used to answer a query next? That would close another detection gap.