Created
November 17, 2024 16:39
-
-
Save tijldeneut/0244ff27d19f8c2520fe69cd1dd77894 to your computer and use it in GitHub Desktop.
Downloading CTF Callenges
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
r''' | |
Copyright 2024 Photubias(c) | |
This program is free software: you can redistribute it and/or modify | |
it under the terms of the GNU General Public License as published by | |
the Free Software Foundation, either version 3 of the License, or | |
(at your option) any later version. | |
This program is distributed in the hope that it will be useful, | |
but WITHOUT ANY WARRANTY; without even the implied warranty of | |
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
GNU General Public License for more details. | |
You should have received a copy of the GNU General Public License | |
along with this program. If not, see <http://www.gnu.org/licenses/>. | |
Filename: ctfd-downloader-Photubias.py | |
This downloads challenges and files, full details will be in challs.json. | |
Supports unauthenticated, authenticated-creds and authenticated-Session token downloads. | |
The session cookie option is useful for MFA environments | |
-> The only requirement is the availability of the API: | |
https://ctf.example.com/api/v1/challenges | |
Just run python ctfd-downloader-Photubias.py https://ctf.example.com | |
and look at the output | |
''' | |
import requests, os, json, argparse, re | |
requests.packages.urllib3.disable_warnings() | |
def getAPI(sURL, sEndpoint, oSession, boolRedirect=True): | |
if sEndpoint[1] == '/': sEndpoint=sEndpoint[1:] | |
oResponse = oSession.get(f'{sURL}/api/v1/{sEndpoint}', allow_redirects=boolRedirect) | |
return oResponse | |
def errorStop(sMessage): | |
print(f'[-] Fatal error: {sMessage}') | |
exit(-1) | |
def sanitizeFileFolder(sOrg): | |
return sOrg.replace('<','').replace('>','').replace(':','').replace('"','').replace('/','').replace('\\','').replace('|','').replace('?','').replace('*','') | |
def downloadChalls(sURL, oSession, dctChallenges, sFolder): | |
if not os.path.isdir(f'{sFolder}'): os.mkdir(f'{sFolder}') | |
dctExported = {} | |
for dctChall in dctChallenges['data']: | |
print('[+] Downloading {} - {} [{} pts]'.format(dctChall['name'], dctChall['category'], dctChall['value'])) | |
if dctChall['category'] not in dctExported: dctExported[dctChall['category']] = {} | |
if not os.path.isdir('{}/{}'.format(sFolder, dctChall['category'])): os.mkdir('{}/{}'.format(sFolder, dctChall['category'])) | |
dctChallInfo = getAPI(sURL, 'challenges/{}'.format(dctChall['id']), oSession).json()['data'] | |
sChallDir = '{}/{}/{} [{} pts]'.format(sFolder, sanitizeFileFolder(dctChall['category']), sanitizeFileFolder(dctChall['name']), dctChall['value']) | |
os.mkdir(sChallDir) | |
## README: Title, Category, Points, Description and Hints | |
sContent = 'Challenge:\n{}\n\nDescription:\n{}\n\nHints:\n'.format(sChallDir, dctChallInfo['description']) | |
if '"https://drive.google.com' in dctChallInfo['view']: | |
sGoogleURL = 'https://drive.google.com' + re.findall(r'\"https://drive.google.com(.*?)\"',dctChallInfo['view'])[0] | |
print(f' [!] Found a Google Drive link, please download manually:\n {sGoogleURL}') | |
for sHint in dctChallInfo: | |
if 'content' in sHint: sContent += '{}\n'.format(sHint.content) | |
open(f'{sChallDir}/README.md', 'w', encoding='utf-8').write(sContent) | |
## Challenge Files | |
sFileDir = f'{sChallDir}/files' | |
if len(dctChallInfo['files']) > 0: os.mkdir(sFileDir) | |
for sFileURL in dctChallInfo['files']: | |
sFilename = sFileURL.split('?token')[0].split('/')[-1] | |
print(f' [+] Downloading file: {sFilename}') | |
if sFileURL[:4] != 'http': sFileURL = sURL + sFileURL | |
bFileData = oSession.get(sFileURL).content | |
open(f'{sFileDir}/{sFilename}','wb').write(bFileData) | |
dctExported[dctChall['category']][dctChall['name']] = dctChallInfo | |
print('Writing challs.json') | |
sChallsFile = open(f'{sFolder}/challs.json','w') | |
json.dump(dctExported, sChallsFile, indent=4, sort_keys=True) | |
sChallsFile.close() | |
return | |
def verifyArgsAndAccess(dctArgs, oSession): | |
def loginWithCreds(sURL, sUsername, sPassword, oSession): | |
oResp = oSession.get(f'{sURL}/login') ## Get CSRF token | |
lstTokens = re.findall(r'<input type="hidden" name="nonce" value="(.*?)">', oResp.text) | |
if len(lstTokens)==0: lstTokens = re.findall(r'\'csrfNonce\': "(.*?)"',oResp.text) | |
sToken = lstTokens[0] | |
oResp = oSession.post(f'{sURL}/login',{"name":sUsername,"password":sPassword,"nonce":sToken}, allow_redirects=False) | |
if 'Your username or password is incorrect' in oResp.text: errorStop('Credentials provided are not correct.') | |
return oSession | |
def loginWithCookie(sURL, sSession, oSession): | |
oResp = oSession.get(f'{sURL}/login') ## Get correct headers | |
sCookieDomain = oSession.cookies.list_domains()[0] | |
oSession.cookies.set('session', sSession, domain=sCookieDomain) | |
oResp = oSession.get(f'{sURL}/challenges', allow_redirects=False) | |
if oResp.status_code == 302: errorStop('Error with the "session" cookie, please retry') | |
return oSession | |
sURL = dctArgs.url | |
oResp = getAPI(sURL, 'challenges', oSession, boolRedirect=False) | |
if oResp.status_code == 302: | |
## Authentication required | |
if not dctArgs.username and not dctArgs.session: errorStop(f' Authentication required, please specify credentials or session token') | |
if dctArgs.username and not dctArgs.password: | |
sPassword = input('[?] Please enter the password for user {}'.format(dctArgs.username)) | |
oSession = loginWithCreds(sURL, dctArgs.username, sPassword, oSession) | |
else: oSession = loginWithCreds(sURL, dctArgs.username, dctArgs.password, oSession) | |
if dctArgs.session: loginWithCookie(sURL, dctArgs.session, oSession) | |
elif oResp.status_code >= 500: errorStop(f'URL {sURL}/api/v1/challenges not reachable') | |
oResp = getAPI(sURL, 'challenges', oSession) | |
if 'json' not in oResp.headers['Content-Type']: errorStop(f'URL {sURL}/api/v1/challenges is not JSON data') | |
if sURL[-1]=='/': sURL = sURL[:-1] | |
if sURL[:4].lower()!='http': sURL = f'https://{sURL}' | |
sFolder = sURL.replace('https://','').replace('http://','').replace('/','').replace('.','_') | |
return sURL, sFolder, oSession | |
def main(): | |
## Banner | |
print(r''' | |
[*****************************************************************************] | |
--- CTFd Downloader --- | |
This script will try to download all available challenges & files | |
from a CTFd platform, no warranties | |
Unauthenticated, via credentials or the session header (e.g. to bypass MFA) | |
_______________________/-> Created By Tijl Deneut(c) <-\_______________________ | |
[*****************************************************************************] | |
''') | |
## Defaults and parsing arguments | |
oParser = argparse.ArgumentParser() | |
oParser.add_argument('-u', '--username', help='Username for CTFd, optional') | |
oParser.add_argument('-p', '--password', help='Password for CTFd, optional, will be asked if username is provided') | |
oParser.add_argument('-s', '--session', help='Cookie value for "session", just specify the value. E.g. --session 22f3d655-0cd6-48e9-aaa2-b307ed6ef999.16vWw_3atI7ux6g9Vx8tjAAuyT0') | |
oParser.add_argument('-v', '--verbose', help='Verbosity; more info', action='store_true') | |
oParser.add_argument('--proxy', help='Default Burp proxy at 127.0.0.1:8080', action='store_true') | |
oParser.add_argument('url', help='Base URL for CTFd, e.g. https://ctf.sans.org. Hint: open challenges and remove "/challenges" from the URL') | |
dctArgs = oParser.parse_args() | |
dctProxy = {} if not dctArgs.proxy else {'http':'http://127.0.0.1:8080','https':'http://127.0.0.1:8080'} | |
## Set up variables | |
oSession = requests.Session() | |
oSession.verify = False | |
oSession.proxies = dctProxy | |
## Verification | |
sURL, sFolder, oSession = verifyArgsAndAccess(dctArgs, oSession) | |
## Get challenges | |
dctChallenges = getAPI(sURL, 'challenges', oSession).json() | |
if os.path.isdir(f'{sFolder}'): print(f'[!] Warning, folder {sFolder} found, will overwrite') | |
sAnswer = input('[+] Success, detected {} challenges, ready to create folder {} and start the download? [Y/n]: '.format(len(dctChallenges['data']), sFolder)) | |
if 'n' in sAnswer: exit() | |
downloadChalls(sURL, oSession, dctChallenges, sFolder) | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment