├── .nojekyll ├── frontend ├── js │ ├── services.js │ ├── story.js │ ├── settings.js │ ├── app.js │ ├── functions.js │ ├── team.js │ ├── rank.js │ ├── home.js │ └── challenges.js ├── locales │ ├── en.js │ └── pt.js └── css │ └── styles.css ├── nizkctf ├── __init__.py ├── cli │ ├── __init__.py │ ├── log.py │ ├── teamsecrets.py │ ├── team.py │ ├── challenges.py │ ├── localserver.py │ ├── news.py │ └── scoreboard.py ├── text.py ├── repohost │ ├── __init__.py │ ├── common.py │ ├── github.py │ └── gitlab.py ├── scoring.py ├── settings.py ├── six.py ├── localsettings.py ├── gen_iso3166 ├── news.py ├── proof.py ├── serializable.py ├── iso3166.py ├── acceptedsubmissions.py ├── challenge.py ├── subrepo.py ├── team.py └── proposal.py ├── pip-requirements.txt ├── challenges ├── index.json ├── test1.en.md ├── test2.en.md ├── test1.pt.md ├── g00d_b0y.en.md ├── g00d_b0y.pt.md ├── test2.pt.md ├── test1.json ├── test2.json └── test3.json ├── .gitignore ├── Makefile ├── readme-fixer.sh ├── settings.json ├── README.md ├── known_hosts ├── setup-vpn ├── example-README.pt.md ├── index.html ├── example-README.en.md ├── lambda_function.py └── ctf /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/js/services.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nizkctf/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nizkctf/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pip-requirements.txt: -------------------------------------------------------------------------------- 1 | requests[security] >= 2.13.0 2 | pysodium >= 0.7.4.1 3 | -------------------------------------------------------------------------------- /challenges/index.json: -------------------------------------------------------------------------------- 1 | [ 2 | "test1", 3 | "test2", 4 | "test3" 5 | ] 6 | -------------------------------------------------------------------------------- /challenges/test1.en.md: -------------------------------------------------------------------------------- 1 | Just a fake challenge for homologation purposes. Flag: CTF-BR{oi} 2 | -------------------------------------------------------------------------------- /challenges/test2.en.md: -------------------------------------------------------------------------------- 1 | Just another fake challenge for homologation purposes. Flag: CTF-BR{hi} 2 | -------------------------------------------------------------------------------- /challenges/test1.pt.md: -------------------------------------------------------------------------------- 1 | Apenas um challenge genérico para propósitos de homologação. Flag: CTF-BR{oi} 2 | -------------------------------------------------------------------------------- /challenges/g00d_b0y.en.md: -------------------------------------------------------------------------------- 1 | Now prove you were a good kid and show you learned the most basic lesson in CTFs!! 2 | -------------------------------------------------------------------------------- /challenges/g00d_b0y.pt.md: -------------------------------------------------------------------------------- 1 | Dessa vez, prove que você foi um bom garoto e aprendeu a lição básica dos CTFs!! 2 | -------------------------------------------------------------------------------- /challenges/test2.pt.md: -------------------------------------------------------------------------------- 1 | Just another fake challenge for homologation purposes. Flag: CTF-BR{hi} 2 | Apenas mais um challenge genérico para homologação. Flag: CTF-BR{hi} 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # our own stuff 2 | /submissions 3 | /local-settings.json 4 | /team-secrets.json 5 | 6 | # elm-make 7 | /elm-stuff 8 | 9 | # python 10 | *.pyc 11 | *.pyo 12 | 13 | # wingide 14 | *.wpr 15 | *.wpu 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: 2 | @echo nothing yet 3 | 4 | nizkctf/iso3166.py: nizkctf/gen_iso3166 settings.json 5 | PYTHONPATH="." $< >$@ 6 | 7 | ../lambda.zip: FORCE 8 | rm -f "$@" 9 | zip -ry "$@" * -x 'submissions/*' 10 | 11 | FORCE: 12 | -------------------------------------------------------------------------------- /challenges/test1.json: -------------------------------------------------------------------------------- 1 | {"description": "Just a fake challenge for homologation purposes. Flag: CTF-BR{oi}", "title": "Test 01", "tags": ["Test"], "points": 20, "pk": "Z8rrCSjJnGRalHKc41aSUj9T2Opt88ckxnvJDWWbZZY=", "salt": "EjJvA9Ee/EPxjE1IGSM8lElae6YmCw5Fa/V4v+LY9PM=", "id": "test1"} 2 | -------------------------------------------------------------------------------- /challenges/test2.json: -------------------------------------------------------------------------------- 1 | {"description": "Just another fake challenge for homologation purposes. Flag: CTF-BR{hi}", "title": "Test 02", "tags": ["Test"], "points": 25, "pk": "E7dq1U5GT39Y1kFfcdAq3qncNjKto++867xIpn/UTqc=", "salt": "ykmTVU5lEX2DLDWOv3YTOkyWCkXiqnSXFbPQwDDkX/w=", "id": "test2"} 2 | -------------------------------------------------------------------------------- /nizkctf/text.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import unicodedata 6 | 7 | 8 | def width(s): 9 | asian = sum(unicodedata.east_asian_width(c) in {'W', 'F'} for c in s) 10 | return len(s) + asian 11 | -------------------------------------------------------------------------------- /nizkctf/repohost/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | from .github import GitHub 6 | from .gitlab import GitLab 7 | 8 | from ..settings import Settings 9 | RepoHost = globals()[Settings.repository_host] 10 | -------------------------------------------------------------------------------- /challenges/test3.json: -------------------------------------------------------------------------------- 1 | {"id": "test3", "title": "Test 03 (dynamic score)", "description": "This is a fake challenge to test static and dynamic scores on the same repo. Flag: CTF-BR{10pts_to_gryffindor}", "tags": ["Test", "Dynamic_points"], "salt": "au3TpnX+QgHx0Jop+ppKBmLqGQ+5UTh/FEktVTSuhOo=", "pk": "BhI0OzpzZWrw/QZ9AACtfLThNDrqpzo8gThPENHrfR0="} 2 | -------------------------------------------------------------------------------- /nizkctf/cli/log.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | 6 | 7 | def info(s): 8 | print('\033[93m[*]\033[00m %s' % s) 9 | 10 | 11 | def success(s): 12 | print('\033[92m[+]\033[00m %s' % s) 13 | 14 | 15 | def fail(s): 16 | print('\033[91m[!]\033[00m %s' % s) 17 | -------------------------------------------------------------------------------- /nizkctf/scoring.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from .settings import Settings 4 | from math import floor, log 5 | 6 | 7 | def compute_points(chall, num_solves): 8 | num_solves = max(1, num_solves) 9 | 10 | params = Settings.dynamic_scoring 11 | if not params or 'points' in chall.keys(): 12 | return chall['points'] 13 | 14 | # Google CTF 2017's formula 15 | K, V, minpts, maxpts = params['K'], params['V'], \ 16 | params['minpts'], params['maxpts'] 17 | return int(max(minpts, floor(maxpts - K*log((num_solves + V)/(1 + V), 2)))) 18 | -------------------------------------------------------------------------------- /readme-fixer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Organization name (As in github. Ex. pwn2winctf): " 4 | read org 5 | echo "CTF name (ex. Pwn2Win 2019): " 6 | read name 7 | echo "CTF id/repo name (ex. 2019): " 8 | read ctf 9 | echo "Flag format example (ex. CTF-BR{fl4g}): " 10 | read flag 11 | 12 | sed -i 1,2d example-README.*.md 13 | sed -i 's/pwn2winctf/'$org'/Ig' example-README.*.md 14 | sed -i 's/NIZKCTF\ example\ CTF/'"$name"'/Ig' example-README.*.md 15 | sed -i 's/nizkctf/'$ctf'/Ig' example-README.*.md 16 | sed -i 's/CTF-BR{flag123}/'"$flag"'/Ig' example-README.*.md 17 | 18 | mv {example-,}README.en.md 19 | mv {example-,}README.pt.md 20 | 21 | echo "Done!" 22 | -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ctf_name": "NIZKCTF upstream", 3 | "repository_host": "GitHub", 4 | "submissions_project": "pwn2winctf/test_submissions", 5 | 6 | "scrypt_ops_limit": 33554432, 7 | "scrypt_mem_limit": 402653184, 8 | 9 | "dynamic_scoring": {"K": 80.0, "V": 3.0, "minpts": 50, "maxpts": 500}, 10 | 11 | "max_size_team_name": 50, 12 | "max_size_chall_id": 30, 13 | "max_size_team_countries": 5, 14 | "flag_icon_css_ver": "2.8.0", 15 | 16 | "github_api_endpoint": "https://api.github.com/", 17 | "github_ssh_url": "git@github.com:%s.git", 18 | 19 | "gitlab_api_endpoint": "https://gitlab.com/api/v4/", 20 | "gitlab_ssh_url": "git@gitlab.com:%s.git" 21 | } 22 | -------------------------------------------------------------------------------- /nizkctf/settings.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import json 7 | from .six import viewitems 8 | 9 | 10 | class Settings(object): 11 | pass 12 | 13 | 14 | def load(): 15 | """ Load settings from json file """ 16 | thisdir = os.path.dirname(os.path.realpath(__file__)) 17 | with open(os.path.join(thisdir, os.pardir, 'settings.json')) as f: 18 | settings = json.load(f) 19 | assert isinstance(settings, dict) 20 | for k, v in viewitems(settings): 21 | setattr(Settings, k, v) 22 | 23 | 24 | load() 25 | -------------------------------------------------------------------------------- /frontend/locales/en.js: -------------------------------------------------------------------------------- 1 | const enLocale = { 2 | 'home': 'Home', 3 | 'challenges': 'Challenges', 4 | 'rank': 'Rank', 5 | 'rules': 'Rules', 6 | 'settings': 'Settings', 7 | 'news': 'News', 8 | 'messages': 'Messages', 9 | 'solves': 'Solves', 10 | 'private-message': 'Your team has a new private msg, use console to read', 11 | 'solved': 'solved', 12 | 'total-solves': 'Total solves', 13 | 'score': 'Score', 14 | 'categories': 'Categories', 15 | 'isolated': 'Isolated', 16 | 'platform': 'Platform', 17 | 'position': 'Position', 18 | 'members': 'Members', 19 | 'solved-challenges': 'Solved Challenges', 20 | 'team': 'Team', 21 | 'language': 'Language' 22 | } 23 | -------------------------------------------------------------------------------- /frontend/locales/pt.js: -------------------------------------------------------------------------------- 1 | const ptLocale = { 2 | 'home': 'Home', 3 | 'challenges': 'Desafios', 4 | 'rank': 'Rank', 5 | 'rules': 'Regras', 6 | 'settings': 'Configurações', 7 | 'news': 'Notícias', 8 | 'messages': 'Mensagens', 9 | 'solves': 'Resolvidos', 10 | 'private-message': 'Seu time tem uma nova mensagem, use o console para lê-la.', 11 | 'solved': 'resolveu', 12 | 'total-solves': 'Total de soluções', 13 | 'score': 'Pontos', 14 | 'categories': 'Categorias', 15 | 'isolated': 'Isolado', 16 | 'platform': 'Plataforma', 17 | 'position': 'Posição', 18 | 'members': 'Membros', 19 | 'solved-challenges': 'Desafios Resolvidos', 20 | 'team': 'Time', 21 | 'language': 'Língua' 22 | } -------------------------------------------------------------------------------- /nizkctf/six.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # a subset of the six module 3 | 4 | from __future__ import unicode_literals, division, print_function,\ 5 | absolute_import 6 | import operator 7 | import sys 8 | 9 | 10 | PY2 = sys.version_info[0] == 2 11 | 12 | text_type = type('') 13 | 14 | 15 | if PY2: 16 | viewitems = operator.methodcaller("viewitems") 17 | input = raw_input 18 | else: 19 | viewitems = operator.methodcaller("items") 20 | input = input 21 | 22 | 23 | def to_bytes(s): 24 | if isinstance(s, text_type): 25 | return bytes(s.encode('utf-8')) 26 | return s 27 | 28 | 29 | def to_unicode(s): 30 | if isinstance(s, text_type): 31 | return s 32 | encoding = sys.getfilesystemencoding() 33 | s = s.decode(encoding) 34 | return s 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NIZKCTF upstream repository 2 | 3 | This is the upstream repository for the Non-Interactive Zero-Knowledge CTF platform, a distributed and openly auditable CTF platform that uses zero-knowledge cryptographic proofs to submit and verify the flags for Capture The Flag competitions. It works from command line, but we also have a [javascript version](https://github.com/pwn2winctf/NIZKCTF-js) to have an easier and more friendly interface. 4 | 5 | The paper describing the platform can be found [here](https://arxiv.org/pdf/1708.05844.pdf). 6 | 7 | If you want to host your CTF using the platform, fork this repository and follow the instructions on the [NIZKCTF-tutorial repository](https://github.com/pwn2winctf/nizkctf-tutorial). We have also developed a [dynamic provisioner](https://github.com/pwn2winctf/NIZKCTF-provisioning) for isolated challenges if you need it. Contact us if you need any help. 8 | 9 | -------------------------------------------------------------------------------- /nizkctf/localsettings.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import json 7 | import threading 8 | 9 | 10 | LOCALSETTINGS_FILE = 'local-settings.json' 11 | 12 | 13 | class DefaultLocalSettings(object): 14 | __lock__ = threading.Lock() 15 | 16 | def path(self): 17 | thisdir = os.path.dirname(os.path.realpath(__file__)) 18 | return os.path.join(thisdir, os.pardir, LOCALSETTINGS_FILE) 19 | 20 | def __init__(self): 21 | if os.path.exists(self.path()): 22 | with open(self.path()) as f: 23 | self.__dict__.update(json.load(f)) 24 | 25 | def __setattr__(self, k, v): 26 | self.__dict__[k] = v 27 | with self.__lock__: 28 | with open(self.path(), 'w') as f: 29 | json.dump(self.__dict__, f) 30 | 31 | 32 | LocalSettings = DefaultLocalSettings() 33 | -------------------------------------------------------------------------------- /known_hosts: -------------------------------------------------------------------------------- 1 | github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== 2 | gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY= 3 | gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9 4 | gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf 5 | -------------------------------------------------------------------------------- /frontend/js/story.js: -------------------------------------------------------------------------------- 1 | const ChallengeComponent = Vue.component('challenge-card', { 2 | template: ` 3 |
4 |
5 |
6 | {{challenge.title}} 7 |

{{challenge.description.substr(0,100)}}...

8 |

9 | {{tag}} 10 |

11 |
12 |
13 | More 14 |
15 |
16 |
17 | `, 18 | props: ['challenge'] 19 | }); 20 | 21 | const Challenges = { 22 | template: ` 23 |
24 | 25 |
26 | `, 27 | data: () => ({ 28 | challenges 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /frontend/js/settings.js: -------------------------------------------------------------------------------- 1 | const Settings = Vue.component('settings', { 2 | template: ` 3 |
4 | 5 |

{{$t('team')}}:

6 |

7 | {{$t('language')}}: 8 | 12 |

13 | Save 14 |
15 | `, 16 | data: () => ({ 17 | team: Cookies.get('team'), 18 | language: Cookies.get('lang') || 'En' 19 | }), 20 | methods: { 21 | save() { 22 | const lang = $('select').val(); 23 | Cookies.set('team', this.team); 24 | Cookies.set('lang', lang); 25 | app.$i18n.locale = lang; 26 | } 27 | }, 28 | mounted() { 29 | $(document).ready(function() { 30 | $('select').material_select(); 31 | }); 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /nizkctf/cli/teamsecrets.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import shutil 7 | import time 8 | from . import log 9 | from ..serializable import SerializableDict 10 | 11 | 12 | TEAMSECRETS_FILE = 'team-secrets.json' 13 | 14 | 15 | class DefaultTeamSecrets(SerializableDict): 16 | def path(self): 17 | thisdir = os.path.dirname(os.path.realpath(__file__)) 18 | return os.path.join(thisdir, os.pardir, os.pardir, TEAMSECRETS_FILE) 19 | 20 | @staticmethod 21 | def _binary_field(k): 22 | return k.endswith('_sk') 23 | 24 | 25 | TeamSecrets = DefaultTeamSecrets() 26 | 27 | 28 | def write_team_secrets(team_id, crypt_sk, sign_sk): 29 | if TeamSecrets.exists(): 30 | log.info('overriding %s' % TEAMSECRETS_FILE) 31 | 32 | backup = TeamSecrets.path() + time.strftime('.%Y-%m-%d-%H-%M-%S') 33 | shutil.copy(TeamSecrets.path(), backup) 34 | 35 | TeamSecrets['id'] = team_id 36 | TeamSecrets['crypt_sk'] = crypt_sk 37 | TeamSecrets['sign_sk'] = sign_sk 38 | TeamSecrets.save() 39 | 40 | log.success('ready, share %s with your team!' % TEAMSECRETS_FILE) 41 | -------------------------------------------------------------------------------- /frontend/css/styles.css: -------------------------------------------------------------------------------- 1 | .news { 2 | height: 216px; 3 | word-wrap: break-word; 4 | overflow: auto; 5 | } 6 | 7 | .news-priv { 8 | color: red; 9 | font-weight: bold; 10 | } 11 | 12 | .card-content { 13 | height: 145px; 14 | } 15 | 16 | .card .card-content { 17 | padding: 10px; 18 | } 19 | 20 | .card .card-action { 21 | padding: 10px 10px 1px 10px; 22 | } 23 | 24 | .card .row { 25 | margin: 0 0 10px 0; 26 | } 27 | 28 | .clickable { 29 | cursor: pointer; 30 | } 31 | 32 | .btn-small { 33 | width: 30px; 34 | height: 30px; 35 | } 36 | 37 | .icon-header { 38 | font-size: 20px !important; 39 | line-height: 30px !important; 40 | } 41 | 42 | .input-header input { 43 | font-weight: bold; 44 | font-size: 15px !important; 45 | padding-left: 0px !important; 46 | width: 150px; 47 | } 48 | 49 | .team-selected { 50 | background-color: #ccc !important; 51 | } 52 | 53 | .blue-grey.darken-1.is-solved { 54 | background-color: green !important; 55 | } 56 | 57 | .country-flag { 58 | width: 25px; 59 | padding: 2px; 60 | } 61 | 62 | .categories { 63 | text-align: center; 64 | } 65 | 66 | .categories span.badge { 67 | float: initial; 68 | padding: 3px 6px; 69 | cursor: pointer; 70 | } -------------------------------------------------------------------------------- /nizkctf/gen_iso3166: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | from __future__ import unicode_literals, division, print_function,\ 5 | absolute_import 6 | 7 | from nizkctf.settings import Settings 8 | import os 9 | import json 10 | import string 11 | import requests 12 | 13 | 14 | def iter_country_codes(): 15 | r = requests.get('https://api.github.com/repos/lipis/flag-icon-css' 16 | '/contents/flags/1x1?ref=%s' % Settings.flag_icon_css_ver) 17 | r.raise_for_status() 18 | for item in r.json(): 19 | if item['type'] == 'file': 20 | basename, ext = os.path.splitext(item['name']) 21 | if ext == '.svg': 22 | yield basename 23 | 24 | 25 | def gen_code(): 26 | allowed_chars = set(string.ascii_lowercase).union(set('-')) 27 | 28 | py = 'valid_countries = set([' 29 | i = 0 30 | 31 | for country_code in iter_country_codes(): 32 | if i % 8 == 0: 33 | py += '\n ' 34 | 35 | assert set(country_code).issubset(allowed_chars) 36 | py += " '%s'," % country_code 37 | 38 | i += 1 39 | 40 | py += '\n])' 41 | return py 42 | 43 | 44 | def main(): 45 | print(gen_code()) 46 | 47 | 48 | if __name__ == '__main__': 49 | main() 50 | -------------------------------------------------------------------------------- /nizkctf/news.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import time 7 | import pysodium 8 | from base64 import b64encode 9 | from .team import Team 10 | from .six import text_type 11 | from .subrepo import SubRepo 12 | from .serializable import SerializableList 13 | 14 | 15 | NEWS_FILE = 'news.json' 16 | 17 | 18 | class News(SerializableList): 19 | pretty_print = True 20 | 21 | def __init__(self): 22 | super(News, self).__init__() 23 | 24 | def path(self): 25 | return SubRepo.get_path(NEWS_FILE) 26 | 27 | def add(self, msg_text, to=None): 28 | current_time = int(time.time()) 29 | if to is None: 30 | message = {"msg": msg_text, "time": current_time} 31 | else: 32 | team_pk = Team(name=to)['crypt_pk'] 33 | encrypted_msg = pysodium.crypto_box_seal(msg_text.encode("utf-8"), 34 | team_pk) 35 | encoded_msg = b64encode(encrypted_msg) 36 | 37 | message = {"msg": encoded_msg.decode("utf-8"), 38 | "to": to, 39 | "time": current_time} 40 | 41 | self.append(message) 42 | self.save() 43 | -------------------------------------------------------------------------------- /frontend/js/app.js: -------------------------------------------------------------------------------- 1 | Vue.use(VueI18n); 2 | if (!Cookies.get('lang')) { 3 | Cookies.set('lang', 'En'); 4 | } 5 | const routes = [ 6 | { 7 | path: '/', 8 | component: Home 9 | }, 10 | { 11 | path: '/challenges', 12 | component: Challenges 13 | }, 14 | { 15 | path: '/rank', 16 | component: Rank 17 | }, 18 | { 19 | path: '/team/:name', 20 | component: Team 21 | }, 22 | { 23 | path: '/settings', 24 | component: Settings 25 | } 26 | ]; 27 | 28 | const router = new VueRouter({ 29 | routes 30 | }); 31 | 32 | const Title = Vue.component('app-title', { 33 | template: ` 34 |
35 |
36 |

{{$t(title)}}

37 |
38 |
39 | `, 40 | props: ['title'] 41 | }) 42 | 43 | const app = new Vue({ 44 | router, 45 | data: () => ({ 46 | loaded: false 47 | }), 48 | async mounted() { 49 | settings = await getSettings(); 50 | this.loaded = true; 51 | }, 52 | i18n: new VueI18n({ 53 | locale: Cookies.get('lang'), 54 | fallbackLocale: 'En', 55 | messages: { 56 | En: enLocale, 57 | Pt: ptLocale 58 | } 59 | }) 60 | }).$mount('#app'); 61 | -------------------------------------------------------------------------------- /nizkctf/proof.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import pysodium 7 | from base64 import b64encode, b64decode 8 | from .six import text_type 9 | from .challenge import Challenge 10 | from .cli.teamsecrets import TeamSecrets 11 | 12 | 13 | def proof_open(team, proof): 14 | assert isinstance(proof, bytes) 15 | proof = b64decode(proof) 16 | 17 | claimed_chall_id = proof[2*64:].decode('utf-8') 18 | claimed_chall = Challenge(claimed_chall_id) 19 | 20 | chall_pk = claimed_chall['pk'] 21 | team_pk = team['sign_pk'] 22 | 23 | membership_proof = pysodium.crypto_sign_open(proof, chall_pk) 24 | chall_id = pysodium.crypto_sign_open(membership_proof, 25 | team_pk).decode('utf-8') 26 | 27 | if claimed_chall_id != chall_id: 28 | raise ValueError('invalid proof') 29 | 30 | return claimed_chall 31 | 32 | 33 | def proof_create(chall_id, chall_sk): 34 | chall_id = chall_id.encode('utf-8') 35 | 36 | assert isinstance(chall_id, bytes) 37 | assert isinstance(chall_sk, bytes) 38 | 39 | team_sk = TeamSecrets['sign_sk'] 40 | 41 | membership_proof = pysodium.crypto_sign(chall_id, team_sk) 42 | proof = pysodium.crypto_sign(membership_proof, chall_sk) 43 | 44 | return b64encode(proof) 45 | -------------------------------------------------------------------------------- /nizkctf/cli/team.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import hashlib 6 | import os 7 | import json 8 | import codecs 9 | import pysodium 10 | import base64 11 | from . import log 12 | from .teamsecrets import write_team_secrets 13 | from ..team import Team 14 | from ..subrepo import SubRepo 15 | from ..six import to_unicode 16 | 17 | 18 | def register(team_name, countries): 19 | team_name = to_unicode(team_name) 20 | 21 | log.info('updating subrepo') 22 | SubRepo.pull() 23 | 24 | log.info('registering new team: %s' % team_name) 25 | team = Team(name=team_name) 26 | 27 | if team.exists(): 28 | log.fail('team is already registered') 29 | return False 30 | 31 | log.info('generating encryption keypair') 32 | crypt_pk, crypt_sk = pysodium.crypto_box_keypair() 33 | 34 | log.info('generating signature keypair') 35 | sign_pk, sign_sk = pysodium.crypto_sign_keypair() 36 | 37 | team.update({'countries': countries, 38 | 'crypt_pk': crypt_pk, 39 | 'sign_pk': sign_pk}) 40 | team.validate() 41 | team.save() 42 | 43 | SubRepo.push(commit_message='Register team %s' % team_name) 44 | log.success('team %s added successfully' % team_name) 45 | 46 | write_team_secrets(team.id, crypt_sk, sign_sk) 47 | 48 | return True 49 | -------------------------------------------------------------------------------- /nizkctf/repohost/common.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import requests 7 | import urllib 8 | import codecs 9 | from ..localsettings import LocalSettings 10 | 11 | 12 | class BaseRepoHost(object): 13 | @classmethod 14 | def login(cls, username=None, password=None, token=None, OTP=None): 15 | if not token and (username and password): 16 | token = cls.get_token(username, password, OTP) 17 | if not token: 18 | raise ValueError("Pass either a token or an username/password") 19 | LocalSettings.token = token 20 | 21 | @classmethod 22 | def instance(cls): 23 | token = os.getenv('REPOHOST_TOKEN') or \ 24 | LocalSettings.token 25 | return cls(token) 26 | 27 | def __init__(self, token): 28 | self.token = token 29 | self.s = requests.Session() 30 | self._init_session() 31 | 32 | def _init_session(self): 33 | pass 34 | 35 | @classmethod 36 | def get_token(cls, username, password, OTP): 37 | pass 38 | 39 | @staticmethod 40 | def _raise_for_status(r): 41 | try: 42 | r.raise_for_status() 43 | except Exception as e: 44 | raise APIError(r.text, e) 45 | 46 | 47 | class APIError(Exception): 48 | pass 49 | 50 | 51 | class WebhookAuthError(Exception): 52 | pass 53 | 54 | 55 | quote_plus = urllib.quote_plus if hasattr(urllib, 'quote_plus') else \ 56 | urllib.parse.quote_plus 57 | -------------------------------------------------------------------------------- /nizkctf/serializable.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import json 7 | from base64 import b64encode, b64decode 8 | from .six import viewitems, text_type 9 | 10 | 11 | class Serializable(object): 12 | pretty_print = False 13 | 14 | def __init__(self): 15 | self.load() 16 | 17 | def load(self): 18 | if self.exists(): 19 | self.clear() 20 | with open(self.path()) as f: 21 | self.update(json.load(f)) 22 | self._unserialize_inplace() 23 | 24 | def save(self): 25 | with open(self.path(), 'w') as f: 26 | kw = {'sort_keys': True, 'indent': 1} if self.pretty_print else {} 27 | json.dump(self._serialize(), f, **kw) 28 | 29 | def exists(self): 30 | return os.path.exists(self.path()) 31 | 32 | def _unserialize_inplace(self): 33 | pass 34 | 35 | def _serialize(self): 36 | return self 37 | 38 | 39 | class SerializableDict(Serializable, dict): 40 | @staticmethod 41 | def _binary_field(k): 42 | return False 43 | 44 | def _unserialize_inplace(self): 45 | for k, v in viewitems(self): 46 | if self._binary_field(k): 47 | assert isinstance(v, text_type) 48 | self[k] = b64decode(v) 49 | 50 | def _serialize(self): 51 | return {k: b64encode(v).decode('utf-8') if self._binary_field(k) else v 52 | for k, v in viewitems(self)} 53 | 54 | 55 | class SerializableList(Serializable, list): 56 | def clear(self): 57 | del self[:] 58 | 59 | def update(self, l): 60 | self += l 61 | -------------------------------------------------------------------------------- /nizkctf/iso3166.py: -------------------------------------------------------------------------------- 1 | valid_countries = set([ 2 | 'ad', 'ae', 'af', 'ag', 'ai', 'al', 'am', 'ao', 3 | 'aq', 'ar', 'as', 'at', 'au', 'aw', 'ax', 'az', 4 | 'ba', 'bb', 'bd', 'be', 'bf', 'bg', 'bh', 'bi', 5 | 'bj', 'bl', 'bm', 'bn', 'bo', 'bq', 'br', 'bs', 6 | 'bt', 'bv', 'bw', 'by', 'bz', 'ca', 'cc', 'cd', 7 | 'cf', 'cg', 'ch', 'ci', 'ck', 'cl', 'cm', 'cn', 8 | 'co', 'cr', 'cu', 'cv', 'cw', 'cx', 'cy', 'cz', 9 | 'de', 'dj', 'dk', 'dm', 'do', 'dz', 'ec', 'ee', 10 | 'eg', 'eh', 'er', 'es', 'et', 'eu', 'fi', 'fj', 11 | 'fk', 'fm', 'fo', 'fr', 'ga', 'gb-eng', 'gb-nir', 'gb-sct', 12 | 'gb-wls', 'gb', 'gd', 'ge', 'gf', 'gg', 'gh', 'gi', 13 | 'gl', 'gm', 'gn', 'gp', 'gq', 'gr', 'gs', 'gt', 14 | 'gu', 'gw', 'gy', 'hk', 'hm', 'hn', 'hr', 'ht', 15 | 'hu', 'id', 'ie', 'il', 'im', 'in', 'io', 'iq', 16 | 'ir', 'is', 'it', 'je', 'jm', 'jo', 'jp', 'ke', 17 | 'kg', 'kh', 'ki', 'km', 'kn', 'kp', 'kr', 'kw', 18 | 'ky', 'kz', 'la', 'lb', 'lc', 'li', 'lk', 'lr', 19 | 'ls', 'lt', 'lu', 'lv', 'ly', 'ma', 'mc', 'md', 20 | 'me', 'mf', 'mg', 'mh', 'mk', 'ml', 'mm', 'mn', 21 | 'mo', 'mp', 'mq', 'mr', 'ms', 'mt', 'mu', 'mv', 22 | 'mw', 'mx', 'my', 'mz', 'na', 'nc', 'ne', 'nf', 23 | 'ng', 'ni', 'nl', 'no', 'np', 'nr', 'nu', 'nz', 24 | 'om', 'pa', 'pe', 'pf', 'pg', 'ph', 'pk', 'pl', 25 | 'pm', 'pn', 'pr', 'ps', 'pt', 'pw', 'py', 'qa', 26 | 're', 'ro', 'rs', 'ru', 'rw', 'sa', 'sb', 'sc', 27 | 'sd', 'se', 'sg', 'sh', 'si', 'sj', 'sk', 'sl', 28 | 'sm', 'sn', 'so', 'sr', 'ss', 'st', 'sv', 'sx', 29 | 'sy', 'sz', 'tc', 'td', 'tf', 'tg', 'th', 'tj', 30 | 'tk', 'tl', 'tm', 'tn', 'to', 'tr', 'tt', 'tv', 31 | 'tw', 'tz', 'ua', 'ug', 'um', 'un', 'us', 'uy', 32 | 'uz', 'va', 'vc', 've', 'vg', 'vi', 'vn', 'vu', 33 | 'wf', 'ws', 'ye', 'yt', 'za', 'zm', 'zw', 34 | ]) 35 | -------------------------------------------------------------------------------- /frontend/js/functions.js: -------------------------------------------------------------------------------- 1 | const createPooling = (promise, cb, intervalTime) => { 2 | let interval; 3 | return { 4 | isStarted: false, 5 | async start() { 6 | if (this.isStarted) { 7 | return; 8 | } 9 | 10 | this.isStarted = true; 11 | 12 | cb(await promise()); 13 | interval = setInterval(async () => { 14 | cb(await promise()); 15 | }, intervalTime || 60000); 16 | }, 17 | stop () { 18 | this.isStarted = false 19 | clearInterval(interval); 20 | } 21 | } 22 | }; 23 | 24 | const converter = new showdown.Converter(); 25 | const getSubmisionsPath = () => settings.submissions_project.split('/')[1]; 26 | const getTeamPath = teamName => sha256(teamName).splice(1, 0, '/').splice(5, 0, '/'); 27 | const mountUrl = (path, time = (1000 * 60 * 10)) => `${path}?_${Math.floor(+(new Date)/time)}` 28 | 29 | const getSettings = () => $.getJSON('settings.json'); 30 | const getNews = () => $.getJSON(mountUrl(`/${getSubmisionsPath()}/news.json`)); 31 | const getChallenges = () => $.getJSON(mountUrl('challenges/index.json')); 32 | const getChallenge = id => $.getJSON(mountUrl(`challenges/${id}.json`)); 33 | const getChallengeDescription = (id, lang) => $.get(mountUrl(`challenges/${id}.${lang.toLowerCase()}.md`)); 34 | const getSolvedChallenges = () => $.getJSON(mountUrl(`/${getSubmisionsPath()}/accepted-submissions.json`, 1000 * 60)); 35 | const getTeam = teamName => $.getJSON(mountUrl(`/${getSubmisionsPath()}/${getTeamPath(teamName)}/team.json`)); 36 | const getTeamMembers = teamName => $.getJSON(mountUrl(`/${getSubmisionsPath()}/${getTeamPath(teamName)}/members.json`)); 37 | const getLocaleMessages = lang => $.getJSON(mountUrl(`frontend/locales/${lang}.json`)); 38 | 39 | String.prototype.splice = function(idx, rem, str) { 40 | return this.slice(0, idx) + str + this.slice(idx + Math.abs(rem)); 41 | }; 42 | -------------------------------------------------------------------------------- /nizkctf/cli/challenges.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import codecs 7 | import json 8 | import pysodium 9 | import base64 10 | import textwrap 11 | 12 | from . import log 13 | from .teamsecrets import TeamSecrets 14 | from ..team import my_team 15 | from ..proof import proof_create 16 | from ..challenge import Challenge, lookup_flag 17 | from ..subrepo import SubRepo 18 | from ..acceptedsubmissions import AcceptedSubmissions 19 | 20 | 21 | def submit_flag(flag, chall_id=None): 22 | chall, chall_sk = lookup_flag(flag, chall_id) 23 | 24 | if chall_sk is None: 25 | return False, 'This is not the correct flag.' 26 | 27 | SubRepo.pull() 28 | 29 | submissions = my_team().submissions() 30 | if chall in submissions.challs(): 31 | return False, 'Your team already solved %s.' % chall.id 32 | 33 | proof = proof_create(chall.id, chall_sk) 34 | submissions.submit(proof) 35 | SubRepo.push(commit_message='Proof: found flag for %s' % chall.id) 36 | 37 | return True, 'Congratulations! You found the right flag for %s.' % chall.id 38 | 39 | 40 | LINE_WIDTH = 72 41 | 42 | 43 | def pprint(): 44 | print('') 45 | print('-'*LINE_WIDTH) 46 | print('') 47 | submissions = AcceptedSubmissions() 48 | for chall_id in Challenge.index(): 49 | chall = Challenge(chall_id) 50 | print('ID: %s (%d points) [%s]' % ( 51 | chall.id, 52 | submissions.compute_points(chall, additional_solves=1), 53 | ', '.join(chall['tags']))) 54 | print('') 55 | print(chall['title']) 56 | print('') 57 | print('\n'.join(textwrap.wrap(chall.description(), 58 | LINE_WIDTH, 59 | replace_whitespace=False, 60 | drop_whitespace=False, 61 | break_on_hyphens=False))) 62 | print('') 63 | print('-'*LINE_WIDTH) 64 | print('') 65 | -------------------------------------------------------------------------------- /frontend/js/team.js: -------------------------------------------------------------------------------- 1 | const Team = Vue.component('team', { 2 | template: ` 3 |
4 |
5 |

{{team.name}}

6 |

{{$t('position')}}: {{teamRank.pos}}

7 |

{{$t('score')}}: {{teamRank.score}}

8 |

Crypt PK: {{team.crypt_pk}}

9 |

Sign PK: {{team.sign_pk}}

10 |
{{$t('members')}}
11 | 16 |
{{$t('solved-challenges')}}
17 | 22 |
23 |
24 |

404 - Team "{{$route.params.name}}" not found

25 |
26 |
27 | `, 28 | data: () => ({ 29 | loaded: false 30 | }), 31 | methods: { 32 | loadTeam: async function(teamName) { 33 | return getTeam(teamName); 34 | }, 35 | loadMembers: async function(teamName) { 36 | return getTeamMembers(teamName); 37 | }, 38 | loadTeamRank: async function(teamName) { 39 | const solvedChallenges = await getSolvedChallenges() 40 | return solvedChallenges.standings.filter(teamRank => teamRank.team === teamName)[0]; 41 | } 42 | }, 43 | mounted: async function() { 44 | await Promise.all([ 45 | this.loadTeamRank(this.$route.params.name) 46 | .then(teamRank => { this.teamRank = teamRank }), 47 | this.loadTeam(this.$route.params.name) 48 | .then(team => { this.team = team }), 49 | this.loadMembers(this.$route.params.name) 50 | .then(members => { this.members = members }) 51 | ]); 52 | this.loaded = true; 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /nizkctf/cli/localserver.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | try: 7 | from BaseHTTPServer import HTTPServer 8 | from SimpleHTTPServer import SimpleHTTPRequestHandler 9 | except ImportError: 10 | from http.server import SimpleHTTPRequestHandler, HTTPServer 11 | from ..settings import Settings 12 | from ..subrepo import SubRepo 13 | from .teamsecrets import TeamSecrets 14 | from ..localsettings import LocalSettings 15 | 16 | 17 | def handler(routes, root_redir=None, forbidden=set()): 18 | forbidden = set(os.path.realpath(path) for path in forbidden) 19 | 20 | class RequestHandler(SimpleHTTPRequestHandler): 21 | protocol_version = 'HTTP/1.0' 22 | 23 | def do_GET(self): 24 | if root_redir and self.path == '/': 25 | self.send_response(301) 26 | self.send_header("Location", root_redir) 27 | self.end_headers() 28 | return 29 | SimpleHTTPRequestHandler.do_GET(self) 30 | 31 | def translate_path(self, path): 32 | path = path.split('?', 1)[0] 33 | path = path.split('#', 1)[0] 34 | 35 | root = None 36 | for url_prefix, cur_root in routes: 37 | if path.startswith(url_prefix): 38 | root = cur_root 39 | path = path[len(url_prefix):] 40 | break 41 | if not root: 42 | return '' 43 | 44 | os.chdir(root) 45 | path = SimpleHTTPRequestHandler.translate_path(self, path) 46 | if path in forbidden: 47 | return '' 48 | return path 49 | 50 | return RequestHandler 51 | 52 | 53 | def main(port=8000): 54 | thisdir = os.path.dirname(os.path.realpath(__file__)) 55 | rootdir = os.path.realpath(os.path.join(thisdir, os.pardir, os.pardir)) 56 | subdir = SubRepo.get_path() 57 | 58 | ctf = os.path.basename(rootdir) 59 | submissions = os.path.basename(Settings.submissions_project) 60 | 61 | routes = [ 62 | ('/%s' % ctf, rootdir), 63 | ('/%s' % submissions, subdir), 64 | ] 65 | 66 | forbidden = { 67 | LocalSettings.path(), 68 | TeamSecrets.path(), 69 | } 70 | 71 | HandlerClass = handler(routes, '/%s' % ctf, forbidden) 72 | 73 | server_address = ('localhost', port) 74 | httpd = HTTPServer(server_address, HandlerClass) 75 | sa = httpd.socket.getsockname() 76 | print("Serving HTTP on", sa[0], "port", sa[1], "...") 77 | httpd.serve_forever() 78 | -------------------------------------------------------------------------------- /setup-vpn: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | if [[ $# != 3 ]]; then 3 | echo "usage: $0 vpn_addr team_id password" 4 | exit 1 5 | fi 6 | vpn_addr="$1" 7 | team_id="$2" 8 | password="$3" 9 | 10 | echo "team-${team_id}" > auth.txt 11 | echo "$password" >> auth.txt 12 | 13 | vpn_port=$((10000 + $team_id)) 14 | 15 | cat > ctf.ovpn < 39 | -----BEGIN CERTIFICATE----- 40 | MIIDtTCCAp2gAwIBAgIJAKz96Ok7WRJ4MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV 41 | BAYTAkJSMRAwDgYDVQQKEwdQd24yV2luMRMwEQYDVQQDEwpQd24yV2luIENBMQ8w 42 | DQYDVQQpEwZzZXJ2ZXIwHhcNMTcxMDExMDIxMTAxWhcNMjcxMDA5MDIxMTAxWjBF 43 | MQswCQYDVQQGEwJCUjEQMA4GA1UEChMHUHduMldpbjETMBEGA1UEAxMKUHduMldp 44 | biBDQTEPMA0GA1UEKRMGc2VydmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 45 | CgKCAQEAv0ONla2e6+JrhRElPobYgXyZs9ZlGplo6NYH4n2iOUPODkFfydRMkhqs 46 | T48q7s3sWpHOezr5Qj9SepGUvcYK/9tc7uAn2psUW8FOOK3qGjvw4o6G2x9sI/tS 47 | J6OWKbu84Xy05l6BrRxI+qWVLcYgjogIflXgkwLcFLUA19uGaQYzaPO4csGtGVPC 48 | oS0mrn/GgyH6RSXN502LUO4b+3LihI5fxf2nQjTb3pdImVMtznbP8XNaq/je5h5q 49 | hQT67DWjXVdZd41awMJlbvbmywdROLYUVMO73q78C1vg6lrr44tNi4D3cYXNwA18 50 | S+99+dDSCiTrtlr0dtGR8AHdOwM8GwIDAQABo4GnMIGkMB0GA1UdDgQWBBQMMm2V 51 | mbmXdQrfFdEZ+A3Vj8lUbjB1BgNVHSMEbjBsgBQMMm2VmbmXdQrfFdEZ+A3Vj8lU 52 | bqFJpEcwRTELMAkGA1UEBhMCQlIxEDAOBgNVBAoTB1B3bjJXaW4xEzARBgNVBAMT 53 | ClB3bjJXaW4gQ0ExDzANBgNVBCkTBnNlcnZlcoIJAKz96Ok7WRJ4MAwGA1UdEwQF 54 | MAMBAf8wDQYJKoZIhvcNAQELBQADggEBALJIROdxRx7M+R+OUUK0soIZlIiJEuXA 55 | nPNXvvC3hhYeo54GaiPBmfrDEtp+dgTpTzVuW+nur7M/oSnCAwBvasaUXQU+Am/A 56 | Z1r8zBSIsDDRM3OCfKbqUymjpzGNz7S6GawYIcroak5NW/C8VcuZzo7FTXPSI32u 57 | thfeDTzWTIcXOaKi1efsKgR49JVQ6YVhv5dzHxYtfZa3AGiRQRD4lKfbeQcd+Eh+ 58 | mzr8C4EuOK+YQiXHSyO9DxilNaR3t5LeNyiRH/xC2gFcBJtR1Ep/ZYNdA9TT41Gd 59 | ERKi59X9sSQJ7h+ZM8F56E99/7oW02PUpbxgf4CciLFcQKXk07uZJX8= 60 | -----END CERTIFICATE----- 61 | 62 | EOF 63 | 64 | cat > verify-cn < 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 25 |
26 |
27 | 28 |
29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /example-README.en.md: -------------------------------------------------------------------------------- 1 | **EVERYTHING BELOW IS A SAMPLE README WITH INSTRUCTIONS ON THE PLAYER SETUP, EDIT FOR YOUR CTF** 2 | 3 | # NIZKCTF example CTF 4 | 5 | 6 | ## Registration 7 | 1. All team members must have a GitHub account and [configure a SSH key in their account settings](https://github.com/settings/keys). 8 | 9 | **Note**: If you prefer team members to stay anonymous, you can create a single GitHub account for the entire team and share its credentials. 10 | 11 | 2. All team members must have the git client [correctly set up](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup). If you have never used git before, run: 12 | ```bash 13 | git config --global user.name "John Doe" 14 | git config --global user.email johndoe@example.com 15 | ``` 16 | 17 | 3. All team members must clone the repository and install the dependencies: 18 | ```bash 19 | git clone git@github.com:pwn2winctf/NIKCTF.git 20 | cd NIKCTF 21 | sudo apt-get install libsodium18 22 | curl https://bootstrap.pypa.io/get-pip.py | sudo -H python 23 | sudo -H python -m pip install -r pip-requirements.txt 24 | ``` 25 | **Note**: If you are using Ubuntu 14.04, add [ppa:elt/libsodium](https://launchpad.net/~elt/+archive/ubuntu/libsodium) to your system to be able to install `libsodium18`. 26 | 27 | 4. If dependencies are installed correctly, you should now see the help menu when calling: 28 | ```bash 29 | ./ctf -h 30 | ``` 31 | 32 | 5. The **leader of the team** must execute the following command and follow the instructions to register the team: 33 | ```bash 34 | ./ctf init 35 | ``` 36 | 37 | 6. The **other members of the team** must login to GitHub without registering a new team, by running: 38 | ```bash 39 | ./ctf login 40 | ``` 41 | 42 | 7. After that, **the leader** must share the `team-secrets.json` with the members of the team. The **other members of the team** must place the `team-secrets.json` file shared by the leader in their `NIZKCTF` directory. 43 | 44 | ## Challenges 45 | 46 | Challenges are available on https://pwn2winctf.github.io. 47 | 48 | If you prefer to browse them locally, you may also run a local webserver by typing `./ctf serve`, or list challenges through the command line interface: 49 | ```bash 50 | ./ctf challs 51 | ``` 52 | 53 | ## Flag submission 54 | 55 | To submit a flag: 56 | ```bash 57 | ./ctf submit --chall chall-id 'CTF-BR{flag123}' 58 | ``` 59 | 60 | You may omit `--chall chall-id` from the command, however it will be slower to run this way. In this case, we will look for the flag in every challenge released until now. 61 | 62 | ## Scoreboard 63 | 64 | Currently, the scoreboard is only available through the command line interface: 65 | ```bash 66 | ./ctf score --names --pull 67 | ``` 68 | 69 | However we plan to make it available through the web interface in a future release. 70 | -------------------------------------------------------------------------------- /nizkctf/acceptedsubmissions.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import time 7 | from .six import text_type 8 | from .subrepo import SubRepo 9 | from .serializable import SerializableDict 10 | from .scoring import compute_points 11 | 12 | 13 | ACCEPTED_SUBMISSIONS_FILE = 'accepted-submissions.json' 14 | 15 | 16 | class AcceptedSubmissions(SerializableDict): 17 | pretty_print = True 18 | 19 | def __init__(self): 20 | super(AcceptedSubmissions, self).__init__() 21 | self.setdefault('tasks', []) 22 | self.setdefault('standings', []) 23 | 24 | def path(self): 25 | return SubRepo.get_path(ACCEPTED_SUBMISSIONS_FILE) 26 | 27 | def get_team_standing(self, team_name): 28 | for team_standing in self['standings']: 29 | if team_standing['team'] == team_name: 30 | return team_standing 31 | 32 | team_standing = {'team': team_name, 33 | 'taskStats': {}, 34 | 'score': 0} 35 | self['standings'].append(team_standing) 36 | return team_standing 37 | 38 | def get_solves(self, chall_id): 39 | solves = set() 40 | for team_standing in self['standings']: 41 | if chall_id in team_standing['taskStats']: 42 | solves.add(team_standing['team']) 43 | return solves 44 | 45 | def compute_points(self, chall, additional_solves=0): 46 | num_solves = len(self.get_solves(chall.id)) + additional_solves 47 | return compute_points(chall, num_solves) 48 | 49 | def recompute_score(self, chall): 50 | points = self.compute_points(chall) 51 | chall_id = chall.id 52 | # update affected team's standings 53 | for team_standing in self['standings']: 54 | task_stats = team_standing['taskStats'] 55 | if chall_id in task_stats: 56 | task_stats[chall_id]['points'] = points 57 | team_standing['score'] = sum(task['points'] 58 | for task in task_stats.values()) 59 | 60 | def rank(self): 61 | standings = self['standings'] 62 | standings.sort(key=lambda standing: (standing['score'], 63 | -standing['lastAccept']), 64 | reverse=True) 65 | for i, standing in enumerate(standings): 66 | standing['pos'] = i + 1 67 | 68 | def add(self, chall, team): 69 | chall_id = chall.id 70 | team_name = team['name'] 71 | 72 | if chall_id not in self['tasks']: 73 | self['tasks'].append(chall_id) 74 | 75 | team_standing = self.get_team_standing(team_name) 76 | 77 | if chall_id in team_standing['taskStats']: 78 | # Challenge already submitted by team 79 | return 80 | 81 | accepted_time = int(time.time()) 82 | team_standing['taskStats'][chall_id] = {'points': 0, 83 | 'time': accepted_time} 84 | team_standing['lastAccept'] = accepted_time 85 | 86 | self.recompute_score(chall) 87 | self.rank() 88 | self.save() 89 | -------------------------------------------------------------------------------- /nizkctf/cli/news.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import sys 7 | import time 8 | import json 9 | import pysodium 10 | from base64 import b64decode 11 | from ..news import News 12 | from ..team import my_team 13 | from ..text import width 14 | 15 | from .teamsecrets import TeamSecrets 16 | from ..team import Team 17 | from ..proposal import retry_push 18 | from ..six import to_unicode 19 | 20 | 21 | TIME_DISPLAY_FORMAT = '%Y-%m-%d %H:%M:%S' 22 | 23 | 24 | def submit(msg_text, to=None): 25 | for _ in retry_push('Added news to %s' % (to or 'all')): 26 | News().add(msg_text, to) 27 | 28 | 29 | def pprint(news, team_only): 30 | ''' 31 | Pretty print news in terminal. 32 | 33 | Args: 34 | news (list): List of news. 35 | team_only (bool): whether to display only team directed news or not. 36 | ''' 37 | 38 | if len(news) == 0: 39 | print('No news yet.') 40 | return 41 | 42 | team = my_team() 43 | 44 | def decrypt_news(news_item): 45 | assert('to' in news_item) 46 | 47 | # Message was sent to a team, so we need to decode and decrypt it 48 | decoded_msg = b64decode(news_item['msg'].encode("utf-8")) 49 | try: 50 | team_pk, team_sk = team['crypt_pk'], TeamSecrets['crypt_sk'] 51 | decrypted_msg = pysodium.crypto_box_seal_open(decoded_msg, 52 | team_pk, 53 | team_sk) 54 | except: 55 | decrypted_msg = b'' 56 | 57 | news_item['msg'] = decrypted_msg.decode("utf-8") 58 | return news_item 59 | 60 | def filter_news(news): 61 | # Filter items based on team_only flag, applying decryptionif needed 62 | to_filter = [team['name']] + ([] if team_only else [None]) 63 | for news_item in news: 64 | to = news_item.get('to') 65 | if to in to_filter: 66 | yield decrypt_news(news_item) if to else news_item 67 | 68 | news = filter_news(news) 69 | 70 | to_len = max(width(team['name']), 10) 71 | 72 | # FIXME test formatting 73 | time_len = msg_len = 20 74 | 75 | def hyph(n): 76 | return '-'*(n + 2) 77 | 78 | sep = hyph(time_len) + '+' + hyph(to_len) + '+' + hyph(msg_len) 79 | 80 | def fmtcol(s, n): 81 | return ' ' + s + ' '*(n - width(s) + 1) 82 | 83 | def fmt(time, to, msg): 84 | return fmtcol(time, time_len) + '|' + \ 85 | fmtcol(to, to_len) + '|' + \ 86 | fmtcol(msg, msg_len) 87 | 88 | def fmtime(timestamp): 89 | return to_unicode(time.strftime(TIME_DISPLAY_FORMAT, 90 | time.localtime(timestamp))) 91 | 92 | print('') 93 | print(sep) 94 | print(fmt('Date', 'To', 'Message')) 95 | print(sep) 96 | 97 | for news_item in news: 98 | print(fmt(fmtime(news_item['time']), 99 | news_item.get('to', to_unicode('all')), news_item['msg'])) 100 | 101 | print(sep) 102 | print('') 103 | -------------------------------------------------------------------------------- /frontend/js/rank.js: -------------------------------------------------------------------------------- 1 | const Rank = Vue.component('rank', { 2 | template: ` 3 |
4 | 5 | 8 |
    9 |
  • 10 |
    {{team.pos}}. {{team.team}} 11 |
    {{team.score}}
    12 |
    13 | 14 |
    15 |
    16 |
  • 17 |
18 |
19 | `, 20 | data: () => ({ 21 | rank: [], 22 | teams: {}, 23 | userTeam: Cookies.get('team'), 24 | chart: null 25 | }), 26 | methods: { 27 | getTeamFlagUrl: country => `https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/${settings.flag_icon_css_ver || '2.8.0'}/flags/4x3/${country}.svg`, 28 | startChart: function(data) { 29 | this.chart = new Chart($('#chart'), { 30 | "type":"line", 31 | "data": { 32 | "labels":["January","February","March","April","May","June","July"], 33 | "datasets":[ 34 | { 35 | "label":"My First Dataset", 36 | "data":[65,59,80,81,56,55,40], 37 | "fill":false, 38 | "borderColor":"rgb(75, 192, 192)", 39 | "lineTension":0.1 40 | } 41 | ] 42 | }, 43 | "options": {} 44 | }); 45 | }, 46 | loadTeam: async function(teamName) { 47 | if (!this.teams[teamName]) { 48 | this.teams[teamName] = await getTeam(teamName); 49 | } 50 | 51 | return this.teams[teamName]; 52 | }, 53 | loadRank: function(acceptedSubmissions) { 54 | this.rank = acceptedSubmissions.standings.filter((team, i) => i < (this.limit || acceptedSubmissions.standings.length)); 55 | this.rank.forEach(async (rank, index) => { 56 | this.rank.splice(index, 1, Object.assign({}, rank, { 57 | countries: (await this.loadTeam(rank.team)).countries 58 | })); 59 | }) 60 | this.startChart(); 61 | }, 62 | teamClick: function(teamName) { 63 | this.$router.push({ path: `/team/${teamName}` }); 64 | } 65 | }, 66 | props: ['limit', 'hideTitle'], 67 | mounted: function() { 68 | this.rankPolling = createPooling( 69 | getSolvedChallenges, 70 | this.loadRank 71 | ); 72 | this.rankPolling.start(); 73 | }, 74 | beforeDestroy: function() { 75 | this.rankPolling.stop(); 76 | } 77 | }); 78 | -------------------------------------------------------------------------------- /nizkctf/challenge.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | from base64 import b64decode 6 | import os 7 | import re 8 | import json 9 | import codecs 10 | import pysodium 11 | from .serializable import SerializableDict, SerializableList 12 | from .settings import Settings 13 | 14 | 15 | CHALLENGES_DIR = 'challenges' 16 | INDEX_FILE = 'index.json' 17 | 18 | thisdir = os.path.dirname(os.path.realpath(__file__)) 19 | chall_dir = os.path.realpath(os.path.join(thisdir, os.pardir, CHALLENGES_DIR)) 20 | 21 | 22 | class Challenge(SerializableDict): 23 | def __init__(self, id): 24 | self.validate_id(id) 25 | self.id = id 26 | super(Challenge, self).__init__() 27 | 28 | def __eq__(self, other): 29 | return self.id == other.id 30 | 31 | def __hash__(self): 32 | return hash(self.id) 33 | 34 | def path(self): 35 | return os.path.join(chall_dir, self.id + '.json') 36 | 37 | def description(self, lang='en'): 38 | # Get from a localized markdown file 39 | path = os.path.join(chall_dir, '{}.{}.md'.format(self.id, lang)) 40 | if os.path.exists(path): 41 | with codecs.open(path, encoding='utf-8') as f: 42 | return f.read() 43 | # Get from the json 44 | return self['description'] 45 | 46 | @staticmethod 47 | def validate_id(id): 48 | if len(id) > Settings.max_size_chall_id or \ 49 | not re.match(r'^[a-zA-Z0-9-_]+$', id): 50 | raise ValueError('invalid challenge ID') 51 | 52 | @staticmethod 53 | def _binary_field(k): 54 | return k in {'salt', 'pk'} 55 | 56 | @staticmethod 57 | def index(): 58 | return ChallengeIndex() 59 | 60 | 61 | class ChallengeIndex(SerializableList): 62 | def path(self): 63 | return os.path.join(chall_dir, INDEX_FILE) 64 | 65 | 66 | def derive_keypair(salt, opslimit, memlimit, flag): 67 | flag = flag.encode('utf-8') 68 | assert isinstance(salt, bytes) 69 | assert isinstance(flag, bytes) 70 | assert len(salt) == pysodium.crypto_pwhash_SALTBYTES 71 | 72 | chall_seed = pysodium.crypto_pwhash( 73 | pysodium.crypto_sign_SEEDBYTES, 74 | flag, 75 | salt, 76 | opslimit, 77 | memlimit, 78 | pysodium.crypto_pwhash_ALG_ARGON2ID13) 79 | 80 | return pysodium.crypto_sign_seed_keypair(chall_seed) 81 | 82 | 83 | def random_salt(): 84 | return pysodium.randombytes( 85 | pysodium.crypto_pwhash_SALTBYTES) 86 | 87 | 88 | def lookup_flag(flag, chall_id=None): 89 | if chall_id: 90 | # challenge provided, only try it 91 | try_challenges = [Challenge(chall_id)] 92 | if not try_challenges[0].exists(): 93 | raise ValueError("A challenge named '%s' does not exist." % 94 | chall_id) 95 | else: 96 | # try every challenge 97 | try_challenges = [Challenge(id) for id in Challenge.index()] 98 | 99 | try_params = set((chall['salt'], chall['opslimit'], chall['memlimit']) 100 | for chall in try_challenges) 101 | pk_chall = {chall['pk']: chall for chall in try_challenges} 102 | 103 | for salt, opslimit, memlimit in try_params: 104 | pk, sk = derive_keypair(salt, opslimit, memlimit, flag) 105 | match = pk_chall.get(pk) 106 | 107 | if match: 108 | return match, sk 109 | 110 | return None, None 111 | -------------------------------------------------------------------------------- /nizkctf/subrepo.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import subprocess 7 | import base64 8 | import pysodium 9 | from .settings import Settings 10 | from .localsettings import LocalSettings 11 | from .repohost import RepoHost 12 | 13 | 14 | SUBREPO_NAME = 'submissions' 15 | 16 | 17 | class SubRepo(object): 18 | @classmethod 19 | def set_clone_into(cls, clone_into): 20 | cls.clone_into = clone_into 21 | cls.path = os.path.join(clone_into, SUBREPO_NAME) 22 | 23 | @classmethod 24 | def get_path(cls, subpath=''): 25 | if os.path.exists(cls.path): 26 | return os.path.join(cls.path, subpath) 27 | raise EnvironmentError("The subrepository path ('%s') was not created " 28 | "yet. Please call 'ctf login' to get it cloned " 29 | "before performing any further actions." % 30 | cls.path) 31 | 32 | @classmethod 33 | def clone(cls, fork=True): 34 | repohost = RepoHost.instance() 35 | upstream_url = repohost.get_ssh_url(Settings.submissions_project) 36 | 37 | if fork: 38 | forked_project, origin_url = \ 39 | repohost.fork(Settings.submissions_project) 40 | LocalSettings.forked_project = forked_project 41 | else: 42 | origin_url = upstream_url 43 | 44 | cls.git(['clone', origin_url, SUBREPO_NAME], cwd=cls.clone_into) 45 | cls.git(['remote', 'add', 'upstream', upstream_url]) 46 | 47 | if fork: 48 | cls.git(['remote', 'set-url', 'origin', 49 | repohost.get_ssh_url(forked_project)]) 50 | 51 | @classmethod 52 | def pull(cls): 53 | cls.git(['checkout', 'master']) 54 | cls.git(['pull', '--rebase', 'upstream', 'master']) 55 | 56 | @classmethod 57 | def push(cls, commit_message='commit', merge_request=True): 58 | branch = 'master' 59 | if merge_request: 60 | branch = cls.random_branch() 61 | cls.git(['checkout', '-b', branch, 'master']) 62 | 63 | cls.git(['add', '-A']) 64 | cls.git(['commit', '--no-gpg-sign', '-m', commit_message], 65 | returncodes={0, 1}) # do not fail on 'nothing to commit' 66 | cls.git(['push', '-u', 'origin', branch]) 67 | 68 | if merge_request: 69 | repohost = RepoHost.instance() 70 | repohost.merge_request(LocalSettings.forked_project, 71 | Settings.submissions_project, 72 | source_branch=branch, 73 | title=commit_message) 74 | 75 | @staticmethod 76 | def random_branch(): 77 | return base64.b32encode(pysodium.randombytes(10))\ 78 | .decode('utf-8').lower() 79 | 80 | @classmethod 81 | def git(cls, args, **kwargs): 82 | returncodes = kwargs.pop('returncodes', {0}) 83 | if 'cwd' not in kwargs: 84 | kwargs['cwd'] = cls.get_path() 85 | 86 | p = subprocess.Popen(['git'] + args, **kwargs) 87 | 88 | r = None 89 | if 'stdout' in kwargs: 90 | r = p.stdout.read() 91 | 92 | returncode = p.wait() 93 | if returncode not in returncodes: 94 | raise GitError(returncode) 95 | 96 | return r 97 | 98 | 99 | class GitError(Exception): 100 | def __init__(self, returncode, *args): 101 | self.returncode = returncode 102 | super(GitError, self).__init__(*args) 103 | 104 | 105 | thisdir = os.path.dirname(os.path.realpath(__file__)) 106 | SubRepo.set_clone_into(os.path.realpath(os.path.join(thisdir, os.pardir))) 107 | -------------------------------------------------------------------------------- /lambda_function.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | from nizkctf.settings import Settings 6 | from nizkctf.subrepo import SubRepo 7 | from nizkctf.repohost import RepoHost 8 | from nizkctf.proposal import consider_proposal 9 | from nizkctf.six import to_bytes 10 | import os 11 | import json 12 | import base64 13 | import tempfile 14 | import traceback 15 | 16 | 17 | def run(merge_info): 18 | SubRepo.set_clone_into(tempfile.mkdtemp()) 19 | 20 | # Prepare git and ssh for usage inside the container 21 | setup_environment() 22 | 23 | # Merge proposal if changes are valid 24 | consider_proposal(merge_info) 25 | 26 | 27 | def handle_payload(payload, context): 28 | merge_info = RepoHost.webhook.adapt_payload(payload) 29 | 30 | if not merge_info: 31 | # Message not of our interest (e.g. merge request closed) 32 | return 33 | 34 | try: 35 | run(merge_info) 36 | except: 37 | # Do not re-raise, we do not want automatic retries 38 | traceback.print_exc() 39 | # Send tracking number to the user 40 | send_cloudwatch_info(merge_info, context) 41 | 42 | 43 | def send_cloudwatch_info(merge_info, context): 44 | proj = Settings.submissions_project 45 | mr_id = merge_info['mr_id'] 46 | 47 | comment = "Sorry. A failure has occurred when processing your proposal. " \ 48 | "Please contact support and present the following info:\n\n" \ 49 | "**Stream name**: %s\n" \ 50 | "**Request ID**: %s\n" % \ 51 | (context.log_stream_name, context.aws_request_id) 52 | 53 | repohost = RepoHost.instance() 54 | repohost.mr_comment(proj, mr_id, comment) 55 | repohost.mr_close(proj, mr_id) 56 | 57 | 58 | def handle_apigw(event, context): 59 | headers = event['params']['header'] 60 | raw_payload = event['body'] 61 | 62 | # autenticate the message 63 | secret = to_bytes(os.getenv('WEBHOOK_SECRET_TOKEN')) 64 | RepoHost.webhook.auth(secret, headers, to_bytes(raw_payload)) 65 | 66 | payload = json.loads(raw_payload) 67 | return handle_payload(payload, context) 68 | 69 | 70 | def handle_sns(event, context): 71 | raw_payload = event['Records'][0]['Sns']['Message'] 72 | payload = json.loads(raw_payload) 73 | 74 | # no way to authenticate, but also no need to 75 | # (publishing to the SNS topic should already be authenticated) 76 | 77 | return handle_payload(payload, context) 78 | 79 | 80 | def setup_environment(): 81 | root = os.getenv('LAMBDA_TASK_ROOT') 82 | bin_dir = os.path.join(root, 'bin') 83 | os.environ['PATH'] += ':' + bin_dir 84 | os.environ['GIT_EXEC_PATH'] = bin_dir 85 | 86 | ssh_dir = tempfile.mkdtemp() 87 | 88 | ssh_identity = os.path.join(ssh_dir, 'identity') 89 | with os.fdopen(os.open(ssh_identity, os.O_WRONLY | os.O_CREAT, 0o600), 90 | 'w') as f: 91 | f.write(base64.b64decode(os.getenv('SSH_IDENTITY'))) 92 | 93 | ssh_config = os.path.join(ssh_dir, 'config') 94 | with open(ssh_config, 'w') as f: 95 | f.write('CheckHostIP no\n' 96 | 'StrictHostKeyChecking yes\n' 97 | 'IdentityFile %s\n' 98 | 'UserKnownHostsFile %s\n' % 99 | (ssh_identity, os.path.join(root, 'known_hosts'))) 100 | 101 | os.environ['GIT_SSH_COMMAND'] = 'ssh -F %s' % ssh_config 102 | 103 | 104 | def lambda_handler(event, context): 105 | if 'Records' in event: 106 | return handle_sns(event, context) 107 | elif 'body' in event: 108 | return handle_apigw(event, context) 109 | raise ValueError("Did not recognize a valid event originated by SNS nor " 110 | "by API Gateway. Did you configure it correctly?") 111 | -------------------------------------------------------------------------------- /frontend/js/home.js: -------------------------------------------------------------------------------- 1 | const News = Vue.component('news', { 2 | template: ` 3 |
4 | 18 |
19 |
20 | [ {{formatDate(singleNews.time)}} ] admin: {{formatNews(singleNews)}} 21 | [ {{formatDate(singleNews.time)}} ] admin: {{$t("private-message")}} 22 |
23 |
24 |
25 |
[ {{formatDate(solve.time)}} ] {{solve.team}} {{$t("solved")}} {{solve.chall}}
26 |
27 |
28 | `, 29 | data: () => ({ 30 | title: "Home", 31 | news: [], 32 | solves: [] 33 | }), 34 | methods: { 35 | formatDate: date => moment(date, "X").format('DD-MM-YYYY HH:mm:ss'), 36 | formatNews: function(msg) { 37 | if (msg.to) { 38 | return 39 | } 40 | 41 | return msg.msg 42 | 43 | }, 44 | loadNews: function(news) { 45 | this.news = news.filter(msg => { 46 | console.log(!msg.to || (msg.to && msg.to === Cookies.get('team'))); 47 | return !msg.to || (msg.to && msg.to === Cookies.get('team')) 48 | }) 49 | .sort((msgA, msgB) => msgB.time - msgA.time); 50 | }, 51 | setChallengesSolves: function(acceptedSubmissions) { 52 | this.solves = acceptedSubmissions.standings.reduce((reducer, { taskStats, team }) => { 53 | Object.keys(taskStats).forEach(chall => { 54 | reducer.push({ 55 | team, 56 | chall, 57 | time: taskStats[chall].time 58 | }); 59 | }); 60 | return reducer; 61 | }, []) 62 | .sort((solveA, solveB) => solveB.time - solveA.time); 63 | } 64 | }, 65 | mounted: function() { 66 | $('ul.tabs').tabs(); 67 | this.newsPolling = createPooling( 68 | getNews, 69 | this.loadNews 70 | ); 71 | this.newsPolling.start(); 72 | 73 | this.submissionsPolling = createPooling( 74 | getSolvedChallenges, 75 | this.setChallengesSolves 76 | ); 77 | this.submissionsPolling.start(); 78 | }, 79 | beforeDestroy: function() { 80 | this.newsPolling.stop(); 81 | this.submissionsPolling.stop(); 82 | } 83 | }); 84 | 85 | const Home = { 86 | template: ` 87 |
88 | 89 |
90 |
91 |
{{$t('news')}}
92 | 93 |
94 |
95 |
{{$t('rank')}}
96 | 97 |
98 |
99 |
100 |
101 |
{{$t('challenges')}}
102 | 103 |
104 |
105 |
106 | `, 107 | mounted: () => { 108 | title = "Home"; 109 | }, 110 | 111 | } 112 | -------------------------------------------------------------------------------- /nizkctf/cli/scoreboard.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import sys 6 | import json 7 | import operator 8 | import tempfile 9 | import subprocess 10 | import codecs 11 | from ..team import Team 12 | from ..text import width 13 | from ..six import viewitems 14 | from ..acceptedsubmissions import AcceptedSubmissions 15 | 16 | 17 | def rank(): 18 | ''' 19 | Compute ranking given a submissions file. 20 | 21 | Args: 22 | f (file): File-like object with the submissions json. 23 | 24 | Returns: 25 | List containing the computed ranking for the submission list. 26 | The result is in the following format: 27 | 28 | (team, score) 29 | 30 | And a map containing submissions sorted by team. 31 | ''' 32 | 33 | submissions = {} 34 | scores = [] 35 | for team in AcceptedSubmissions()['standings']: 36 | team_id = Team(name=team['team']).id 37 | submissions[team_id] = [sub for sub in team['taskStats'].values()] 38 | submissions[team_id] = sorted(submissions[team_id], 39 | key=operator.itemgetter('time')) 40 | scores.append((team_id, team['score'])) 41 | 42 | return (scores, submissions) 43 | 44 | 45 | def pprint(ranking, top=0, show_names=False): 46 | ''' 47 | Pretty print scoreboard in terminal. 48 | 49 | Args: 50 | ranking (list): List of tuples containing teams and scores. 51 | top (int): Number of teams to show in scoreboard. 52 | 53 | ''' 54 | 55 | if top == 0: 56 | top = len(ranking) 57 | 58 | if len(ranking) == 0: 59 | print('Nobody scored yet.') 60 | return 61 | 62 | ranking = ranking[:top] 63 | 64 | if show_names: 65 | ranking = [(Team(id=team)['name'], score) for team, score in ranking] 66 | 67 | team_len = max(width(team) for team, score in ranking) 68 | team_len = max(team_len, 10) 69 | 70 | pos_len = score_len = 6 71 | 72 | def hyph(n): 73 | return '-'*(n + 2) 74 | 75 | sep = hyph(pos_len) + '+' + hyph(team_len) + '+' + hyph(score_len) 76 | 77 | def fmtcol(s, n): 78 | return ' ' + s + ' '*(n - width(s) + 1) 79 | 80 | def fmt(pos, team, score): 81 | return fmtcol(pos, pos_len) + '|' + \ 82 | fmtcol(team, team_len) + '|' + \ 83 | fmtcol(score, score_len) 84 | 85 | print('') 86 | print(sep) 87 | print(fmt('Pos', 'Team', 'Score')) 88 | print(sep) 89 | 90 | for idx, (team, score) in enumerate(ranking): 91 | pos = '%d' % (idx + 1) 92 | print(fmt(pos, team, '%d' % score)) 93 | 94 | print(sep) 95 | print('') 96 | 97 | 98 | def plot(ranking, submissions, top=3): 99 | ''' 100 | Plot points for top teams. 101 | 102 | Args: 103 | ranking (list): List containing teams and scores sorted in 104 | descending order. 105 | submissions (dict): Dict [team] -> submission list. 106 | top (int): Number of teams to appear in chart. 107 | ''' 108 | if len(ranking) == 0: 109 | return 110 | 111 | # generate temporary files with data points 112 | fnames = [] 113 | for team, _ in ranking[0:top]: 114 | f = tempfile.NamedTemporaryFile(suffix='.dat', 115 | prefix='nizkctf-', delete=True) 116 | w = codecs.getwriter('utf-8')(f) 117 | partial = 0 118 | for subm in submissions[team]: 119 | partial += subm['points'] 120 | w.write('%s, %d\n' % (subm['time'], partial)) 121 | w.flush() 122 | fnames.append((team, f)) 123 | 124 | # generate gnuplot file 125 | f = tempfile.NamedTemporaryFile(suffix='.gp', 126 | prefix='nizkctf-', delete=True) 127 | w = codecs.getwriter('utf-8')(f) 128 | w.write('set terminal dumb 120 30\n') 129 | w.write('set xdata time\n') 130 | w.write('set datafile sep \',\'\n') 131 | w.write('set timefmt "%s"\n') 132 | w.write('set style data steps\n') 133 | w.write('plot ') 134 | fmt = '\'%s\' using 1:2 title \'%s\'' 135 | w.write(fmt % (fnames[0][1].name, fnames[0][0])) 136 | for team, ft in fnames[1:]: 137 | w.write(', ') 138 | w.write(fmt % (ft.name, team)) 139 | w.flush() 140 | 141 | # plot in terminal 142 | p = subprocess.Popen(['gnuplot', f.name], 143 | stderr=sys.stderr, 144 | stdout=sys.stdout) 145 | p.wait() 146 | 147 | # close/delete files 148 | f.close() 149 | for nm, f in fnames: 150 | f.close() 151 | -------------------------------------------------------------------------------- /nizkctf/repohost/github.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import requests 6 | import hashlib 7 | import hmac 8 | from ..settings import Settings 9 | from ..six import to_bytes 10 | from .common import BaseRepoHost, APIError, WebhookAuthError 11 | 12 | 13 | class GitHubWebhook(object): 14 | @staticmethod 15 | def auth(secret, headers, raw_payload): 16 | received_sig = to_bytes(headers['X-Hub-Signature']) 17 | 18 | h = hmac.new(secret, raw_payload, hashlib.sha1).hexdigest() 19 | correct_sig = b'sha1=' + to_bytes(h) 20 | 21 | if not hmac.compare_digest(received_sig, correct_sig): 22 | raise WebhookAuthError() 23 | 24 | @staticmethod 25 | def adapt_payload(payload): 26 | # filtering 27 | if 'pull_request' not in payload: 28 | return None 29 | if payload['action'] not in {'opened', 'reopened'}: 30 | return None 31 | if payload['pull_request']['base']['repo']['full_name'] != \ 32 | Settings.submissions_project: 33 | return None 34 | if payload['pull_request']['base']['ref'] != 'master': 35 | return None 36 | # mappings 37 | return {"mr_id": payload['pull_request']['number'], 38 | "source_ssh_url": payload['pull_request']['head']['repo'] 39 | ['ssh_url'], 40 | "source_commit": payload['pull_request']['head']['sha'], 41 | "user_id": payload['pull_request']['user']['id'], 42 | "username": payload['pull_request']['user']['login']} 43 | 44 | 45 | class GitHub(BaseRepoHost): 46 | webhook = GitHubWebhook 47 | 48 | @classmethod 49 | def get_token(cls, username, password, OTP): 50 | authorization = {'scopes': 'public_repo', 51 | 'note': Settings.ctf_name} 52 | if OTP is None: 53 | headers = None 54 | else: 55 | headers = {'X-GitHub-OTP': OTP} 56 | 57 | r = requests.post(Settings.github_api_endpoint + 58 | 'authorizations', 59 | json=authorization, 60 | auth=(username, password), 61 | headers=headers) 62 | 63 | data = r.json() 64 | 65 | try: 66 | if cls._has_error(data, 'already_exists'): 67 | raise APIError 68 | if cls._has_error(data, 'Bad credentials'): 69 | raise BadCreds("Bad Credentials: Please certify that your login and password are correct") 70 | except APIError: 71 | print("API Error: Please visit https://github.com/settings/tokens " 72 | "and make sure you do not already have a personal " 73 | "access token called '%s'" % Settings.ctf_name) 74 | #except BadCreds: 75 | # print("Bad Credentials: Please certify that your login and password are correct") 76 | 77 | cls._raise_for_status(r) 78 | 79 | return data['token'] 80 | 81 | @staticmethod 82 | def get_ssh_url(proj): 83 | return Settings.github_ssh_url % proj 84 | 85 | def fork(self, source): 86 | r = self.s.post(Settings.github_api_endpoint + 87 | 'repos/' + source + '/forks') 88 | self._raise_for_status(r) 89 | data = r.json() 90 | return data['full_name'], data['ssh_url'] 91 | 92 | def merge_request(self, source, target, 93 | source_branch='master', 94 | target_branch='master', 95 | title='Pull Request'): 96 | source_branch = source.split('/', 2)[0] + ':' + source_branch 97 | 98 | pull_request = {'head': source_branch, 99 | 'base': target_branch, 100 | 'title': title} 101 | 102 | r = self.s.post(Settings.github_api_endpoint + 103 | 'repos/' + target + '/pulls', 104 | json=pull_request) 105 | self._raise_for_status(r) 106 | return r.json() 107 | 108 | def mr_comment(self, proj, mr_id, contents): 109 | r = self.s.post(Settings.github_api_endpoint + 110 | 'repos/' + proj + '/issues/%d' % mr_id + '/comments', 111 | json={'body': contents}) 112 | self._raise_for_status(r) 113 | return r.json() 114 | 115 | def mr_close(self, proj, mr_id): 116 | r = self.s.patch(Settings.github_api_endpoint + 117 | 'repos/' + proj + '/pulls/%d' % mr_id, 118 | json={'state': 'closed'}) 119 | self._raise_for_status(r) 120 | return r.json() 121 | 122 | def mr_accept(self, proj, mr_id, sha): 123 | r = self.s.put(Settings.github_api_endpoint + 124 | 'repos/' + proj + '/pulls/%d' % mr_id + '/merge', 125 | json={'sha': sha}) 126 | self._raise_for_status(r) 127 | return r.json() 128 | 129 | def _init_session(self): 130 | self.s.headers.update({'Authorization': 'token ' + self.token}) 131 | 132 | @staticmethod 133 | def _has_error(data, err_code): 134 | return any(err.get('code') == err_code 135 | for err in data.get('errors', [])) 136 | -------------------------------------------------------------------------------- /nizkctf/repohost/gitlab.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import requests 6 | import hmac 7 | import re 8 | from ..settings import Settings 9 | from ..six import to_bytes 10 | from .common import BaseRepoHost, APIError, WebhookAuthError, quote_plus 11 | 12 | 13 | class GitLabWebhook(object): 14 | @staticmethod 15 | def auth(secret, headers, raw_payload): 16 | received_token = to_bytes(headers['X-Gitlab-Token']) 17 | if not hmac.compare_digest(secret, received_token): 18 | raise WebhookAuthError() 19 | 20 | @staticmethod 21 | def adapt_payload(payload): 22 | # filtering 23 | if payload['object_kind'] != 'merge_request': 24 | return None 25 | if payload['object_attributes']['action'] not in {'open', 'reopen'}: 26 | return None 27 | if payload['object_attributes']['target']['path_with_namespace'] != \ 28 | Settings.submissions_project: 29 | return None 30 | if payload['object_attributes']['target_branch'] != 'master': 31 | return None 32 | # mappings 33 | return {"mr_id": payload['object_attributes']['iid'], 34 | "source_ssh_url": payload['object_attributes']['source'] 35 | ['git_ssh_url'], 36 | "source_commit": payload['object_attributes']['last_commit'] 37 | ['id'], 38 | "user_id": payload['object_attributes']['author_id'], 39 | "username": payload['user']['username']} 40 | 41 | 42 | class GitLab(BaseRepoHost): 43 | webhook = GitLabWebhook 44 | 45 | @classmethod 46 | def get_token(cls, username, password): 47 | auth = {'login': username, 48 | 'password': password} 49 | 50 | r = requests.post(Settings.gitlab_api_endpoint + 51 | 'session', 52 | json=auth) 53 | cls._raise_for_status(r) 54 | 55 | data = r.json() 56 | return data['private_token'] 57 | 58 | @staticmethod 59 | def get_ssh_url(proj): 60 | return Settings.gitlab_ssh_url % proj 61 | 62 | def fork(self, source): 63 | r = self.s.post(Settings.gitlab_api_endpoint + 64 | 'projects/' + quote_plus(source) + '/fork') 65 | 66 | data = r.json() 67 | if self._has_error(data, 'name', 'has already been taken'): 68 | # Simulate GitHub API behaviour (return already existing fork) 69 | username = self._get_user_namespace() 70 | sink = username + '/' + source.split('/', 2)[1] 71 | sink_proj = self._get_project(sink) 72 | forked_from = sink_proj.get('forked_from_project', {})\ 73 | .get('path_with_namespace') 74 | if forked_from != source: 75 | raise APIError("Project '%s' already exists and is not a fork " 76 | "from '%s'. Please remove or rename it to " 77 | "allow a fork to be made." % (sink, source)) 78 | data = sink_proj 79 | else: 80 | self._raise_for_status(r) 81 | 82 | return data['path_with_namespace'], data['ssh_url_to_repo'] 83 | 84 | def merge_request(self, source, target, 85 | source_branch='master', 86 | target_branch='master', 87 | title='Merge Request'): 88 | target_id = self._get_project(target)['id'] 89 | 90 | merge_request = {'source_branch': source_branch, 91 | 'target_branch': target_branch, 92 | 'title': title, 93 | 'target_project_id': target_id} 94 | 95 | r = self.s.post(Settings.gitlab_api_endpoint + 96 | 'projects/' + quote_plus(source) + '/merge_requests', 97 | json=merge_request) 98 | self._raise_for_status(r) 99 | return r.json() 100 | 101 | def mr_comment(self, proj, mr_id, contents): 102 | r = self.s.post(Settings.gitlab_api_endpoint + 103 | 'projects/' + quote_plus(proj) + 104 | '/merge_requests/%d' % mr_id + '/notes', 105 | json={'body': contents}) 106 | self._raise_for_status(r) 107 | return r.json() 108 | 109 | def mr_close(self, proj, mr_id): 110 | r = self.s.put(Settings.gitlab_api_endpoint + 111 | 'projects/' + quote_plus(proj) + 112 | '/merge_requests/%d' % mr_id, 113 | json={'state_event': 'close'}) 114 | self._raise_for_status(r) 115 | return r.json() 116 | 117 | def mr_accept(self, proj, mr_id, sha): 118 | r = self.s.put(Settings.gitlab_api_endpoint + 119 | 'projects/' + quote_plus(proj) + 120 | '/merge_requests/%d' % mr_id + '/merge', 121 | json={'sha': sha}) 122 | self._raise_for_status(r) 123 | return r.json() 124 | 125 | def _get_user_namespace(self): 126 | return (n['path'] for n in self._get_namespaces() 127 | if n['kind'] == 'user').next() 128 | 129 | def _get_namespaces(self): 130 | r = self.s.get(Settings.gitlab_api_endpoint + 131 | 'namespaces') 132 | self._raise_for_status(r) 133 | return r.json() 134 | 135 | def _get_project(self, proj): 136 | r = self.s.get(Settings.gitlab_api_endpoint + 137 | 'projects/' + quote_plus(proj)) 138 | self._raise_for_status(r) 139 | return r.json() 140 | 141 | def _init_session(self): 142 | self.s.headers.update({'PRIVATE-TOKEN': self.token}) 143 | 144 | @staticmethod 145 | def _has_error(data, key, msg): 146 | return msg in data.get('message', {}).get(key, []) 147 | -------------------------------------------------------------------------------- /nizkctf/team.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import re 7 | import hashlib 8 | import pysodium 9 | from .six import text_type 10 | from .iso3166 import valid_countries 11 | from .settings import Settings 12 | from .subrepo import SubRepo 13 | from .serializable import SerializableDict, SerializableList 14 | from .proof import proof_open 15 | from .cli.teamsecrets import TeamSecrets 16 | 17 | 18 | TEAM_FILE = 'team.json' 19 | MEMBERS_FILE = 'members.json' 20 | SUBMISSIONS_FILE = 'submissions.csv' 21 | 22 | 23 | class Team(SerializableDict): 24 | def __init__(self, name=None, id=None): 25 | if name: 26 | id = self.name_to_id(name) 27 | self.update({'name': name}) 28 | if id: 29 | self.validate_id(id) 30 | self.id = id 31 | else: 32 | raise ValueError('Either name or id are required') 33 | 34 | super(Team, self).__init__() 35 | 36 | if self.exists(): 37 | self.validate() 38 | 39 | def dir(self): 40 | return SubRepo.get_path(self.id) 41 | 42 | def path(self): 43 | return os.path.join(self.dir(), TEAM_FILE) 44 | 45 | def save(self): 46 | if not self.exists(): 47 | os.makedirs(self.dir()) 48 | super(Team, self).save() 49 | 50 | def members(self): 51 | return TeamMembers(self) 52 | 53 | def submissions(self): 54 | return TeamSubmissions(self) 55 | 56 | @staticmethod 57 | def name_to_id(name): 58 | assert isinstance(name, text_type) 59 | sha = hashlib.sha256(name.encode('utf-8')).hexdigest() 60 | return sha[0:1] + '/' + sha[1:4] + '/' + sha[4:] 61 | 62 | @staticmethod 63 | def validate_id(id): 64 | assert isinstance(id, text_type) 65 | if not re.match(r'^[0-9a-f]/[0-9a-f]{3}/[0-9a-f]{60}$', id): 66 | raise ValueError('Invalid Team ID') 67 | 68 | @staticmethod 69 | def _binary_field(k): 70 | return k.endswith('_pk') 71 | 72 | def validate(self): 73 | expected_keys = {'name', 'countries', 'crypt_pk', 'sign_pk'} 74 | if set(self.keys()) != expected_keys: 75 | raise ValueError("Team should contain, and only contain: %s" % 76 | ', '.join(expected_keys)) 77 | 78 | assert isinstance(self['name'], text_type) 79 | if len(self['name']) > Settings.max_size_team_name: 80 | raise ValueError("Team name must have at most %d chars." % 81 | Settings.max_size_team_name) 82 | if self.name_to_id(self['name']) != self.id: 83 | raise ValueError("Team name does not match its ID") 84 | 85 | assert isinstance(self['countries'], list) 86 | if len(self['countries']) > Settings.max_size_team_countries: 87 | raise ValueError("Team must have at most %d countries." % 88 | Settings.max_size_team_countries) 89 | for country in self['countries']: 90 | if country not in valid_countries: 91 | raise ValueError("Must use 2-letter ISO country codes.") 92 | 93 | assert isinstance(self['crypt_pk'], bytes) 94 | if len(self['crypt_pk']) != pysodium.crypto_box_PUBLICKEYBYTES: 95 | raise ValueError("Team's crypt_pk has incorrect size") 96 | 97 | assert isinstance(self['sign_pk'], bytes) 98 | if len(self['sign_pk']) != pysodium.crypto_sign_PUBLICKEYBYTES: 99 | raise ValueError("Team's sign_pk has incorrect size") 100 | 101 | 102 | class TeamMembers(SerializableList): 103 | pretty_print = True 104 | 105 | def __init__(self, team): 106 | self.team = team 107 | self.team_dir = team.dir() 108 | super(TeamMembers, self).__init__() 109 | 110 | def path(self): 111 | return os.path.join(self.team_dir, MEMBERS_FILE) 112 | 113 | def projection(self, attr): 114 | return [member[attr] for member in self] 115 | 116 | def add(self, id=None, username=None): 117 | assert isinstance(id, int) or isinstance(id, long) 118 | assert isinstance(username, text_type) 119 | 120 | another_team = lookup_member(id=id) 121 | if another_team: 122 | if another_team != self.team: 123 | raise ValueError("User '%s' is already member of team '%s'" % 124 | (username, another_team['name'])) 125 | else: 126 | # do nothing, but do not fail if it is the same team 127 | return 128 | 129 | self.append({'id': id, 'username': username}) 130 | self.save() 131 | 132 | 133 | class TeamSubmissions(object): 134 | def __init__(self, team): 135 | self.team = team 136 | self.path = os.path.join(team.dir(), SUBMISSIONS_FILE) 137 | 138 | def submit(self, proof): 139 | assert isinstance(proof, bytes) 140 | with open(self.path, 'ab') as f: 141 | f.write(proof + b'\n') 142 | 143 | def challs(self): 144 | r = [] 145 | if os.path.exists(self.path): 146 | with open(self.path, 'rb') as f: 147 | for proof in f: 148 | r.append(proof_open(self.team, proof.strip())) 149 | if len(set(r)) != len(r): 150 | raise ValueError('Team submissions contain repeated challenges') 151 | return r 152 | 153 | 154 | def my_team(): 155 | return Team(id=TeamSecrets['id']) 156 | 157 | 158 | def all_teams(): 159 | root = SubRepo.get_path() 160 | for path, dirs, files in os.walk(root): 161 | if TEAM_FILE in files: 162 | assert path.startswith(root) 163 | id = path[len(root):].strip('/') 164 | yield Team(id=id) 165 | 166 | 167 | def lookup_member(id=None, username=None): 168 | if id: 169 | attr = 'id' 170 | value = id 171 | elif username: 172 | attr = 'username' 173 | value = username 174 | else: 175 | raise ValueError('Provide either an id or an username') 176 | 177 | for team in all_teams(): 178 | if value in team.members().projection(attr): 179 | return team 180 | 181 | return None 182 | -------------------------------------------------------------------------------- /frontend/js/challenges.js: -------------------------------------------------------------------------------- 1 | const ChallengeModal = Vue.component('challenge-modal', { 2 | template: ` 3 | 16 | `, 17 | props: ['challenge'], 18 | data: () => ({ 19 | loaded: false, 20 | descriptionMap: {} 21 | }), 22 | methods: { 23 | loadDescription: async function(challenge) { 24 | const lang = Cookies.get('lang').toLowerCase(); 25 | 26 | if (!this.descriptionMap[lang]) { 27 | this.descriptionMap[lang] = {}; 28 | } 29 | 30 | if (this.descriptionMap[lang][challenge.id]) { 31 | this.challenge.description = this.descriptionMap[lang][challenge.id]; 32 | return; 33 | } 34 | 35 | const challengeMd = await getChallengeDescription(this.challenge.id, lang); 36 | this.descriptionMap[lang][challenge.id] = converter.makeHtml(challengeMd); 37 | this.challenge.description = this.descriptionMap[lang][challenge.id]; 38 | }, 39 | }, 40 | mounted: async function() { 41 | $('.modal').modal(); 42 | this.loadDescription(this.challenge); 43 | }, 44 | watch: { 45 | challenge: function(challenge) { 46 | thisloaded = false 47 | this.loadDescription(challenge); 48 | } 49 | }, 50 | }) 51 | 52 | const ChallengeComponent = Vue.component('challenge-card', { 53 | template: ` 54 |
55 |
56 |
57 | {{challenge.title}} 58 |
{{$t('total-solves')}}: {{challenge.solves}}
59 |
{{$t('score')}}: {{challenge.points}}
60 |
61 |
62 |
63 | {{$t(challenge.optional)}} 64 | {{tag}} 65 |
66 |
67 |
68 |
69 | `, 70 | props: ['challenge', 'selectChallengeFunction'], 71 | methods: { 72 | selectChallenge: function() { 73 | this.selectChallengeFunction(this.challenge); 74 | } 75 | } 76 | }); 77 | 78 | const Challenges = Vue.component('challenges', { 79 | template: ` 80 |
81 | 82 |
83 | {{category}} 84 |
85 |
86 | 87 |
88 |
89 | 90 |
91 |
92 | `, 93 | data: () => ({ 94 | loaded: false, 95 | challenges: [], 96 | selectedChallenge: null, 97 | categories: [], 98 | filteredChallenges: [], 99 | selectedCategory: null 100 | }), 101 | props: ['hideTitle', 'submissions'], 102 | watch: { 103 | submissions: function(submissions) { 104 | this.loadSubmissions(submissions); 105 | } 106 | }, 107 | methods: { 108 | selectCategory: function(category) { 109 | if (this.selectedCategory === category) { 110 | this.selectedCategory = null; 111 | this.filteredChallenges = this.challenges; 112 | return; 113 | } 114 | 115 | this.selectedCategory = category; 116 | this.filteredChallenges = this.challenges.filter(challenge => challenge.tags.indexOf(category) >= 0); 117 | }, 118 | loadChallenges: async function(challengeList) { 119 | const mountChallPromise = (challId, index) => getChallenge(challId) 120 | .then(chall => this.challenges.splice(index, 0, chall)); 121 | 122 | const challPromiseMap = challList => challList 123 | .filter(chall => this.challenges.map(c => c.id).indexOf(chall) < 0) 124 | .map(mountChallPromise); 125 | 126 | await Promise.all(challPromiseMap(challengeList)); 127 | const categories = new Set(); 128 | this.challenges.forEach(challenge => { 129 | challenge.tags.forEach(category => { 130 | categories.add(category); 131 | }); 132 | }) 133 | this.categories = [...categories]; 134 | this.challenges = this.challenges.sort((challA, challB) => challA.title.localeCompare(challB.title)) 135 | this.filteredChallenges = this.challenges; 136 | 137 | if (!this.submissions && !this.submissionsPolling.isStarted) { 138 | this.submissionsPolling.start(); 139 | } 140 | this.loaded = true 141 | }, 142 | loadSubmissions: function(acceptedSubmissions) { 143 | const userTeam = Cookies.get('team'); 144 | let teamSolves = new Set(); 145 | solves = acceptedSubmissions.standings.reduce((reducer, { taskStats, team }) => { 146 | Object.keys(taskStats).forEach(chall => { 147 | reducer[chall]++ || (reducer[chall] = 1) 148 | }); 149 | 150 | if (userTeam && userTeam === team) { 151 | teamSolves = new Set(Object.keys(taskStats)); 152 | } 153 | return reducer; 154 | }, {}); 155 | 156 | this.challenges.forEach((challenge, index) => { 157 | this.challenges.splice(index, 1, Object.assign({}, challenge, { 158 | solves: solves[challenge.id] || 0, 159 | points: this.calculatePoints(solves[challenge.id]), 160 | solved: teamSolves.has(challenge.id) 161 | })); 162 | }); 163 | }, 164 | calculatePoints: function(solves) { 165 | const { K, V, minpts, maxpts } = settings['dynamic_scoring']; 166 | return parseInt(Math.max(minpts, Math.floor(maxpts - K * Math.log2(((solves + 1 || 1) + V)/(1 + V))))) 167 | }, 168 | openModal: function(challenge) { 169 | this.selectedChallenge = challenge; 170 | Vue.nextTick(() => { 171 | $('.modal').modal('open'); 172 | }) 173 | } 174 | }, 175 | mounted: async function() { 176 | this.loadChallenges(await getChallenges()); 177 | this.submissionsPolling = createPooling( 178 | getSolvedChallenges, 179 | this.loadSubmissions 180 | ); 181 | title = 'Challenges'; 182 | }, 183 | beforeDestroy: function() { 184 | this.challengesPolling.stop(); 185 | this.submissionsPolling.stop(); 186 | } 187 | }); 188 | -------------------------------------------------------------------------------- /nizkctf/proposal.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals, division, print_function,\ 4 | absolute_import 5 | import os 6 | import re 7 | import time 8 | import random 9 | import subprocess 10 | from .settings import Settings 11 | from .repohost import RepoHost 12 | from .subrepo import SubRepo 13 | from .team import Team, TEAM_FILE, SUBMISSIONS_FILE 14 | from .acceptedsubmissions import AcceptedSubmissions 15 | 16 | 17 | DIFF_MAX_SIZE = 5000 18 | PUSH_RETRIES = 10 19 | SLEEP_FACTOR = 0.2 20 | 21 | 22 | def consider_proposal(merge_info): 23 | # Clone official repository 24 | SubRepo.clone(fork=False) 25 | 26 | # Set CI user name / email 27 | setup_user_name_and_email() 28 | 29 | # Fetch proposal 30 | add_proposal_remote(merge_info) 31 | 32 | # Get commits between which to compute diffs 33 | commit = merge_info['source_commit'] 34 | merge_base = get_merge_base(commit) 35 | 36 | # Check if there is a single commit in the merge request 37 | check_rev_count(merge_base, commit) 38 | # Check if the diff is not too big 39 | check_diff_size(merge_base, commit) 40 | # Check if only allowed ops were done (add/modify) 41 | check_no_unallowed_ops(merge_base, commit) 42 | # Validate and get files added/modified 43 | added_file = get_added_file(merge_base, commit) 44 | modified_file = get_modified_file(merge_base, commit) 45 | 46 | if added_file and modified_file: 47 | raise ValueError("We only allow commits doing a single operation") 48 | 49 | changed_file = added_file or modified_file 50 | if not changed_file: 51 | raise ValueError("You managed to make a commit which does nothing") 52 | changed_basename = os.path.basename(changed_file) 53 | 54 | if added_file and changed_basename == TEAM_FILE: 55 | team_registration(merge_info, added_file) 56 | elif changed_file and changed_basename == SUBMISSIONS_FILE: 57 | flag_submission(merge_info, changed_file) 58 | else: 59 | raise ValueError("unrecognized operation") 60 | 61 | 62 | def team_registration(merge_info, added_file): 63 | # Checkout first to get the new team file 64 | commit = merge_info['source_commit'] 65 | checkout(commit) 66 | 67 | team = filename_owner(added_file) 68 | team.validate() 69 | 70 | def local_changes(): 71 | # Back to branch, do local modifications 72 | checkout('master') 73 | add_member(team, merge_info) 74 | 75 | local_changes() # Validate local modifications before accepting MR 76 | accept_proposal(merge_info) 77 | 78 | for _ in retry_push('Add member who registered team'): 79 | local_changes() 80 | 81 | 82 | def flag_submission(merge_info, modified_file): 83 | team = filename_owner(modified_file) 84 | challs_before = set(team.submissions().challs()) 85 | 86 | # Checkout to get the newly submitted challenge 87 | commit = merge_info['source_commit'] 88 | checkout(commit) 89 | 90 | challs_after = set(team.submissions().challs()) 91 | 92 | new_challs = challs_after - challs_before 93 | assert len(new_challs) == 1 94 | chall, = new_challs 95 | 96 | def local_changes(): 97 | # Back to branch, do local modifications 98 | checkout('master') 99 | add_member(team, merge_info) 100 | AcceptedSubmissions().add(chall, team) 101 | 102 | local_changes() # Validate local modifications before accepting MR 103 | accept_proposal(merge_info) 104 | 105 | for _ in retry_push('Accept challenge solution'): 106 | local_changes() 107 | 108 | 109 | def add_member(team, merge_info): 110 | team_dir = team.dir() 111 | if not os.path.exists(team_dir): 112 | os.makedirs(team_dir) 113 | 114 | team.members().add(id=merge_info['user_id'], 115 | username=merge_info['username']) 116 | 117 | 118 | def accept_proposal(merge_info, retries=PUSH_RETRIES): 119 | proj = Settings.submissions_project 120 | mr_id = merge_info['mr_id'] 121 | commit = merge_info['source_commit'] 122 | 123 | repohost = RepoHost.instance() 124 | for retry in range(1, retries + 1): 125 | try: 126 | repohost.mr_accept(proj, mr_id, commit) 127 | break 128 | except: 129 | time.sleep(SLEEP_FACTOR * retry * random.random()) 130 | if retry == retries: 131 | raise 132 | 133 | 134 | def retry_push(commit_message, retries=PUSH_RETRIES): 135 | for retry in range(1, retries + 1): 136 | try: 137 | checkout('master') 138 | SubRepo.git(['reset', '--hard', 'upstream/master']) 139 | SubRepo.pull() 140 | yield retry # do local modifications 141 | SubRepo.push(commit_message, merge_request=False) 142 | break 143 | except: 144 | time.sleep(SLEEP_FACTOR * retry * random.random()) 145 | if retry == retries: 146 | raise 147 | 148 | 149 | def filename_owner(filename): 150 | team_id, basename = os.path.split(filename) 151 | return Team(id=team_id) 152 | 153 | 154 | def add_proposal_remote(merge_info): 155 | url = merge_info['source_ssh_url'] 156 | SubRepo.git(['remote', 'add', 'proposal', url]) 157 | SubRepo.git(['fetch', '--all']) 158 | 159 | 160 | def setup_user_name_and_email(): 161 | ci_user_name = os.getenv('CI_USER_NAME') 162 | ci_user_email = os.getenv('CI_USER_EMAIL') 163 | if ci_user_name: 164 | SubRepo.git(['config', 'user.name', ci_user_name]) 165 | if ci_user_email: 166 | SubRepo.git(['config', 'user.email', ci_user_email]) 167 | 168 | 169 | def checkout(commit): 170 | SubRepo.git(['checkout', commit]) 171 | 172 | 173 | def get_added_file(src, dest): 174 | return get_file(src, dest, 'A', {TEAM_FILE, SUBMISSIONS_FILE}) 175 | 176 | 177 | def get_modified_file(src, dest): 178 | return get_file(src, dest, 'M', {SUBMISSIONS_FILE}) 179 | 180 | 181 | def get_file(src, dest, filt, whitelist): 182 | stats = diff_stats(src, dest, ['--diff-filter=' + filt]) 183 | if len(stats) == 0: 184 | return None 185 | if len(stats) != 1: 186 | raise ValueError("We only allow a single file to be added or modified " 187 | "per commit") 188 | 189 | stat, = stats 190 | lines_added, lines_removed, filename = stat 191 | if lines_removed != 0: 192 | raise ValueError("We do not allow lines to be removed from files") 193 | if lines_added != 1: 194 | raise ValueError("Changes can only add a single line to a file") 195 | 196 | check_whitelist(filename, whitelist) 197 | return filename 198 | 199 | 200 | def check_no_unallowed_ops(src, dest): 201 | stats = diff_stats(src, dest, ['--diff-filter=am']) 202 | if len(stats) != 0: 203 | raise ValueError("We only allow files to be added or modified") 204 | 205 | 206 | def check_whitelist(filename, whitelist): 207 | basename = os.path.basename(filename) 208 | if basename not in whitelist: 209 | raise ValueError("Filename '%s' not in the whitelist" % basename) 210 | 211 | 212 | def diff_stats(src, dest, args=[]): 213 | stats = SubRepo.git(['diff', '--numstat'] + args + [src, dest], 214 | stdout=subprocess.PIPE) 215 | lines = [re.split(r'\s+', line.strip(), 2) for line in 216 | stats.split('\n')] 217 | lines = [line for line in lines if line != ['']] 218 | return [(int(lines_added), int(lines_removed), filename) 219 | for lines_added, lines_removed, filename 220 | in lines] 221 | 222 | 223 | def check_rev_count(src, dest): 224 | revs = int(SubRepo.git(['rev-list', '--count', src+'...'+dest], 225 | stdout=subprocess.PIPE).strip()) 226 | 227 | if revs != 1: 228 | raise ValueError("We only accept a single commit per merge request") 229 | 230 | 231 | def check_diff_size(src, dest): 232 | diff = SubRepo.git(['diff', '--no-color', '-U0', src, dest], 233 | stdout=subprocess.PIPE) 234 | 235 | if len(diff) > DIFF_MAX_SIZE: 236 | raise ValueError("Diff size (%d bytes) is above the maximum permitted " 237 | "(%d bytes)" % (len(diff), DIFF_MAX_SIZE)) 238 | 239 | 240 | def get_merge_base(commit): 241 | merge_base = SubRepo.git(['merge-base', 'upstream/master', commit], 242 | stdout=subprocess.PIPE).strip() 243 | return merge_base 244 | -------------------------------------------------------------------------------- /ctf: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | from __future__ import unicode_literals, division, print_function,\ 5 | absolute_import 6 | import os 7 | import sys 8 | import codecs 9 | import argparse 10 | import getpass 11 | import base64 12 | import pysodium 13 | import traceback 14 | 15 | from nizkctf.cli import scoreboard, challenges, news, team, localserver 16 | from nizkctf.repohost import RepoHost 17 | from nizkctf.subrepo import SubRepo 18 | from nizkctf.challenge import Challenge, derive_keypair, random_salt 19 | from nizkctf.six import input, to_unicode, PY2 20 | from nizkctf.settings import Settings 21 | 22 | 23 | def ensure_unicode_locale(): 24 | if sys.stdout.encoding != 'UTF-8': 25 | sys.stderr.write('\033[1mWARNING\033[00m: This CTF accepts ' 26 | 'international characters in team names and\nproblem ' 27 | 'descriptions. You are currently not using a Unicode ' 28 | 'locale,\ntherefore you may experience random ' 29 | 'UnicodeEncodeError exceptions.\n\nPlease fix by ' 30 | 'changing to a Unicode locale, e.g.\n\n' 31 | ' export LC_ALL=en_US.UTF-8\n\n\n') 32 | 33 | 34 | def read_opt(msg, opts): 35 | while True: 36 | inp = input(msg) 37 | if inp.strip() in opts: 38 | return inp.strip() 39 | 40 | 41 | def read_auth(opt): 42 | while True: 43 | if opt == '1': 44 | print('Enter your username and password:') 45 | username = input('Username: ') 46 | password = getpass.getpass('Password: ') 47 | OTP = input('OTP (only if you use two-factor authentication): ') 48 | if OTP != "": 49 | RepoHost.login(username=username, password=password, OTP=OTP) 50 | else: 51 | RepoHost.login(username=username, password=password) 52 | return True 53 | elif opt == '2': 54 | print('Enter your auth token (with public_repo scope):') 55 | token = input('Token: ') 56 | RepoHost.login(token=token) 57 | return True 58 | 59 | 60 | def cmd_scoreboard(args): 61 | if args.pull: 62 | SubRepo.pull() 63 | 64 | ranking, submissions = scoreboard.rank() 65 | scoreboard.pprint(ranking, top=args.top, show_names=args.names) 66 | 67 | if args.chart: 68 | scoreboard.plot(ranking, submissions) 69 | 70 | 71 | def parse_countries(countries): 72 | if countries == '': 73 | return [] 74 | return [c.lower().strip() for c in countries.split(',')] 75 | 76 | 77 | def cmd_register(args): 78 | team.register(args.name, parse_countries(args.countries)) 79 | 80 | 81 | def cmd_login(args): 82 | if args.token: 83 | RepoHost.login(token=args.token) 84 | elif args.username and args.password: 85 | RepoHost.login(username=args.username, password=args.password) 86 | else: 87 | read_auth('1') 88 | print('Credentials stored') 89 | print('Cloning submissions repository') 90 | SubRepo.clone() 91 | 92 | 93 | def cmd_init(args): 94 | print('NIZKCTF initializing your environment.') 95 | print('First of all, we need your github/gitlab credentials:') 96 | print('[1] auth via username / password') 97 | print('[2] auth with personal access token (with public_repo scope)') 98 | opt = read_opt('>> ', {'1', '2'}) 99 | print('') 100 | 101 | read_auth(opt) 102 | print('') 103 | 104 | print('Cloning submissions repository') 105 | SubRepo.clone() 106 | 107 | print('Do you want to register a new team? [y/n]') 108 | opt = read_opt('>> ', {'y', 'n'}) 109 | if opt == 'y': 110 | print('Enter your team name:') 111 | team_name = input('>> ') 112 | print("Enter a comma-separated list of your member's countries.") 113 | print("Use 2-letter ISO country codes: http://flag-icon-css.lip.is") 114 | print("Example: For a team composed by brazilians and russians: BR,RU") 115 | print("(leave empty if you want to omit nationality)") 116 | team_countries = parse_countries(input('>> ')) 117 | team.register(team_name, team_countries) 118 | print('') 119 | 120 | print('We are all set!') 121 | 122 | 123 | def cmd_submit(args): 124 | print('Checking flag: %s' % args.flag) 125 | ret, msg = challenges.submit_flag(args.flag, args.chall) 126 | print(msg) 127 | if not ret: 128 | sys.exit(1) 129 | 130 | 131 | def cmd_challenges(args): 132 | challenges.pprint() 133 | 134 | 135 | def cmd_serve(args): 136 | localserver.main(port=args.port) 137 | 138 | 139 | def cmd_add_challenge(args): 140 | id = input('Challenge id (digits, letters, underscore): ').strip() 141 | title = input('Title: ').strip() 142 | description = input('Description: ').strip() 143 | if not Settings.dynamic_scoring: 144 | points = int(input('Points: ').strip()) 145 | tags = input('Tags (separate tags with space): ').strip().split() 146 | salt = input('Salt (empty string for random salt): ').strip() 147 | if salt == '': 148 | salt = random_salt() 149 | while True: 150 | level = input('Pwhash level (interactive, moderate, sensitive): ').strip().upper() 151 | try: 152 | opslimit = getattr(pysodium, 'crypto_pwhash_argon2id_OPSLIMIT_'+level) 153 | memlimit = getattr(pysodium, 'crypto_pwhash_argon2id_MEMLIMIT_'+level) 154 | break 155 | except: 156 | traceback.print_exc() 157 | flag = input('Flag: ').strip() 158 | 159 | pk, sk = derive_keypair(salt, opslimit, memlimit, flag) 160 | chall = Challenge(id=id) 161 | chall['id'] = id 162 | chall['title'] = title 163 | chall['description'] = description 164 | if not Settings.dynamic_scoring: 165 | chall['points'] = points 166 | chall['tags'] = tags 167 | chall['salt'] = salt 168 | chall['opslimit'] = opslimit 169 | chall['memlimit'] = memlimit 170 | chall['pk'] = pk 171 | chall.save() 172 | 173 | 174 | def cmd_news(args): 175 | if args.pull: 176 | SubRepo.pull() 177 | news.pprint(news.News(), team_only=args.team) 178 | 179 | 180 | def cmd_add_news(args): 181 | news.submit(args.msg, args.to) 182 | 183 | 184 | def main(): 185 | ensure_unicode_locale() 186 | if PY2: 187 | sys.argv = map(to_unicode, sys.argv) 188 | 189 | commands = { 190 | 'init': cmd_init, 191 | 'login': cmd_login, 192 | 'score': cmd_scoreboard, 193 | 'register': cmd_register, 194 | 'submit': cmd_submit, 195 | 'challs': cmd_challenges, 196 | 'serve': cmd_serve, 197 | 198 | 'add': cmd_add_challenge, 199 | 200 | 'news': cmd_news, 201 | 'add_news': cmd_add_news, 202 | } 203 | 204 | parser = argparse.ArgumentParser(description='nizk CTF cli') 205 | subparsers = parser.add_subparsers(help='command help', 206 | metavar='{init,login,score,register,' 207 | 'submit,challs,serve,news}') 208 | 209 | parser_init = subparsers.add_parser('init', help='init ctf environment') 210 | parser_init.set_defaults(command='init') 211 | 212 | parser_login = subparsers.add_parser('login', 213 | help='authenticate in gitlab/github') 214 | parser_login.set_defaults(command='login') 215 | parser_login.add_argument('--username', type=str, default=None, 216 | metavar='USERNAME', 217 | help='username for logging in') 218 | parser_login.add_argument('--password', type=str, default=None, 219 | metavar='PASSWORD', 220 | help='password for logging in') 221 | parser_login.add_argument('--token', type=str, default=None, 222 | metavar='TOKEN', 223 | help='use personal access token (with public_repo scope) instead of ' 224 | 'username/password') 225 | 226 | parser_score = subparsers.add_parser('score', help='scoreboard help') 227 | parser_score.set_defaults(command='score') 228 | parser_score.add_argument('--top', type=int, 229 | default=0, metavar='N', 230 | help='size of ranking to display') 231 | parser_score.add_argument('--pull', action='store_true', 232 | help='pull submissions before displaying scores') 233 | parser_score.add_argument('--names', action='store_true', 234 | help='display team names') 235 | parser_score.add_argument('--chart', action='store_true', 236 | help='display chart') 237 | 238 | parser_register = subparsers.add_parser('register', 239 | help='register a new team') 240 | parser_register.set_defaults(command='register') 241 | parser_register.add_argument('name', metavar='NAME', help='team name') 242 | parser_register.add_argument('countries', metavar='COUNTRIES', 243 | help='Comma-separated list of 2-letter ISO ' 244 | 'country codes (pass an empty string to ' 245 | 'omit nationality)') 246 | 247 | parser_submit = subparsers.add_parser('submit', help='submit a flag') 248 | parser_submit.set_defaults(command='submit') 249 | parser_submit.add_argument('flag', metavar='FLAG', help='flag') 250 | parser_submit.add_argument('--chall', type=str, default=None, 251 | metavar='CHALL_ID', help='challenge id') 252 | 253 | parser_challenges = subparsers.add_parser('challs', help='list challenges') 254 | parser_challenges.set_defaults(command='challs') 255 | 256 | parser_serve = subparsers.add_parser('serve', help='start a local server') 257 | parser_serve.set_defaults(command='serve') 258 | parser_serve.add_argument('--port', type=int, default=8000, 259 | metavar='PORT', help='port') 260 | 261 | parser_add_challenge = subparsers.add_parser('add') 262 | parser_add_challenge.set_defaults(command='add') 263 | 264 | parser_news = subparsers.add_parser('news', help='pull and show news') 265 | parser_news.set_defaults(command='news') 266 | parser_news.add_argument('--pull', action='store_true', 267 | help='pull news before displaying news') 268 | parser_news.add_argument('--team', action='store_true', 269 | help='show only team news') 270 | 271 | parser_add_news = subparsers.add_parser('add_news') 272 | parser_add_news.set_defaults(command='add_news') 273 | parser_add_news.add_argument('--msg', required=True, 274 | help='msg to be added') 275 | parser_add_news.add_argument('--to', help='team name') 276 | 277 | if len(sys.argv) == 1: 278 | parser.print_help() 279 | sys.exit(1) 280 | args = parser.parse_args() 281 | commands[args.command](args) 282 | 283 | 284 | if __name__ == '__main__': 285 | main() 286 | --------------------------------------------------------------------------------