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 | ||