├── .gitignore ├── function.png ├── assets ├── dynamic-challenge-modal.js ├── dynamic-challenge-create.js ├── dynamic-challenge-update.js ├── dynamic-challenge-create.njk ├── dynamic-challenge-modal.njk └── dynamic-challenge-update.njk ├── README.md └── __init__.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | -------------------------------------------------------------------------------- /function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CTFd/DynamicValueChallenge/HEAD/function.png -------------------------------------------------------------------------------- /assets/dynamic-challenge-modal.js: -------------------------------------------------------------------------------- 1 | window.challenge.renderer = new markdownit({ 2 | html: true, 3 | }); 4 | 5 | window.challenge.preRender = function () { 6 | 7 | }; 8 | 9 | window.challenge.render = function (markdown) { 10 | return window.challenge.renderer.render(markdown); 11 | }; 12 | 13 | 14 | window.challenge.postRender = function () { 15 | 16 | }; 17 | 18 | window.challenge.submit = function (cb, preview) { 19 | var chal_id = $('#chal-id').val(); 20 | var answer = $('#answer-input').val(); 21 | var nonce = $('#nonce').val(); 22 | 23 | var url = "/chal/"; 24 | if (preview) { 25 | url = "/admin/chal/"; 26 | } 27 | 28 | $.post(script_root + url + chal_id, { 29 | key: answer, 30 | nonce: nonce 31 | }, function (data) { 32 | cb(data); 33 | }); 34 | }; -------------------------------------------------------------------------------- /assets/dynamic-challenge-create.js: -------------------------------------------------------------------------------- 1 | // Markdown Preview 2 | $('#desc-edit').on('shown.bs.tab', function (event) { 3 | if (event.target.hash == '#desc-preview'){ 4 | var editor_value = $('#desc-editor').val(); 5 | $(event.target.hash).html( 6 | window.challenge.render(editor_value) 7 | ); 8 | } 9 | }); 10 | $('#new-desc-edit').on('shown.bs.tab', function (event) { 11 | if (event.target.hash == '#new-desc-preview'){ 12 | var editor_value = $('#new-desc-editor').val(); 13 | $(event.target.hash).html( 14 | window.challenge.render(editor_value) 15 | ); 16 | } 17 | }); 18 | $("#solve-attempts-checkbox").change(function() { 19 | if(this.checked) { 20 | $('#solve-attempts-input').show(); 21 | } else { 22 | $('#solve-attempts-input').hide(); 23 | $('#max_attempts').val(''); 24 | } 25 | }); 26 | 27 | $(document).ready(function(){ 28 | $('[data-toggle="tooltip"]').tooltip(); 29 | }); 30 | -------------------------------------------------------------------------------- /assets/dynamic-challenge-update.js: -------------------------------------------------------------------------------- 1 | $('#submit-key').click(function (e) { 2 | submitkey($('#chalid').val(), $('#answer').val()) 3 | }); 4 | 5 | $('#submit-keys').click(function (e) { 6 | e.preventDefault(); 7 | $('#update-keys').modal('hide'); 8 | }); 9 | 10 | $('#limit_max_attempts').change(function() { 11 | if(this.checked) { 12 | $('#chal-attempts-group').show(); 13 | } else { 14 | $('#chal-attempts-group').hide(); 15 | $('#chal-attempts-input').val(''); 16 | } 17 | }); 18 | 19 | // Markdown Preview 20 | $('#desc-edit').on('shown.bs.tab', function (event) { 21 | if (event.target.hash == '#desc-preview') { 22 | var editor_value = $('#desc-editor').val(); 23 | $(event.target.hash).html( 24 | window.challenge.render(editor_value) 25 | ); 26 | } 27 | }); 28 | $('#new-desc-edit').on('shown.bs.tab', function (event) { 29 | if (event.target.hash == '#new-desc-preview') { 30 | var editor_value = $('#new-desc-editor').val(); 31 | $(event.target.hash).html( 32 | window.challenge.render(editor_value) 33 | ); 34 | } 35 | }); 36 | 37 | function loadchal(id, update) { 38 | $.get(script_root + '/admin/chal/' + id, function(obj){ 39 | $('#desc-write-link').click(); // Switch to Write tab 40 | if (typeof update === 'undefined') 41 | $('#update-challenge').modal(); 42 | }); 43 | } 44 | 45 | function openchal(id){ 46 | loadchal(id); 47 | } 48 | 49 | $(document).ready(function(){ 50 | $('[data-toggle="tooltip"]').tooltip(); 51 | }); 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dynamic Value Challenges for CTFd 2 | 3 | #### NOTE: This plugin has been integrated into CTFd as of 2.0.0. Please file any issues at the [main CTFd repo](https://github.com/CTFd/CTFd) 4 | 5 | It's becoming commonplace in CTF to see challenges whose point values decrease 6 | after each solve. 7 | 8 | This CTFd plugin creates a dynamic challenge type which implements this 9 | behavior. Each dynamic challenge starts with an initial point value and then 10 | each solve will decrease the value of the challenge until a minimum point value. 11 | 12 | By reducing the value of the challenge on each solve, all users who have previously 13 | solved the challenge will have lowered scores. Thus an easier and more solved 14 | challenge will naturally have a lower point value than a harder and less solved 15 | challenge. 16 | 17 | Within CTFd you are free to mix and match regular and dynamic challenges. 18 | 19 | The current implementation requires the challenge to keep track of three values: 20 | 21 | * Initial - The original point valuation 22 | * Decay - The amount of solves before the challenge will be at the minimum 23 | * Minimum - The lowest possible point valuation 24 | 25 | The value decay logic is implemented with the following math: 26 | 27 | 34 | 35 | ![](https://raw.githubusercontent.com/CTFd/DynamicValueChallenge/master/function.png) 36 | 37 | or in pseudo code: 38 | 39 | ``` 40 | value = (((minimum - initial)/(decay**2)) * (solve_count**2)) + initial 41 | value = math.ceil(value) 42 | ``` 43 | 44 | If the number generated is lower than the minimum, the minimum is chosen 45 | instead. 46 | 47 | A parabolic function is chosen instead of an exponential or logarithmic decay function 48 | so that higher valued challenges have a slower drop from their initial value. 49 | 50 | # Installation 51 | 52 | **REQUIRES: CTFd >= v1.2.0** 53 | 54 | 1. Clone this repository to `CTFd/plugins`. It is important that the folder is 55 | named `DynamicValueChallenge` so CTFd can serve the files in the `assets` 56 | directory. 57 | -------------------------------------------------------------------------------- /assets/dynamic-challenge-create.njk: -------------------------------------------------------------------------------- 1 |
2 |
3 | Dynamic value challenges decrease in value as they receive solves. The more solves a dynamic challenge has, the lower its value is to everyone who has solved it. 4 |
5 | 6 |
7 | 10 | 11 |
12 |
13 | 16 | 17 |
18 | 19 | 27 | 28 |
29 |
30 |
31 | 34 | 35 |
36 |
37 |
38 |
39 |
40 | 41 |
42 | 45 | 46 |
47 | 48 |
49 | 52 | 53 |
54 | 55 |
56 | 59 | 60 |
61 | 62 |
63 | 66 | 67 |
68 | 69 |
70 | 74 |
75 | 76 |
77 |
78 | 82 |
83 |
84 | 85 |
86 |
87 | 91 |
92 |
93 | 94 |
95 | 101 |
102 | 103 |
104 | 107 | 108 | Attach multiple files using Control+Click or Cmd+Click. 109 |
110 | 111 | 112 | 113 | 114 |
115 | 116 |
117 |
-------------------------------------------------------------------------------- /assets/dynamic-challenge-modal.njk: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/dynamic-challenge-update.njk: -------------------------------------------------------------------------------- 1 | 140 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import division # Use floating point for math calculations 2 | from CTFd.plugins.challenges import BaseChallenge, CHALLENGE_CLASSES 3 | from CTFd.plugins import register_plugin_assets_directory 4 | from CTFd.plugins.keys import get_key_class 5 | from CTFd.models import db, Solves, WrongKeys, Keys, Challenges, Files, Tags, Teams, Hints 6 | from CTFd import utils 7 | import math 8 | 9 | 10 | class DynamicValueChallenge(BaseChallenge): 11 | id = "dynamic" # Unique identifier used to register challenges 12 | name = "dynamic" # Name of a challenge type 13 | templates = { # Handlebars templates used for each aspect of challenge editing & viewing 14 | 'create': '/plugins/DynamicValueChallenge/assets/dynamic-challenge-create.njk', 15 | 'update': '/plugins/DynamicValueChallenge/assets/dynamic-challenge-update.njk', 16 | 'modal': '/plugins/DynamicValueChallenge/assets/dynamic-challenge-modal.njk', 17 | } 18 | scripts = { # Scripts that are loaded when a template is loaded 19 | 'create': '/plugins/DynamicValueChallenge/assets/dynamic-challenge-create.js', 20 | 'update': '/plugins/DynamicValueChallenge/assets/dynamic-challenge-update.js', 21 | 'modal': '/plugins/DynamicValueChallenge/assets/dynamic-challenge-modal.js', 22 | } 23 | 24 | @staticmethod 25 | def create(request): 26 | """ 27 | This method is used to process the challenge creation request. 28 | 29 | :param request: 30 | :return: 31 | """ 32 | files = request.files.getlist('files[]') 33 | 34 | # Create challenge 35 | chal = DynamicChallenge( 36 | name=request.form['name'], 37 | description=request.form['description'], 38 | value=request.form['value'], 39 | category=request.form['category'], 40 | type=request.form['chaltype'], 41 | minimum=request.form['minimum'], 42 | decay=request.form['decay'] 43 | ) 44 | 45 | if 'hidden' in request.form: 46 | chal.hidden = True 47 | else: 48 | chal.hidden = False 49 | 50 | max_attempts = request.form.get('max_attempts') 51 | if max_attempts and max_attempts.isdigit(): 52 | chal.max_attempts = int(max_attempts) 53 | 54 | db.session.add(chal) 55 | db.session.commit() 56 | 57 | flag = Keys(chal.id, request.form['key'], request.form['key_type[0]']) 58 | if request.form.get('keydata'): 59 | flag.data = request.form.get('keydata') 60 | db.session.add(flag) 61 | 62 | db.session.commit() 63 | 64 | for f in files: 65 | utils.upload_file(file=f, chalid=chal.id) 66 | 67 | db.session.commit() 68 | 69 | @staticmethod 70 | def read(challenge): 71 | """ 72 | This method is in used to access the data of a challenge in a format processable by the front end. 73 | 74 | :param challenge: 75 | :return: Challenge object, data dictionary to be returned to the user 76 | """ 77 | challenge = DynamicChallenge.query.filter_by(id=challenge.id).first() 78 | data = { 79 | 'id': challenge.id, 80 | 'name': challenge.name, 81 | 'value': challenge.value, 82 | 'initial': challenge.initial, 83 | 'decay': challenge.decay, 84 | 'minimum': challenge.minimum, 85 | 'description': challenge.description, 86 | 'category': challenge.category, 87 | 'hidden': challenge.hidden, 88 | 'max_attempts': challenge.max_attempts, 89 | 'type': challenge.type, 90 | 'type_data': { 91 | 'id': DynamicValueChallenge.id, 92 | 'name': DynamicValueChallenge.name, 93 | 'templates': DynamicValueChallenge.templates, 94 | 'scripts': DynamicValueChallenge.scripts, 95 | } 96 | } 97 | return challenge, data 98 | 99 | @staticmethod 100 | def update(challenge, request): 101 | """ 102 | This method is used to update the information associated with a challenge. This should be kept strictly to the 103 | Challenges table and any child tables. 104 | 105 | :param challenge: 106 | :param request: 107 | :return: 108 | """ 109 | challenge = DynamicChallenge.query.filter_by(id=challenge.id).first() 110 | 111 | challenge.name = request.form['name'] 112 | challenge.description = request.form['description'] 113 | challenge.value = int(request.form.get('value', 0)) if request.form.get('value', 0) else 0 114 | challenge.max_attempts = int(request.form.get('max_attempts', 0)) if request.form.get('max_attempts', 0) else 0 115 | challenge.category = request.form['category'] 116 | challenge.hidden = 'hidden' in request.form 117 | 118 | challenge.initial = request.form['initial'] 119 | challenge.minimum = request.form['minimum'] 120 | challenge.decay = request.form['decay'] 121 | 122 | db.session.commit() 123 | db.session.close() 124 | 125 | @staticmethod 126 | def delete(challenge): 127 | """ 128 | This method is used to delete the resources used by a challenge. 129 | 130 | :param challenge: 131 | :return: 132 | """ 133 | WrongKeys.query.filter_by(chalid=challenge.id).delete() 134 | Solves.query.filter_by(chalid=challenge.id).delete() 135 | Keys.query.filter_by(chal=challenge.id).delete() 136 | files = Files.query.filter_by(chal=challenge.id).all() 137 | for f in files: 138 | utils.delete_file(f.id) 139 | Files.query.filter_by(chal=challenge.id).delete() 140 | Tags.query.filter_by(chal=challenge.id).delete() 141 | Hints.query.filter_by(chal=challenge.id).delete() 142 | DynamicChallenge.query.filter_by(id=challenge.id).delete() 143 | Challenges.query.filter_by(id=challenge.id).delete() 144 | db.session.commit() 145 | 146 | @staticmethod 147 | def attempt(chal, request): 148 | """ 149 | This method is used to check whether a given input is right or wrong. It does not make any changes and should 150 | return a boolean for correctness and a string to be shown to the user. It is also in charge of parsing the 151 | user's input from the request itself. 152 | 153 | :param chal: The Challenge object from the database 154 | :param request: The request the user submitted 155 | :return: (boolean, string) 156 | """ 157 | provided_key = request.form['key'].strip() 158 | chal_keys = Keys.query.filter_by(chal=chal.id).all() 159 | for chal_key in chal_keys: 160 | if get_key_class(chal_key.type).compare(chal_key, provided_key): 161 | return True, 'Correct' 162 | return False, 'Incorrect' 163 | 164 | @staticmethod 165 | def solve(team, chal, request): 166 | """ 167 | This method is used to insert Solves into the database in order to mark a challenge as solved. 168 | 169 | :param team: The Team object from the database 170 | :param chal: The Challenge object from the database 171 | :param request: The request the user submitted 172 | :return: 173 | """ 174 | chal = DynamicChallenge.query.filter_by(id=chal.id).first() 175 | 176 | solve_count = Solves.query.join(Teams, Solves.teamid == Teams.id).filter(Solves.chalid==chal.id, Teams.banned==False).count() 177 | 178 | # It is important that this calculation takes into account floats. 179 | # Hence this file uses from __future__ import division 180 | value = ( 181 | ( 182 | (chal.minimum - chal.initial)/(chal.decay**2) 183 | ) * (solve_count**2) 184 | ) + chal.initial 185 | 186 | value = math.ceil(value) 187 | 188 | if value < chal.minimum: 189 | value = chal.minimum 190 | 191 | chal.value = value 192 | 193 | provided_key = request.form['key'].strip() 194 | solve = Solves(teamid=team.id, chalid=chal.id, ip=utils.get_ip(req=request), flag=provided_key) 195 | db.session.add(solve) 196 | 197 | db.session.commit() 198 | db.session.close() 199 | 200 | @staticmethod 201 | def fail(team, chal, request): 202 | """ 203 | This method is used to insert WrongKeys into the database in order to mark an answer incorrect. 204 | 205 | :param team: The Team object from the database 206 | :param chal: The Challenge object from the database 207 | :param request: The request the user submitted 208 | :return: 209 | """ 210 | provided_key = request.form['key'].strip() 211 | wrong = WrongKeys(teamid=team.id, chalid=chal.id, ip=utils.get_ip(request), flag=provided_key) 212 | db.session.add(wrong) 213 | db.session.commit() 214 | db.session.close() 215 | 216 | 217 | class DynamicChallenge(Challenges): 218 | __mapper_args__ = {'polymorphic_identity': 'dynamic'} 219 | id = db.Column(None, db.ForeignKey('challenges.id'), primary_key=True) 220 | initial = db.Column(db.Integer) 221 | minimum = db.Column(db.Integer) 222 | decay = db.Column(db.Integer) 223 | 224 | def __init__(self, name, description, value, category, type='dynamic', minimum=1, decay=50): 225 | self.name = name 226 | self.description = description 227 | self.value = value 228 | self.initial = value 229 | self.category = category 230 | self.type = type 231 | self.minimum = minimum 232 | self.decay = decay 233 | 234 | 235 | def load(app): 236 | app.db.create_all() 237 | CHALLENGE_CLASSES['dynamic'] = DynamicValueChallenge 238 | register_plugin_assets_directory(app, base_path='/plugins/DynamicValueChallenge/assets/') --------------------------------------------------------------------------------