CoursesAdvanced cloud securityEgress control, DNS & firewalls

Egress control, DNS & firewalls

NAT, egress proxies, DNS filtering, and exfiltration defense.

Advanced35 min · lesson 5 of 15

At an airport, arrivals barely get a glance. Departures get the real screening: the scanners, the watchlists, the agent who wants to know why one bag is full of hard drives. Most cloud networks run it the other way around. Teams spend their budget on the way in, on security groups, web application firewalls (WAFs), load balancers, and TLS (transport layer security) termination, and leave the way out completely unstaffed. Egress control is departures screening for your network. It decides what is allowed to leave, and it writes down every attempt.

Egress is any traffic leaving your VPC (virtual private cloud on AWS), your VNet (virtual network on Azure), or your subnet, bound for the internet or another network. The two things an attacker wants most both ride out on it. Exfiltration is your data leaving for somewhere it should not go. Command-and-control (C2) is a compromised machine phoning home to an attacker's server for its next set of orders. Both happen near the end of an attack, and both need one thing: an open path out. A default-open outbound policy assumes everything inside the fence can be trusted, the exact assumption a breach has already broken. Flip it. Pin egress to the short, stable list of places a workload truly needs, a package mirror, an internal API (application programming interface), a payment gateway, and deny the rest. For most workloads that list is a handful of names, so an allowlist is practical, and any reach toward an unlisted host turns from a silent success into a loud alert.

NAT moves the mail, it doesn't read it

A large office sends all its outgoing post through one mailroom, which stamps the company's street address on every envelope. The outside world sees one building, not five thousand desks. That is Network Address Translation, or NAT: many private machines share a single public IP (internet protocol) address on the way out. AWS calls its box a NAT Gateway, Google calls it Cloud NAT, Azure calls it a NAT Gateway as well. All three do the same job. They hand private workloads a route to the internet and one stable public address to leave from.

Here is the trap that catches a lot of teams. The mailroom stamps the envelope. It does not open it, read it, or check the recipient against any list. A NAT gateway is the same. It translates addresses and does nothing else. So 'we have a NAT gateway' and 'we control what leaves' are two different claims, and plenty of production accounts quietly treat the first as if it were the second. The NAT gives every compromised pod (a running container) the same clean exit your real traffic uses. To control egress you need two more things in the path: a default-deny rule that shuts the door, and a filter that decides which envelopes may pass.

You can watch the 'shared exit, no inspection' behavior in a single line. From a workload that has no public address of its own, ask the internet which address it sees you as. The reply is the NAT's public IP, shared by everything in that subnet. Notice what did not happen: nothing checked where the request was going.

terminal
# from a workload with no public IP of its own, sitting behind the NAT gateway
curl -s https://checkip.amazonaws.com
output
203.0.113.47

Close the door: default-deny at layers 3 and 4

Layers 3 and 4 are the network's street-address layer. Layer 3 (internet protocol) carries IP addresses, and layer 4 (transport: TCP, the transmission control protocol, or UDP, the user datagram protocol) carries port numbers. A layer-3/4 rule is a doorman who knows addresses and door numbers and nothing else. Traffic to 10.20.0.0/24 on port 3128, fine. Anything else, no. Every cloud gives you this. AWS has security groups (SGs) and network ACLs (access control lists), Google has VPC firewall rules, Azure has network security groups (NSGs). The model is identical: set the default to deny outbound, then cut narrow holes for the destinations you trust.

AWS hands you a fresh security group with one outbound rule already in place: allow everything to 0.0.0.0/0, the whole internet. That default is the open door. Strip it, then add back only the route to your egress proxy on port 3128, the classic proxy port.

terminal
# strip the default allow-all egress rule, then permit only the proxy subnet
aws ec2 revoke-security-group-egress --group-id sg-0a1b2c3d \
--ip-permissions IpProtocol=-1,IpRanges='[{CidrIp=0.0.0.0/0}]'
aws ec2 authorize-security-group-egress --group-id sg-0a1b2c3d \
--ip-permissions 'IpProtocol=tcp,FromPort=3128,ToPort=3128,IpRanges=[{CidrIp=10.20.0.0/24,Description=egress-proxy}]'
output
{
"Return": true
}
{
"Return": true,
"SecurityGroupRules": [
{
"SecurityGroupRuleId": "sgr-0a1b2c3d4e5f6a7b8",
"GroupId": "sg-0a1b2c3d",
"IsEgress": true,
"IpProtocol": "tcp",
"FromPort": 3128,
"ToPort": 3128,
"CidrIpv4": "10.20.0.0/24",
"Description": "egress-proxy"
}
]
}
terminal
# a low-priority EGRESS deny that catches everything an allow rule didn't
gcloud compute firewall-rules create deny-all-egress --network=prod-vpc \
--direction=EGRESS --action=DENY --rules=all \
--destination-ranges=0.0.0.0/0 --priority=65534
output
Creating firewall...done.
NAME NETWORK DIRECTION PRIORITY ALLOW DENY DISABLED
deny-all-egress prod-vpc EGRESS 65534 all False
terminal
# deny outbound to the Internet service tag from the app subnet's NSG
az network nsg rule create -g prod-rg --nsg-name app-nsg -n deny-internet-out \
--priority 4096 --direction Outbound --access Deny --protocol '*' \
--destination-address-prefixes Internet --destination-port-ranges '*'
output
{
"access": "Deny",
"destinationAddressPrefix": "Internet",
"destinationAddressPrefixes": [],
"destinationPortRange": "*",
"direction": "Outbound",
"name": "deny-internet-out",
"priority": 4096,
"protocol": "*",
"provisioningState": "Succeeded",
"sourceAddressPrefix": "*",
"sourcePortRange": "*"
}

Each cloud breaks ties by priority, and the numbers run backwards from what you might expect: lower number, higher priority. Google's network already holds an invisible allow-all-egress rule at priority 65535, so your deny at 65534 beats it by one. Azure keeps a default AllowInternetOutBound rule at priority 65001 (the Internet service tag is Azure's shorthand for every public address). Your own rules can only sit between 100 and 4096, so even 4096, the weakest slot Azure lets a user rule take, still lands far ahead of 65001 and wins. AWS has no implied allow once you strip the default rule, so an empty egress list denies on its own.

This buys you a real door, but a dumb one. Layer-3/4 rules speak only in IP addresses, and the destinations you care about hide behind moving ones. A package registry or a Git host sits behind a content delivery network (CDN) whose addresses change constantly and are shared by millions of unrelated customers. Pin the registry by address and your rule breaks next week. Allow the CDN's whole range and you have quietly authorized every other tenant on it. To filter by name instead of by number, you climb to layer 7.

Filter by name: the layer-7 egress firewall

Layer 7 is the application layer, where the real hostname lives. A layer-7 egress firewall is the border guard who reads the label on the parcel, not only the road it is headed down. The workload gets no direct route out. Every outbound flow is forced through one inspection point: Network Firewall in a dedicated egress VPC on AWS, Azure Firewall in a hub VNet on Azure, Secure Web Proxy on Google. The device reads the destination hostname and checks it against your allowlist. A name on the list passes. Everything else is dropped and logged.

Where does it read the hostname? In an HTTPS connection (the web's HTTP requests wrapped in TLS encryption) the client announces the server name in the clear, in a field called SNI (server name indication), before encryption starts. Matching on SNI is cheap and needs no certificates, but it trusts the name the client wrote and cannot see the URL path. To filter on the path you have to open the TLS session: the firewall terminates the connection with its own certificate, reads the plaintext, then re-encrypts. That buys path-level control and costs you a certificate pushed to every workload, plus latency and a privacy trade. Sensible default: SNI matching for the bulk of package and API traffic, full interception saved for the few genuinely high-risk paths.

The three products spell the same allowlist three ways. AWS Network Firewall is a stateful (connection-tracking) rule group built on Suricata, an open-source inspection engine; a domain allowlist compiles into rules that match the SNI and the HTTP Host header. Azure Firewall runs application rules that proxy HTTP and HTTPS and match on target FQDNs (fully qualified domain names, the complete dotted hostname). Secure Web Proxy is an explicit forward proxy (a server your clients hand their requests to, which then makes each request on their behalf) that runs a CEL (common expression language) matcher against a URL list. Different knobs, one question: is this hostname on the list, and if not, drop and log.

terminal
# a STATEFUL rule group that allows ONLY these domains (SNI + Host), drops the rest
aws network-firewall create-rule-group \
--rule-group-name egress-fqdn-allowlist --type STATEFUL --capacity 100 \
--rule-group '{"RulesSource":{"RulesSourceList":{"Targets":[".github.com",".pkg.dev","registry.internal"],"TargetTypes":["TLS_SNI","HTTP_HOST"],"GeneratedRulesType":"ALLOWLIST"}}}'
output
{
"UpdateToken": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"RuleGroupResponse": {
"RuleGroupArn": "arn:aws:network-firewall:eu-west-1:123456789012:stateful-rulegroup/egress-fqdn-allowlist",
"RuleGroupName": "egress-fqdn-allowlist",
"RuleGroupId": "12345678-90ab-cdef-1234-567890abcdef",
"Type": "STATEFUL",
"Capacity": 100,
"RuleGroupStatus": "ACTIVE"
}
}

One catch with the AWS version. The allowlist rule group only says which domains to pass. For 'deny the rest' to actually hold, the firewall policy it attaches to has to set its stateful default action to drop, otherwise unmatched traffic sails straight through. The allowlist and the default-drop are two separate settings, and forgetting the second is a popular way to ship a firewall that filters nothing.

terminal
# an application rule collection that allows these FQDNs over HTTPS
az network firewall policy rule-collection-group collection add-filter-collection \
-g hub-rg --policy-name hub-fwpolicy --rule-collection-group-name egress-rcg \
--name approved-fqdns --action Allow --collection-priority 200 \
--rule-name pkg-and-api --rule-type ApplicationRule \
--protocols Https=443 --source-addresses 10.30.0.0/16 \
--target-fqdns '*.github.com' '*.pkg.dev' 'api.internal.contoso.com'
output
{
"name": "egress-rcg",
"priority": 100,
"provisioningState": "Succeeded",
"ruleCollections": [
{
"action": { "type": "Allow" },
"name": "approved-fqdns",
"priority": 200,
"ruleCollectionType": "FirewallPolicyFilterRuleCollection",
"rules": [
{
"name": "pkg-and-api",
"ruleType": "ApplicationRule",
"protocols": [ { "port": 443, "protocolType": "Https" } ],
"sourceAddresses": [ "10.30.0.0/16" ],
"targetFqdns": [ "*.github.com", "*.pkg.dev", "api.internal.contoso.com" ]
}
]
}
]
}
terminal
# a URL list, then an ALLOW rule that matches it (SWP denies unmatched by default)
gcloud network-security url-lists create approved-egress --location=europe-west1 \
--values="github.com,*.github.com,*.pkg.dev,registry.internal"
gcloud network-security gateway-security-policies rules create allow-approved \
--location=europe-west1 --gateway-security-policy=egress-policy --priority=100 \
--session-matcher="inUrlList(host(), 'projects/prod-42/locations/europe-west1/urlLists/approved-egress')" \
--basic-profile=ALLOW --enabled
output
Created url list [approved-egress].
Created rule [allow-approved] in gateway security policy [egress-policy].

Watch how each product treats traffic that matches nothing. Azure Firewall and Secure Web Proxy both deny by default, so an allow rule plus 'no match means drop' is the whole policy. AWS makes you opt into the drop, as above. Get the default right and the allowlist becomes the single, auditable chokepoint every connection must cross. Get it wrong and you have built either a proxy everyone routes around or one that waves everything through.

A broad allowlist re-opens the door you just closed
Allowlisting a shared cloud domain like *.amazonaws.com or *.storage.googleapis.com so your pods can reach your own buckets also authorizes every other tenant's bucket on that same namespace. A compromised pod uploads your data to the attacker's bucket over a connection the firewall reads as an approved name, and the SNI looks identical to a legitimate one. Pin the narrowest name you can (my-lake.s3.eu-west-1.amazonaws.com, never *.amazonaws.com), and back object storage with a resource perimeter that pins the resource rather than the hostname.

DNS: the first hop and the sneakiest exit

Every outbound connection opens with a question to the phone book. DNS (the domain name system) turns a name like api.github.com into an IP address, and it runs before the connection you are trying to filter even exists. That makes it the earliest place to say no. A workload with no route to the internet can usually still reach the cloud's DNS resolver, so blocking the lookup stops the connection before it starts.

DNS is also a smuggling tunnel, and this is the part teams miss. A resolver's job is to forward questions it cannot answer to whoever owns the domain. An attacker who owns evil.example points its nameserver at a machine they control, then has a compromised host encode stolen data into long, random-looking subdomain labels: MFRGG43F.evil.example, ORSXG5A7.evil.example, thousands of them. Your resolver forwards every one to that nameserver, which rebuilds the file on the far end. No connection to a blocked address, no proxy to route around. So the DNS layer does two jobs: block resolution of names you already know are bad, and log every query so you can hunt the tunnels that slip past. The tell is statistical. Baseline the normal length and rate of queries per domain, and if one domain suddenly takes thousands of 60-character labels an hour, that is a channel, and you want eyes on it.

terminal
# create the rule group, build a blocked-domain list, add a BLOCK rule (returns NXDOMAIN), bind the group to the VPC
aws route53resolver create-firewall-rule-group \
--name c2-blocking --creator-request-id frg-2026-07
aws route53resolver create-firewall-domain-list \
--name blocked-c2 --creator-request-id dl-2026-07
aws route53resolver import-firewall-domains \
--firewall-domain-list-id rslvr-fdl-0a1b2c3d4e5f6a7b8 \
--operation REPLACE --domain-file-url s3://sec-config/blocked-domains.txt
aws route53resolver create-firewall-rule \
--firewall-rule-group-id rslvr-frg-9f8e7d6c \
--firewall-domain-list-id rslvr-fdl-0a1b2c3d4e5f6a7b8 \
--priority 100 --action BLOCK --block-response NXDOMAIN \
--name block-known-c2 --creator-request-id fr-2026-07
aws route53resolver associate-firewall-rule-group \
--firewall-rule-group-id rslvr-frg-9f8e7d6c --vpc-id vpc-0abc123 \
--priority 101 --name prod-vpc-dnsfw --creator-request-id assoc-2026-07
output
{
"FirewallRuleGroup": {
"Id": "rslvr-frg-9f8e7d6c",
"Name": "c2-blocking",
"Status": "COMPLETE"
}
}
{
"FirewallDomainList": {
"Id": "rslvr-fdl-0a1b2c3d4e5f6a7b8",
"Name": "blocked-c2",
"Status": "COMPLETE"
}
}
{
"Id": "rslvr-fdl-0a1b2c3d4e5f6a7b8",
"Name": "blocked-c2",
"Status": "IMPORTING"
}
{
"FirewallRule": {
"FirewallRuleGroupId": "rslvr-frg-9f8e7d6c",
"Name": "block-known-c2",
"Priority": 100,
"Action": "BLOCK",
"BlockResponse": "NXDOMAIN"
}
}
{
"FirewallRuleGroupAssociation": {
"Id": "rslvr-frgassoc-0a1b2c3d4e5f6a7b8",
"VpcId": "vpc-0abc123",
"Status": "UPDATING"
}
}
terminal
# a response policy on the VPC, plus a rule that sinkholes bad names to 0.0.0.0
gcloud dns response-policies create egress-guard \
--networks=prod-vpc --description="block known-bad + log"
gcloud dns response-policies rules create block-c2 \
--response-policy=egress-guard --dns-name="*.evil-c2.example." \
--local-data=name="*.evil-c2.example.",type=A,ttl=300,rrdatas=0.0.0.0
output
Created response policy [egress-guard].
Created response policy rule [block-c2].
terminal
# make the firewall the VNet's DNS resolver so it can log and filter, and deny threat-intel hits
az network firewall policy update -g hub-rg --name hub-fwpolicy \
--enable-dns-proxy true --threat-intel-mode Deny
output
{
"dnsSettings": {
"enableProxy": true,
"requireProxyForNetworkRules": null,
"servers": []
},
"name": "hub-fwpolicy",
"provisioningState": "Succeeded",
"threatIntelMode": "Deny"
}

The three block a lookup in different ways, and the difference shows up the moment you test. AWS returns NXDOMAIN (the 'no such name' answer), so the client believes the name does not exist. Google hands back a sinkhole address, a dead end you control (0.0.0.0 here, or an address you monitor) in place of the real one, so you get to see who tried. Azure's threat-intelligence mode denies names and addresses from Microsoft's feed once the firewall is acting as the DNS proxy, which is also what lets it log every query. Choose the sinkhole over NXDOMAIN when you would rather watch the attempt than hide the name.

The exfil that walks through the front door

Here is the attack your name-based allowlist does not catch. A build pipeline's credentials leak, and they are valid. The attacker uses them to read your data lake and copy it to a bucket in their own account on the same cloud. Every request rides a real API, with a real credential, to a hostname your firewall already trusts, because it is the same object-storage endpoint your app hits all day. The network sees nothing wrong. What stops this is a data perimeter: a rule that binds the data to a network and a set of identities, so a valid credential used from the wrong place is turned away by the service itself.

Google builds this in as VPC Service Controls, a fence drawn around managed services like Cloud Storage and BigQuery so data cannot move to a project outside the fence, even with a stolen credential. AWS assembles it from resource-policy conditions, a bucket policy that denies access unless the request arrives through your VPC endpoint (a private doorway into the service that never crosses the internet), plus Block Public Access. Azure does it with storage-account network rules: default-deny, then allow only your subnet or a private endpoint. Same goal on all three. The credential alone is not enough, the request also has to come from the right network.

terminal
# fence Cloud Storage and BigQuery inside a perimeter around one project
gcloud access-context-manager perimeters create prod_perimeter \
--title="prod data perimeter" \
--resources=projects/123456789 \
--restricted-services=storage.googleapis.com,bigquery.googleapis.com \
--policy=987654321
output
Create request issued for: [prod_perimeter]
Waiting for operation [accessPolicies/987654321/servicePerimeters/prod_perimeter/create/1690000000000] to complete...done.
Created service perimeter [prod_perimeter].
bucket-policy.json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyAccessFromOutsideVpce",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::my-lake",
"arn:aws:s3:::my-lake/*"
],
"Condition": {
"StringNotEqualsIfExists": { "aws:SourceVpce": "vpce-0a1b2c3d4e5f6a7b8" },
"BoolIfExists": { "aws:PrincipalIsAWSService": "false" }
}
}
]
}
terminal
# attach the resource perimeter (put-bucket-policy is silent on success), then read from off-perimeter
aws s3api put-bucket-policy --bucket my-lake --policy file://bucket-policy.json
aws s3 cp s3://my-lake/customers.parquet .
output
download failed: s3://my-lake/customers.parquet to ./customers.parquet An error occurred (AccessDenied) when calling the GetObject operation: Access Denied
terminal
# default-deny the storage account, then allow only the app subnet
az storage account update -g prod-rg -n prodlake --default-action Deny
az storage account network-rule add -g prod-rg --account-name prodlake \
--vnet-name prod-vnet --subnet app-subnet
output
{
"bypass": "AzureServices",
"defaultAction": "Deny",
"ipRules": [],
"resourceAccessRules": null,
"virtualNetworkRules": [
{
"action": "Allow",
"state": "Succeeded",
"virtualNetworkResourceId": ".../virtualNetworks/prod-vnet/subnets/app-subnet"
}
]
}
Turning these on can lock you out first
A default-deny egress rule, a VPC Service Controls perimeter, or a storage account set to default-deny will happily block your own CI runners, the update agent, package mirrors, and OS patches right along with the attacker. Roll them out on a canary workload (one low-stakes machine you change first to see what breaks) and use the safe-preview modes where they exist: VPC Service Controls has a dry-run mode that logs violations without enforcing them, so you see the damage before it is real.

Prove the door is shut, then keep watching

A firewall rule you have not tested is a hope, not a control. From inside the locked-down subnet, check three things: a known-bad name should fail to resolve, an unlisted host should be dropped, and an approved host should still work. If all three behave, the policy is real.

terminal
# 1. a known-bad name: the DNS firewall should refuse to resolve it
dig blocked.evil-c2.example +noall +comments | grep status
# 2. an unlisted host: the L7 firewall should drop the connection
curl -sS --max-time 5 https://telemetry.unknown.example
# 3. an approved host: must still succeed
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' https://api.github.com
output
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 51920
curl: (28) Connection timed out after 5001 milliseconds
HTTP 200

Two records close the loop. Flow logs (VPC Flow Logs on AWS and Google, VNet flow logs on Azure) record who talked to whom, so you can rebuild an incident after the fact, and DNS query logs feed the tunnel hunt from earlier. One decision keeps the bill sane: send only internet-bound traffic through the firewall or proxy, and let private connectivity (PrivateLink on AWS, Private Service Connect on Google, Private Endpoints on Azure) carry your in-cloud service calls around it. That is cheaper, and it shrinks the surface where an attacker could spoof an SNI to look approved. Build every layer from landing-zone templates, the version-controlled blueprint each new account is stamped from, so your accounts, subscriptions, and projects come out identical instead of three snowflakes you have to reason about one at a time.

One outbound connection, five gates
1App resolves the name
DNS firewall checks it against known-bad and logs the query
2No route to the internet
the subnet's default route points at the chokepoint, not the wild
3Layer 3/4 gate
security group / NSG / VPC firewall: right IP and port?
4Layer 7 allowlist
SNI / Host / URL on the list, or drop and log
5NAT and out
one shared egress IP; flow logs keep the receipt
The data perimeter sits on the service side, not in this line: even a request that clears all five gates cannot reach storage outside its fence.
Quick check
01The lesson compares a NAT gateway to an office mailroom that stamps the company address on every envelope but never opens one. What is the security lesson in that?
Correct — NAT is address translation only, so owning one says nothing about whether egress is controlled.
Incorrect — that describes a default-deny firewall rule, which is a separate control from NAT.
Incorrect — name-based filtering is the job of a layer-7 egress firewall, not a NAT gateway.
Incorrect — NAT exists to get outbound flows from private hosts to the internet.
02Your egress firewall allows *.pkg.dev by matching the TLS SNI field, without decrypting traffic. What can it NOT do in that mode?
Incorrect — reading the client-presented SNI name is exactly what this mode does.
Correct — the path lives inside the encrypted body, so only TLS interception can see it.
Incorrect — the destination IP is visible at layer 3/4 no matter what encryption is used.
Incorrect — it can log the connection and the SNI value it matched on.
03Your egress proxy allowlists *.s3.amazonaws.com so app pods can reach your data-lake bucket. A pod is compromised and data keeps leaving. Why, and what actually stops it?
Incorrect — S3 is HTTPS over TCP and fully visible to an L7 proxy.
Incorrect — Wrong and harmful: the SNI was matched and allowed on purpose, and downgrading TLS weakens security for no gain.
Correct — a shared-domain allowlist trusts the whole namespace, so you must pin the resource, not the hostname.
Incorrect — logging is about detecting the exfil afterward, not blocking the path it used.

Try this

Run curl -s https://checkip.amazonaws.com on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a broad allowlist re-opens the door you just closed. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related