Add linear decay function to dynamic challenges (#2347)

* Add linear decay funciton to dynamic value challenges
* Add the ability to choose between decay functions for dynamic value challenges
* Closes #2224 
* Closes #865
This commit is contained in:
Kevin Chung
2023-06-29 03:44:33 -04:00
committed by GitHub
parent 79ae94285c
commit 70999b4fa0
6 changed files with 205 additions and 46 deletions

View File

@@ -1,14 +1,10 @@
from __future__ import division # Use floating point for math calculations
import math
from flask import Blueprint from flask import Blueprint
from CTFd.models import Challenges, Solves, db from CTFd.models import Challenges, db
from CTFd.plugins import register_plugin_assets_directory from CTFd.plugins import register_plugin_assets_directory
from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge
from CTFd.plugins.dynamic_challenges.decay import DECAY_FUNCTIONS, logarithmic
from CTFd.plugins.migrations import upgrade from CTFd.plugins.migrations import upgrade
from CTFd.utils.modes import get_model
class DynamicChallenge(Challenges): class DynamicChallenge(Challenges):
@@ -19,6 +15,7 @@ class DynamicChallenge(Challenges):
initial = db.Column(db.Integer, default=0) initial = db.Column(db.Integer, default=0)
minimum = db.Column(db.Integer, default=0) minimum = db.Column(db.Integer, default=0)
decay = db.Column(db.Integer, default=0) decay = db.Column(db.Integer, default=0)
function = db.Column(db.String(32), default="logarithmic")
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(DynamicChallenge, self).__init__(**kwargs) super(DynamicChallenge, self).__init__(**kwargs)
@@ -51,40 +48,8 @@ class DynamicValueChallenge(BaseChallenge):
@classmethod @classmethod
def calculate_value(cls, challenge): def calculate_value(cls, challenge):
Model = get_model() f = DECAY_FUNCTIONS.get(challenge.function, logarithmic)
value = f(challenge)
solve_count = (
Solves.query.join(Model, Solves.account_id == Model.id)
.filter(
Solves.challenge_id == challenge.id,
Model.hidden == False,
Model.banned == False,
)
.count()
)
# If the solve count is 0 we shouldn't manipulate the solve count to
# let the math update back to normal
if solve_count != 0:
# We subtract -1 to allow the first solver to get max point value
solve_count -= 1
# Handle situations where admins have entered a 0 decay
# This is invalid as it can cause a division by zero
if challenge.decay == 0:
challenge.decay = 1
# It is important that this calculation takes into account floats.
# Hence this file uses from __future__ import division
value = (
((challenge.minimum - challenge.initial) / (challenge.decay ** 2))
* (solve_count ** 2)
) + challenge.initial
value = math.ceil(value)
if value < challenge.minimum:
value = challenge.minimum
challenge.value = value challenge.value = value
db.session.commit() db.session.commit()

View File

@@ -16,16 +16,36 @@
</small> </small>
</label> </label>
<input type="number" class="form-control" name="initial" placeholder="Enter value" required> <input type="number" class="form-control" name="initial" placeholder="Enter value" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="value">Decay Limit<br> <label for="value">Decay Function<br>
<small class="form-text text-muted"> <small class="form-text text-muted">
The amount of solves before the challenge reaches its minimum value <span>How the dynamic value will be calculated based on the Decay value</span>
<ul>
<li>Linear: Calculated as <code>Initial - (Decay * SolveCount)</code></li>
<li>Logarithmic: Calculated as <code>(((Minimum - Initial) / (Decay^2)) * (SolveCount^2)) + Initial</code></li>
</ul>
</small> </small>
</label> </label>
<input type="number" class="form-control" name="decay" min="1" placeholder="Enter decay limit" required> <select name="function" class="custom-select">
<option value="linear">Linear</option>
<option value="logarithmic">Logarithmic</option>
</select>
</div>
<div class="form-group">
<label for="value">Decay<br>
<small class="form-text text-muted">
<span>The decay value is used differently depending on the above Decay Function</span>
<ul>
<li>Linear: The amount of points deducted per solve. Equal deduction per solve.</li>
<li>Logarithmic: The amount of solves before the challenge reaches its minimum value. Earlier solves will lose less
points. Later solves will lose more points</li>
</ul>
</small>
</label>
<input type="number" class="form-control" name="decay" min="1" placeholder="Enter Decay value" required>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -20,9 +20,29 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="value">Decay Limit<br> <label for="value">Decay Function<br>
<small class="form-text text-muted"> <small class="form-text text-muted">
The amount of solves before the challenge reaches its minimum value <span>How the dynamic value will be calculated based on the Decay value</span>
<ul>
<li>Linear: Calculated as <code>Initial - (Decay * SolveCount)</code></li>
<li>Logarithmic: Calculated as <code>(((Minimum - Initial) / (Decay^2)) * (SolveCount^2)) + Initial</code></li>
</ul>
</small>
</label>
<select name="function" class="custom-select">
<option value="linear" {% if challenge.function == "linear" %}selected{% endif %}>Linear</option>
<option value="logarithmic" {% if challenge.function == "logarithmic" %}selected{% endif %}>Logarithmic</option>
</select>
</div>
<div class="form-group">
<label for="value">Decay<br>
<small class="form-text text-muted">
<span>The decay value is used differently depending on the above Decay Function</span>
<ul>
<li>Linear: The amount of points deducted per solve. Equal deduction per solve.</li>
<li>Logarithmic: The amount of solves before the challenge reaches its minimum value. Earlier solves will lose less points. Later solves will lose more points</li>
</ul>
</small> </small>
</label> </label>
<input type="number" class="form-control chal-decay" min="1" name="decay" value="{{ challenge.decay }}" required> <input type="number" class="form-control chal-decay" min="1" name="decay" value="{{ challenge.decay }}" required>

View File

@@ -0,0 +1,75 @@
from __future__ import division # Use floating point for math calculations
import math
from CTFd.models import Solves
from CTFd.utils.modes import get_model
def get_solve_count(challenge):
Model = get_model()
solve_count = (
Solves.query.join(Model, Solves.account_id == Model.id)
.filter(
Solves.challenge_id == challenge.id,
Model.hidden == False,
Model.banned == False,
)
.count()
)
return solve_count
def linear(challenge):
solve_count = get_solve_count(challenge)
# If the solve count is 0 we shouldn't manipulate the solve count to
# let the math update back to normal
if solve_count != 0:
# We subtract -1 to allow the first solver to get max point value
solve_count -= 1
value = challenge.initial - (challenge.decay * solve_count)
value = math.ceil(value)
if value < challenge.minimum:
value = challenge.minimum
return value
def logarithmic(challenge):
solve_count = get_solve_count(challenge)
# If the solve count is 0 we shouldn't manipulate the solve count to
# let the math update back to normal
if solve_count != 0:
# We subtract -1 to allow the first solver to get max point value
solve_count -= 1
# Handle situations where admins have entered a 0 decay
# This is invalid as it can cause a division by zero
if challenge.decay == 0:
challenge.decay = 1
# It is important that this calculation takes into account floats.
# Hence this file uses from __future__ import division
value = (
((challenge.minimum - challenge.initial) / (challenge.decay ** 2))
* (solve_count ** 2)
) + challenge.initial
value = math.ceil(value)
if value < challenge.minimum:
value = challenge.minimum
return value
DECAY_FUNCTIONS = {
"linear": linear,
"logarithmic": logarithmic,
}

View File

@@ -0,0 +1,28 @@
"""Add func column to dynamic_challenges
Revision ID: eb68f277ab61
Revises: 9e6f6578ca84
Create Date: 2023-06-28 17:37:48.244827
"""
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "eb68f277ab61"
down_revision = "b37fb68807ea"
branch_labels = None
depends_on = None
def upgrade(op=None):
op.add_column(
"dynamic_challenge", sa.Column("function", sa.String(length=32), nullable=True)
)
conn = op.get_bind()
conn.execute(
"UPDATE dynamic_challenge SET function = 'logarithmic' WHERE function IS NULL"
)
def downgrade(op=None):
op.drop_column("dynamic_challenge", "function")

View File

@@ -349,3 +349,54 @@ def test_dynamic_challenges_reset():
assert DynamicChallenge.query.count() == 0 assert DynamicChallenge.query.count() == 0
destroy_ctfd(app) destroy_ctfd(app)
def test_dynamic_challenge_linear_loses_value_properly():
app = create_ctfd(enable_plugins=True)
with app.app_context():
register_user(app)
client = login_as_user(app, name="admin", password="password")
challenge_data = {
"name": "name",
"category": "category",
"description": "description",
"function": "linear",
"initial": 100,
"decay": 5,
"minimum": 1,
"state": "visible",
"type": "dynamic",
}
r = client.post("/api/v1/challenges", json=challenge_data)
assert r.get_json().get("data")["id"] == 1
gen_flag(app.db, challenge_id=1, content="flag")
for i, team_id in enumerate(range(2, 26)):
name = "user{}".format(team_id)
email = "user{}@examplectf.com".format(team_id)
# We need to bypass rate-limiting so gen_user instead of register_user
user = gen_user(app.db, name=name, email=email)
user_id = user.id
with app.test_client() as client:
# We need to bypass rate-limiting so creating a fake user instead of logging in
with client.session_transaction() as sess:
sess["id"] = user_id
sess["nonce"] = "fake-nonce"
sess["hash"] = hmac(user.password)
data = {"submission": "flag", "challenge_id": 1}
r = client.post("/api/v1/challenges/attempt", json=data)
resp = r.get_json()["data"]
assert resp["status"] == "correct"
chal = DynamicChallenge.query.filter_by(id=1).first()
if i >= 20:
assert chal.value == chal.minimum
else:
assert chal.value == (chal.initial - (i * 5))
destroy_ctfd(app)