Commit d6b8faec53
Unregistered key
Layout: unified · split
content/blog/2026-03-03-auditing-aws-s3.org added +235
| @@ -0,0 +1,235 @@ | ||
| 1 | #+date: [2026-03-03 Tue 18:50:23] | |
| 2 | #+title: Auditing AWS S3 Buckets | |
| 3 | #+description: Learn how to audit AWS S3 buckets for public access. | |
| 4 | #+slug: auditing-aws-s3 | |
| 5 | ||
| 6 | This is the latest in my series of posts on auditing AWS, a cloud platform that | |
| 7 | has existed for around two decades but can still be a mystery to auditors who | |
| 8 | aren't familiar with how cloud platforms operate. | |
| 9 | ||
| 10 | One of the older, and most popular, offerings from AWS is Simple Storage Service | |
| 11 | (S3), a scalable object storage service that can hold any type of data. With | |
| 12 | this ease of use comes risk. | |
| 13 | ||
| 14 | Public S3 buckets are one of the most common and highest-profile AWS | |
| 15 | misconfigurations. The challenge for auditors is that "public" in S3 isn't a | |
| 16 | single setting. It's the combination of three separate controls, and a bucket | |
| 17 | can appear restricted at one layer while still being exposed at another. | |
| 18 | ||
| 19 | This post walks through a script that checks all three layers for every bucket | |
| 20 | in the account and produces a CSV report. | |
| 21 | ||
| 22 | The script used in this post is available at [[https://github.com/audit-labs/audit-tools/blob/main/applications/aws/aws_s3_buckets.sh][audit-labs/audit-tools]]. | |
| 23 | ||
| 24 | * Background: How S3 Controls Public Access | |
| 25 | ||
| 26 | Let's start with the basics. Before running anything, it helps to understand the | |
| 27 | three layers the script checks and how they interact. | |
| 28 | ||
| 29 | ** Public Access Block | |
| 30 | ||
| 31 | Public Access Block (PAB) is a set of four flags that can be applied at the | |
| 32 | account level, the bucket level, or both. When all four are enabled, they | |
| 33 | override any bucket policy or ACL that would otherwise grant public access. | |
| 34 | ||
| 35 | The four flags are: | |
| 36 | ||
| 37 | - ~BlockPublicAcls~: Prevents new ACLs that grant public access and ignores | |
| 38 | existing ones. | |
| 39 | - ~IgnorePublicAcls~: Ignores all public ACLs on the bucket. | |
| 40 | - ~BlockPublicPolicy~: Prevents bucket policies that grant public access. | |
| 41 | - ~RestrictPublicBuckets~: Restricts access to buckets with public policies to | |
| 42 | only AWS services and authorized users within the account. | |
| 43 | ||
| 44 | The script checks whether all four flags are enabled at the bucket level. If any | |
| 45 | one of them is missing or disabled, the bucket is marked ~FALSE-VULNERABLE~. If | |
| 46 | PAB is missing entirely (no configuration exists at all), the bucket is marked | |
| 47 | ~CRITICAL-MISSING~, which is the highest-risk state. | |
| 48 | ||
| 49 | #+begin_note | |
| 50 | *Note:* While this script checks the bucket level PAB, an account-level PAB may | |
| 51 | exist as well. If it's enabled at the account level, the bucket is safe | |
| 52 | regardless of its individual settings. | |
| 53 | #+end_note | |
| 54 | ||
| 55 | ** Bucket Policy Status | |
| 56 | ||
| 57 | AWS evaluates each bucket policy and exposes an ~IsPublic~ flag that reflects | |
| 58 | whether the policy grants public access. The script checks this flag directly | |
| 59 | using ~get-bucket-policy-status~. If no bucket policy exists, this column shows ~No | |
| 60 | Policy~, which is not a finding on its own. Rather, it's a data point that | |
| 61 | instructs you to keep looking at further evidence. | |
| 62 | ||
| 63 | ** ACLs | |
| 64 | ||
| 65 | S3 ACLs predate bucket policies and are largely considered legacy at this point, | |
| 66 | but they're still in use and still a source of public exposure. The script | |
| 67 | checks whether any ACL grants ~READ~ or ~WRITE~ permissions to the ~AllUsers~ group, | |
| 68 | which represents the public internet. | |
| 69 | ||
| 70 | ** How the Three Layers Interact | |
| 71 | ||
| 72 | PAB is the highest authority. If PAB is fully enabled at the bucket level, it | |
| 73 | overrides any public bucket policy or ACL. This means a bucket can have a | |
| 74 | publicly permissive policy and still be safe, as long as PAB is fully | |
| 75 | restricted. | |
| 76 | ||
| 77 | The reverse is also true. A bucket with no public policy and no public ACLs is | |
| 78 | still at risk if PAB is missing or incomplete, because nothing is in place to | |
| 79 | prevent a future policy or ACL change from exposing it. | |
| 80 | ||
| 81 | * What the Script Does | |
| 82 | ||
| 83 | The script lists every bucket in the account, determines each bucket's region, | |
| 84 | runs all three checks against it, and appends the results to a CSV file. | |
| 85 | ||
| 86 | It runs in three steps for each bucket: | |
| 87 | ||
| 88 | 1. Determines the bucket's region by trying ~get-bucket-location~ against a list | |
| 89 | of configured regions; | |
| 90 | 2. Checks PAB, bucket policy status, and ACLs independently; | |
| 91 | 3. Derives an ~OverallPublicStatus~ from the three checks and writes the row to | |
| 92 | ~s3_full_public_access_audit.csv~. | |
| 93 | ||
| 94 | * Prerequisites | |
| 95 | ||
| 96 | You'll need: | |
| 97 | ||
| 98 | - AWS CLI installed (or access to CloudShell) and configured with credentials | |
| 99 | that have read access to ~s3:ListAllMyBuckets~, ~s3:GetBucketLocation~, | |
| 100 | ~s3:GetBucketPublicAccessBlock~, ~s3:GetBucketPolicyStatus~, and ~s3:GetBucketAcl~ | |
| 101 | - ~jq~ installed | |
| 102 | - The ~AWS_REGIONS~ variable in the script updated to include any regions your | |
| 103 | organization uses | |
| 104 | ||
| 105 | Check and update the region list at the top of the script before running: | |
| 106 | ||
| 107 | #+begin_src bash | |
| 108 | AWS_REGIONS="us-east-1 us-west-2 eu-central-1 ap-southeast-2" | |
| 109 | #+end_src | |
| 110 | ||
| 111 | Then run it: | |
| 112 | ||
| 113 | #+begin_src bash | |
| 114 | chmod +x aws_s3_buckets.sh | |
| 115 | ./aws_s3_buckets.sh | |
| 116 | #+end_src | |
| 117 | ||
| 118 | * Deriving Overall Public Status | |
| 119 | ||
| 120 | The most important logic in the script is how it combines the three checks into | |
| 121 | a single ~OverallPublicStatus~. PAB is evaluated first and takes precedence: | |
| 122 | ||
| 123 | #+begin_src bash | |
| 124 | if [ "$PAB_FULLY_RESTRICTED" = "CRITICAL-MISSING" ]; then | |
| 125 | OVERALL_PUBLIC_STATUS="TRUE - PAB Missing (CRITICAL)" | |
| 126 | elif [ "$OVERALL_PUBLIC_STATUS" != "FALSE" ] && [ "$PAB_FULLY_RESTRICTED" != "TRUE" ]; then | |
| 127 | : # Status already set by Policy or ACL check above | |
| 128 | fi | |
| 129 | #+end_src | |
| 130 | ||
| 131 | If PAB is fully restricted (~TRUE~), the overall status stays ~FALSE~ regardless of | |
| 132 | what the policy or ACL checks find. If PAB is missing entirely, the overall | |
| 133 | status is immediately set to critical. If PAB is present but incomplete | |
| 134 | (~FALSE-VULNERABLE~), the overall status reflects whatever the policy or ACL | |
| 135 | checks found. | |
| 136 | ||
| 137 | * Reading the Output | |
| 138 | ||
| 139 | The script prints progress to the terminal as it runs and saves the full results | |
| 140 | to ~s3_full_public_access_audit.csv~: | |
| 141 | ||
| 142 | #+begin_src text | |
| 143 | Starting FULL S3 Public Access Audit for the CURRENT account... | |
| 144 | --- | |
| 145 | 1. Retrieving all bucket names... | |
| 146 | Processing bucket: 13bf5920-a09f-47bc-a75a-394a09f18d6a | |
| 147 | Region determined: eu-west-1 | |
| 148 | Final Status: FALSE | |
| 149 | Processing bucket: c67fa6bd-2fd5-4bc5-825d-587fb535bf2e | |
| 150 | Region determined: eu-west-1 | |
| 151 | Final Status: FALSE | |
| 152 | --- | |
| 153 | Audit Complete. | |
| 154 | Final report saved to s3_full_public_access_audit.csv | |
| 155 | #+end_src | |
| 156 | ||
| 157 | #+begin_src text | |
| 158 | BucketName,Region,PAB_FullyRestricted,Policy_IsPublic,ACL_AllUsersRead,ACL_AllUsersWrite,OverallPublicStatus | |
| 159 | 13bf5920-a09f-47bc-a75a-394a09f18d6a,eu-west-1,FALSE-VULNERABLE,No Policy,FALSE,FALSE,"FALSE" | |
| 160 | c67fa6bd-2fd5-4bc5-825d-587fb535bf2e,eu-west-1,TRUE,No Policy,FALSE,FALSE,"FALSE" | |
| 161 | #+end_src | |
| 162 | ||
| 163 | #+caption: S3 Public Access Audit Results | |
| 164 | #+attr_html: :alt Terminal output of aws_s3_buckets.sh showing the per-bucket audit results and CSV report. | |
| 165 | [[https://img.cleberg.net/blog/20260303-auditing-aws-s3/output.webp]] | |
| 166 | ||
| 167 | Here's how to read each column: | |
| 168 | ||
| 169 | - *PAB_FullyRestricted*: ~TRUE~ means all four PAB flags are enabled at the bucket | |
| 170 | level. ~FALSE-VULNERABLE~ means PAB exists but is incomplete. ~CRITICAL-MISSING~ | |
| 171 | means no PAB configuration exists at all. | |
| 172 | - *Policy_IsPublic*: ~true~ means AWS has determined the bucket policy grants public | |
| 173 | access. ~false~ means it doesn't. ~No Policy~ means no bucket policy is attached. | |
| 174 | - *ACL_AllUsersRead / ACL_AllUsersWrite*: ~TRUE~ means the bucket has an ACL | |
| 175 | granting that permission to the public internet. ~FALSE~ means it doesn't. | |
| 176 | - *OverallPublicStatus*: ~FALSE~ means the bucket is not publicly accessible based | |
| 177 | on all three checks. ~TRUE~ values include the specific reason (e.g., ~TRUE - ACL | |
| 178 | Read~, ~TRUE - Policy~, ~TRUE - PAB Missing (CRITICAL)~). | |
| 179 | ||
| 180 | Note the first bucket in the example above: ~PAB_FullyRestricted~ is | |
| 181 | ~FALSE-VULNERABLE~ but ~OverallPublicStatus~ is still ~FALSE~. This means the bucket | |
| 182 | isn't currently public, but it's missing the PAB configuration that would | |
| 183 | prevent it from becoming public if a policy or ACL were changed. | |
| 184 | ||
| 185 | * Common Exceptions and False Positives | |
| 186 | ||
| 187 | - *Static website hosting*: Buckets used for static website hosting are | |
| 188 | intentionally public. These will show up with ~TRUE~ overall status and public | |
| 189 | ACLs or policies. Confirm the business purpose with IT and document them as | |
| 190 | accepted exceptions rather than findings. | |
| 191 | - *Policy_IsPublic with No Policy*: A ~No Policy~ result in the policy column is not | |
| 192 | a finding. It simply means no bucket policy is attached. The overall status | |
| 193 | depends on PAB and ACLs. | |
| 194 | - *FALSE-VULNERABLE with FALSE overall status*: This is a configuration weakness | |
| 195 | rather than an active exposure finding. The bucket isn't currently public, but | |
| 196 | PAB is not fully enabled, meaning a future change could expose it. Write this | |
| 197 | up separately from buckets that are actively public, as the risk and | |
| 198 | remediation are different. | |
| 199 | - *Cross-account or service-specific policies*: Some bucket policies grant access | |
| 200 | to specific AWS accounts or services (e.g., CloudFront, Config, ELB logging). | |
| 201 | AWS may flag these as ~IsPublic~ even though they're not publicly accessible in | |
| 202 | practice. Review the actual bucket policy before raising it as a finding. | |
| 203 | - *Region coverage*: If a bucket's region isn't in the ~AWS_REGIONS~ list, the | |
| 204 | script can't determine its location and will skip it with a warning. Make sure | |
| 205 | the region list in the script covers your organization's full footprint before | |
| 206 | treating the CSV as a complete population. | |
| 207 | ||
| 208 | * How to Write Up the Finding | |
| 209 | ||
| 210 | There are two distinct finding types this script can surface, and they should be | |
| 211 | written up separately. | |
| 212 | ||
| 213 | *Finding 1: Bucket with incomplete or missing PAB (configuration weakness)* | |
| 214 | ||
| 215 | *Deficiency:* S3 bucket ~13bf5920-a09f-47bc-a75a-394a09f18d6a~ does not have all four | |
| 216 | Public Access Block flags enabled at the bucket level (~PAB_FullyRestricted: | |
| 217 | FALSE-VULNERABLE~). | |
| 218 | ||
| 219 | *Risk:* Without fully enabled PAB, a future bucket policy or ACL change could | |
| 220 | expose the bucket to the public internet without additional controls in place to | |
| 221 | prevent it. | |
| 222 | ||
| 223 | *Finding 2: Bucket actively accessible to the public* | |
| 224 | ||
| 225 | *Deficiency:* S3 bucket ~example-bucket~ has an ACL granting ~READ~ access to the | |
| 226 | ~AllUsers~ group (~ACL_AllUsersRead: TRUE~, ~OverallPublicStatus: TRUE - ACL Read~). | |
| 227 | ||
| 228 | *Risk:* Publicly accessible S3 buckets expose any objects stored within them to | |
| 229 | the internet, potentially including sensitive data. | |
| 230 | ||
| 231 | To filter the CSV to only buckets with a non-FALSE overall status: | |
| 232 | ||
| 233 | #+begin_src bash | |
| 234 | awk -F',' 'NR==1 || $7 != "\"FALSE\""' s3_full_public_access_audit.csv | |
| 235 | #+end_src | |