Repository: CTFd/CTFd

Issue:
Hints can be unlocked after the end of a CTF for no cost
There is a case where if the CTF is frozen/temporarily ended a user can not only access the hints but also "buy" hints for free (regardless of the cost).

Steps to reproduce:
1. Have a user login and view the challenges page
2. Use an administrative account to end/freeze the CTF (in another window)
3. Have the user "buy" hints and log out of the CTFd
4. Have the admin resume the CTF
5. When the user logs back in, the hint will "relock" but points will not be reduced

My suspicion is hints are already being served by the web server but since the scoreboard is frozen the team points are not deducted.

I came up with a fix which seems to do the trick. By replacing **Line 21** in **challenges.py**, this should prevent hints from being leaked:

Original:
`if not utils.ctf_started():`

Suggested:
`if not utils.ctftime() or is_scoreboard_frozen():`

I have made a pull request with the fix.


Discussion:
In my testing, in a frozen CTF I unlocked a hint and was able to unlock that hint and lost points for it. 

In an ended CTF I unlocked a hint and was able to unlock that hint but did not lose points for it. 

Thus it would appear that the issue is that hints can be unlocked after the CTF **ends** for free. 

Your PR likely will not fix this issue because the affected code is actually [here](https://github.com/CTFd/CTFd/blob/master/CTFd/challenges.py#L55). This code should not allow hints to be seen after the end of a CTF unless "Viewing challenges after CTF end" is enabled. 

if you update your PR please include a test for this behavior if possible. 

Thanks again for making the issue and the PR!

Resolving patch:
diff --git a/CTFd/challenges.py b/CTFd/challenges.py
--- a/CTFd/challenges.py
+++ b/CTFd/challenges.py
@@ -36,31 +36,27 @@ def hints_view(hintid):
                 'cost': hint.cost
             })
     elif request.method == 'POST':
-        if not unlock and utils.ctftime():
-            team = Teams.query.filter_by(id=session['id']).first()
-            if team.score() < hint.cost:
-                return jsonify({'errors': 'Not enough points'})
-            unlock = Unlocks(model='hints', teamid=session['id'], itemid=hint.id)
-            award = Awards(teamid=session['id'], name=text_type('Hint for {}'.format(chal.name)), value=(-hint.cost))
-            db.session.add(unlock)
-            db.session.add(award)
-            db.session.commit()
-            json_data = {
-                'hint': hint.hint,
-                'chal': hint.chal,
-                'cost': hint.cost
-            }
-            db.session.close()
-            return jsonify(json_data)
-        elif utils.ctf_ended():
-            json_data = {
-                'hint': hint.hint,
-                'chal': hint.chal,
-                'cost': hint.cost
-            }
-            db.session.close()
-            return jsonify(json_data)
-        else:
+        if unlock is None:  # The user does not have an unlock.
+            if utils.ctftime() or (utils.ctf_ended() and utils.view_after_ctf()):
+                # It's ctftime or the CTF has ended (but we allow views after)
+                team = Teams.query.filter_by(id=session['id']).first()
+                if team.score() < hint.cost:
+                    return jsonify({'errors': 'Not enough points'})
+                unlock = Unlocks(model='hints', teamid=session['id'], itemid=hint.id)
+                award = Awards(teamid=session['id'], name=text_type('Hint for {}'.format(chal.name)), value=(-hint.cost))
+                db.session.add(unlock)
+                db.session.add(award)
+                db.session.commit()
+                json_data = {
+                    'hint': hint.hint,
+                    'chal': hint.chal,
+                    'cost': hint.cost
+                }
+                db.session.close()
+                return jsonify(json_data)
+            elif utils.ctf_ended():  # The CTF has ended. No views after.
+                abort(403)
+        else:  # The user does have an unlock, we should give them their hint.
             json_data = {
                 'hint': hint.hint,
                 'chal': hint.chal,


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -95,6 +95,7 @@ def gen_challenge(db, name='chal_name', description='chal_description', value=10
 
 def gen_award(db, teamid, name="award_name", value=100):
     award = Awards(teamid, name, value)
+    award.date = datetime.datetime.utcnow()
     db.session.add(award)
     db.session.commit()
     return award
diff --git a/tests/user/test_challenges.py b/tests/user/test_challenges.py
--- a/tests/user/test_challenges.py
+++ b/tests/user/test_challenges.py
@@ -271,7 +271,7 @@ def get_ip_fake(req=None):
 
 
 def test_unlocking_hints_with_no_cost():
-    '''Test that hints with no cost can be unlocked'''
+    """Test that hints with no cost can be unlocked"""
     app = create_ctfd()
     with app.app_context():
         register_user(app)
@@ -291,8 +291,110 @@ def test_unlocking_hints_with_no_cost():
     destroy_ctfd(app)
 
 
+def test_unlocking_hints_with_cost_during_ctf_with_points():
+    """Test that hints with a cost are unlocked if you have the points"""
+    app = create_ctfd()
+    with app.app_context():
+        register_user(app)
+        chal = gen_challenge(app.db)
+        chal_id = chal.id
+        hint = gen_hint(app.db, chal_id, cost=10)
+        gen_award(app.db, teamid=2)
+
+        client = login_as_user(app)
+        with client.session_transaction() as sess:
+            data = {
+                "nonce": sess.get('nonce')
+            }
+        r = client.post('/hints/1', data=data)
+        output = r.get_data(as_text=True)
+        output = json.loads(output)
+        assert output.get('hint') == 'This is a hint'
+        user = Teams.query.filter_by(id=2).first()
+        assert user.score() == 90
+    destroy_ctfd(app)
+
+
+def test_unlocking_hints_with_cost_during_ctf_without_points():
+    """Test that hints with a cost are not unlocked if you don't have the points"""
+    app = create_ctfd()
+    with app.app_context():
+        register_user(app)
+        chal = gen_challenge(app.db)
+        chal_id = chal.id
+        hint = gen_hint(app.db, chal_id, cost=10)
+
+        client = login_as_user(app)
+        with client.session_transaction() as sess:
+            data = {
+                "nonce": sess.get('nonce')
+            }
+        r = client.post('/hints/1', data=data)
+        output = r.get_data(as_text=True)
+        output = json.loads(output)
+        assert output.get('errors') == 'Not enough points'
+        user = Teams.query.filter_by(id=2).first()
+        assert user.score() == 0
+    destroy_ctfd(app)
+
+
+def test_unlocking_hints_with_cost_during_ended_ctf():
+    """Test that hints with a cost are not unlocked if the CTF has ended"""
+    app = create_ctfd()
+    with app.app_context():
+        register_user(app)
+        chal = gen_challenge(app.db)
+        chal_id = chal.id
+        hint = gen_hint(app.db, chal_id, cost=10)
+        gen_award(app.db, teamid=2)
+
+        set_config('start', '1507089600')  # Wednesday, October 4, 2017 12:00:00 AM GMT-04:00 DST
+        set_config('end', '1507262400')  # Friday, October 6, 2017 12:00:00 AM GMT-04:00 DST
+
+        with freeze_time("2017-11-4"):
+            client = login_as_user(app)
+            with client.session_transaction() as sess:
+                data = {
+                    "nonce": sess.get('nonce')
+                }
+            r = client.post('/hints/1', data=data)
+            assert r.status_code == 403
+            user = Teams.query.filter_by(id=2).first()
+            assert user.score() == 100
+            assert Unlocks.query.count() == 0
+    destroy_ctfd(app)
+
+
+def test_unlocking_hints_with_cost_during_frozen_ctf():
+    """Test that hints with a cost are unlocked if the CTF is frozen."""
+    app = create_ctfd()
+    with app.app_context():
+        set_config('freeze', '1507262400')  # Friday, October 6, 2017 12:00:00 AM GMT-04:00 DST
+        with freeze_time("2017-10-4"):
+            register_user(app)
+            chal = gen_challenge(app.db)
+            chal_id = chal.id
+            hint = gen_hint(app.db, chal_id, cost=10)
+            gen_award(app.db, teamid=2)
+
+        with freeze_time("2017-10-8"):
+            client = login_as_user(app)
+            with client.session_transaction() as sess:
+                data = {
+                    "nonce": sess.get('nonce')
+                }
+
+            r = client.post('/hints/1', data=data)
+            output = r.get_data(as_text=True)
+            output = json.loads(output)
+            assert output.get('hint') == 'This is a hint'
+            user = Teams.query.filter_by(id=2).first()
+            assert user.score() == 100
+    destroy_ctfd(app)
+
+
 def test_unlocking_hint_for_unicode_challenge():
-    '''Test that hints for challenges with unicode names can be unlocked'''
+    """Test that hints for challenges with unicode names can be unlocked"""
     app = create_ctfd()
     with app.app_context():
         register_user(app)