diff --git a/.travis.yml b/.travis.yml index 2154ac0..5adb050 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,9 +19,9 @@ before_install: install: - "pip install -r src/requirements.txt" - - "python src/create_db.py" + - "python src/create_db.test.py" before_script: - black . --check script: - - flake8 . --count --max-line-length=88 --exclude="src/FlaskRTBCTF/utils/__init__.py" --show-source --statistics \ No newline at end of file + - flake8 . --count --max-line-length=88 --show-source --statistics \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 13be93d..75108c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ $ black . ``` ```bash -$ flake8 src/ ---max-line-length=88 --show-source --statistics +$ flake8 src/ --max-line-length=88 --show-source --statistics ``` if flake8 shows any errors or warnings, please fix the changes in a new commit and squash all the commits into one before submitting the PR. diff --git a/Dockerfile b/Dockerfile index 225088d..5ede1e1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,28 @@ -FROM python:3 +FROM python:3.8.2-alpine3.11 +MAINTAINER eshaan7bansal@gmail.com + +# Env +RUN export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@postgres:${DB_PORT}/${DB_NAME}" \ + && export REDIS_URL="redis://redis:6379/0" + +# update and install packages +RUN apk update \ + && apk add libpq postgresql-dev \ + && apk add build-base \ + && apk add --no-cache git libssl1.1 g++ make libffi-dev + +# Add a new low-privileged user +RUN adduser --shell /sbin/login www-data -DH + +# Install RTB-CTF-Framework WORKDIR /usr/src/app COPY src ./ -RUN pip install --no-cache-dir -r requirements.txt -EXPOSE 8080 -RUN chown -R 1001:1001 . -USER 1001 +RUN pip install --no-cache-dir -r requirements.txt \ + && chown -R www-data ./ + +USER www-data + +EXPOSE 8000 RUN chmod +x /usr/src/app/docker-entrypoint.sh ENTRYPOINT [ "/usr/src/app/docker-entrypoint.sh" ] \ No newline at end of file diff --git a/README.md b/README.md index 4749499..c286edd 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ Build Status - + Code style: black @@ -30,24 +30,17 @@ The main purpose of this project is to serve as a scoring engine and CTF manager ## Features -##### For CTF hosters -* A page to show relevant details about the machine such as name, IP, OS, points and difficulty level. +* Machines listing name, IP, OS, points and difficulty level. +* Challenges listing with title, description, URL, points. +* Totally configurable settings such running time, organization details, CTF name. * Automatic strong password for administrator * Well implemented controls for administrators providing features such as issuing notifications, database CRUD operations, full fledged logging, * Simple User Registration/login process, account management, Forgot password functionalities, * Flag submission (currently 2 flags: user and root), * Real time scoreboard tracking, +* Efficient caching so it's fast * Easily deployable on Heroku. -##### For Developers & Contributors -* Flask-blueprints for modularity and clean codebase, -* Flask-admin for Admin views and easy realtime management, -* Flask-SQLAlchemy for SQL models, -* Flask-login for session handling, -* Flask-wtf for responsive forms, -* Flask-mail for mail service, -* Flask-bcrypt for password hashing and security, - ## Build locally Please see [INSTALLATION.md](INSTALLATION.md). @@ -95,11 +88,6 @@ Please see [INSTALLATION.md](INSTALLATION.md). For further guidelines, Please refer to [CONTRIBUTING.md](CONTRIBUTING.md) -## Screenshots - -> Why look at static pictures, when you can use a demo ? Visit: . - - - - +## Live Demo +** Live Demo: (login with `admin:admin`) diff --git a/docker-compose.yml b/docker-compose.yml index 64139fa..09d3152 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,55 @@ version: "3" services: - rtbd: + rtbctf: build: . + container_name: rtb_gunicorn + restart: unless-stopped ports: - - 80:8080 - restart: unless-stopped \ No newline at end of file + - "80:8000" + expose: + - 8000 + environment: + - DEBUG=False + - SECRET_KEY=changeme + - DB_USER=eshaan + - DB_PASSWORD=eshaan + - DB_NAME=rtbctf + - DB_PORT=5432 + - WORKERS=8 + - ADMIN_PASS=admin + depends_on: + - postgres + - redis + + postgres: + image: library/postgres:12.1-alpine + container_name: rtb_postgres + restart: unless-stopped + expose: + - "5432" + environment: + - POSTGRES_USER=eshaan + - POSTGRES_PASSWORD=eshaan + - POSTGRES_DB=rtbctf + + redis: + image: redis:6.0-rc4-alpine + container_name: rtb_redis + restart: unless-stopped + expose: + - "6379" + + + # nginx: + # image: library/nginx:1.16.1-alpine + # container_name: rtb_nginx + # restart: unless-stopped + # hostname: nginx + # volumes: + # - ./rtb_nginx_http:/etc/nginx/conf.d/default.conf + # ports: + # - "80:80" + # - "443:443" + # depends_on: + # - rtbctf diff --git a/rtb_nginx_http b/rtb_nginx_http new file mode 100644 index 0000000..223a789 --- /dev/null +++ b/rtb_nginx_http @@ -0,0 +1,16 @@ +# the upstream component nginx needs to connect to +upstream flask { + server rtbctf:8000 fail_timeout=30s; +} + + +server { + listen 80; + + server_name rtbctf.com; + + location / { + proxy_pass http://localhost:8000/ + } + +} diff --git a/screenshots/home_ss.png b/screenshots/home_ss.png deleted file mode 100644 index 5687268..0000000 Binary files a/screenshots/home_ss.png and /dev/null differ diff --git a/screenshots/machine_ss.png b/screenshots/machine_ss.png deleted file mode 100644 index d933c62..0000000 Binary files a/screenshots/machine_ss.png and /dev/null differ diff --git a/screenshots/scoreboard_ss.png b/screenshots/scoreboard_ss.png deleted file mode 100644 index 1b8de1c..0000000 Binary files a/screenshots/scoreboard_ss.png and /dev/null differ diff --git a/src/FlaskRTBCTF/admin/views.py b/src/FlaskRTBCTF/admin/views.py index aeaa7cf..fd2eb71 100644 --- a/src/FlaskRTBCTF/admin/views.py +++ b/src/FlaskRTBCTF/admin/views.py @@ -6,11 +6,16 @@ from flask_admin.form import SecureForm from flask_admin.contrib.sqla import ModelView +from ..utils.cache import cache +from ..utils.helpers import clear_points_cache + class BaseModelView(ModelView): export_types = ("csv", "json") can_export = True form_base_class = SecureForm + column_display_pk = True # optional, but I like to see the IDs in the list + form_excluded_columns = ("created_on", "updated_on") def is_accessible(self): if not current_user.is_authenticated or not current_user.isAdmin: @@ -31,6 +36,7 @@ def _handle_view(self, name, **kwargs): class UserAdminView(BaseModelView): + can_view_details = True column_exclude_list = ("password",) form_exclude_list = ("password",) column_searchable_list = ("username", "email") @@ -40,8 +46,14 @@ def create_view(self): flash("Please use registration form for creating new users.", "info") return redirect("/admin/user") + @staticmethod + def after_model_delete(model): + cache.clear(key="scoreboard") + return + class MachineAdminView(BaseModelView): + can_view_details = True column_searchable_list = ("name", "ip") @expose("/new/") @@ -54,6 +66,62 @@ def edit_view(self): return redirect(url_for("ctf.edit_machine", id=id)) +class ChallengeAdminView(BaseModelView): + can_view_details = True + column_searchable_list = ("title", "url") + form_choices = { + "difficulty": [ + ("easy", "Easy"), + ("medium", "Medium"), + ("hard", "Hard"), + ("insane", "Insane"), + ] + } + + @staticmethod + def after_model_change(form, model, is_created): + cache.delete(key="challenges") + return + + @staticmethod + def after_model_delete(model): + cache.delete(key="challenges") + return + + +class UserChallengeAdminView(BaseModelView): + column_filters = ("completed",) + column_list = ("user_id", "challenge_id", "completed") + + @staticmethod + def after_model_change(form, model, is_created): + if form.completed != model.completed: + clear_points_cache(userId=model.user_id, mode="c") + return + + @staticmethod + def after_model_delete(model): + clear_points_cache(userId=model.user_id, mode="c") + return + + +class UserMachineAdminView(BaseModelView): + column_filters = ("owned_user", "owned_root") + column_list = ("user_id", "machine_id", "owned_user", "owned_root") + + @staticmethod + def after_model_change(form, model, is_created): + if (form.owned_user != model.owned_user) or ( + form.owned_root != model.owned_root + ): + clear_points_cache(userId=model.user_id, mode="m") + return + + @staticmethod + def after_model_delete(model): + clear_points_cache(userId=model.user_id, mode="m") + return + + class NotificationAdminView(BaseModelView): column_searchable_list = ("title",) - form_excluded_columns = ("timestamp",) diff --git a/src/FlaskRTBCTF/ctf/forms.py b/src/FlaskRTBCTF/ctf/forms.py index 17e2696..64ba097 100644 --- a/src/FlaskRTBCTF/ctf/forms.py +++ b/src/FlaskRTBCTF/ctf/forms.py @@ -2,7 +2,7 @@ from wtforms import StringField, SubmitField, HiddenField, RadioField from wtforms.validators import DataRequired, Length, ValidationError, IPAddress from wtforms.fields.html5 import IntegerField -from .models import Machine +from .models import Machine, Challenge class MachineForm(FlaskForm): @@ -23,7 +23,7 @@ class MachineForm(FlaskForm): ip = StringField( "IPv4 address of machine", validators=[DataRequired(), IPAddress()] ) - hardness = RadioField( + difficulty = RadioField( "Difficuly Level", validators=[DataRequired()], choices=( @@ -67,3 +67,16 @@ def validate_root_hash(self, root_hash): pass else: raise ValidationError("Incorrect Root Hash.") + + +class ChallengeFlagForm(FlaskForm): + challenge_id = HiddenField("Challenge ID", validators=[DataRequired()]) + flag = StringField("Flag", validators=[DataRequired(), Length(min=4)]) + submit_flag = SubmitField("Submit") + + def validate_flag(self, flag): + ch = Challenge.query.get(int(self.challenge_id.data)) + if not ch: + raise ValidationError("No challenge with that ID exists") + elif ch.flag != str(flag.data): + raise ValidationError("Incorrect flag.") diff --git a/src/FlaskRTBCTF/ctf/models.py b/src/FlaskRTBCTF/ctf/models.py index 812bb85..0634557 100644 --- a/src/FlaskRTBCTF/ctf/models.py +++ b/src/FlaskRTBCTF/ctf/models.py @@ -1,22 +1,157 @@ -from FlaskRTBCTF.utils import db, cache +from sqlalchemy.orm import joinedload -# Machine Table +from FlaskRTBCTF.utils.models import db, TimeMixin, ReprMixin +from FlaskRTBCTF.utils.cache import cache -class Machine(db.Model): +# Machine Table +class Machine(TimeMixin, ReprMixin, db.Model): __tablename__ = "machine" + __repr_fields__ = ( + "name", + "os", + ) id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64), nullable=False) + name = db.Column(db.String(64), nullable=False, unique=True) user_hash = db.Column(db.String(32), nullable=False) root_hash = db.Column(db.String(32), nullable=False) user_points = db.Column(db.Integer, default=0) root_points = db.Column(db.Integer, default=0) os = db.Column(db.String, nullable=False, default="linux") ip = db.Column(db.String(64), nullable=False) - hardness = db.Column(db.String, nullable=False, default="Easy") + difficulty = db.Column(db.String, nullable=False, default="Easy") @staticmethod - @cache.cached(timeout=3600, key_prefix="machines") + @cache.cached(timeout=3600 * 3, key_prefix="machines") def get_all(): - _machines = Machine.query.all() - return _machines + return Machine.query.all() + + +# UserMachine: N to N relationship +class UserMachine(TimeMixin, db.Model): + __tablename__ = "user_machine" + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id"), + nullable=False, + primary_key=True, + index=True, + ) + machine_id = db.Column( + db.Integer, + db.ForeignKey("machine.id"), + nullable=False, + primary_key=True, + index=True, + ) + owned_user = db.Column(db.Boolean, nullable=False, default=False) + owned_root = db.Column(db.Boolean, nullable=False, default=False) + + @classmethod + @cache.memoize(timeout=3600 * 3) + def completed_machines(cls, user_id): + completed = dict() + _ids1 = ( + cls.query.with_entities(cls.machine_id) + .filter_by(user_id=user_id, owned_user=True) + .all() + ) + _ids2 = ( + cls.query.with_entities(cls.machine_id) + .filter_by(user_id=user_id, owned_root=True) + .all() + ) + completed["user"] = [int(id[0]) for id in _ids1] + completed["root"] = [int(id[0]) for id in _ids2] + return completed + + +# Tag Model +class Tag(ReprMixin, db.Model): + __tablename__ = "tag" + __repr_fields__ = ("label",) + id = db.Column(db.Integer, primary_key=True) + label = db.Column(db.String(32), nullable=False) + color = db.Column(db.String(16), nullable=False) + + +# Tags table +tags = db.Table( + "tags", + db.Column("tag_id", db.Integer, db.ForeignKey("tag.id"), primary_key=True), + db.Column( + "challenge_id", db.Integer, db.ForeignKey("challenge.id"), primary_key=True + ), +) + + +# Challenges Model +class Challenge(TimeMixin, ReprMixin, db.Model): + __tablename__ = "challenge" + __repr_fields__ = ("title", "category") + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(64), nullable=False, unique=True) + description = db.Column(db.TEXT, nullable=True) + flag = db.Column(db.TEXT, nullable=False) + points = db.Column(db.Integer, nullable=False, default=0) + url = db.Column(db.TEXT, nullable=True) + difficulty = db.Column(db.String, nullable=True) + + category_id = db.Column(db.Integer, db.ForeignKey("category.id"), nullable=False) + category = db.relationship("Category", backref=db.backref("challenges", lazy=True)) + + tags = db.relationship( + "Tag", + secondary=tags, + lazy="subquery", + backref=db.backref("challenges", lazy="noload"), + ) + + +# UserChallenge: N to N relationship +class UserChallenge(TimeMixin, db.Model): + __tablename__ = "user_challenge" + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id"), + nullable=False, + primary_key=True, + index=True, + ) + challenge_id = db.Column( + db.Integer, + db.ForeignKey("challenge.id"), + nullable=False, + primary_key=True, + index=True, + ) + completed = db.Column(db.Boolean, nullable=False, default=False) + + @classmethod + @cache.memoize(timeout=3600 * 3) + def completed_challenges(cls, user_id): + _ids = ( + cls.query.with_entities(cls.challenge_id) + .filter_by(user_id=user_id, completed=True) + .all() + ) + _ids = [int(id[0]) for id in _ids] + return _ids + + +# Category Model +class Category(ReprMixin, db.Model): + __tablename__ = "category" + __repr_fields__ = ("name",) + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(32), nullable=False) + + @staticmethod + @cache.cached(timeout=3600 * 3, key_prefix="challenges") + def get_challenges(): + categories = ( + Category.query.options(joinedload("challenges")) + .filter(Category.challenges) + .all() + ) + return categories diff --git a/src/FlaskRTBCTF/ctf/routes.py b/src/FlaskRTBCTF/ctf/routes.py index b901293..24581d5 100644 --- a/src/FlaskRTBCTF/ctf/routes.py +++ b/src/FlaskRTBCTF/ctf/routes.py @@ -5,98 +5,102 @@ from flask import Blueprint, render_template, flash, request, redirect, url_for from flask_login import current_user, login_required - from FlaskRTBCTF.users.models import User, Logs -from FlaskRTBCTF.utils import db, cache, is_past_running_time, admin_only -from .models import Machine -from .forms import UserHashForm, RootHashForm, MachineForm +from FlaskRTBCTF.utils import ( + db, + cache, + is_past_running_time, + admin_only, + clear_points_cache, +) +from .models import Machine, Category, UserChallenge, UserMachine +from .forms import UserHashForm, RootHashForm, MachineForm, ChallengeFlagForm ctf = Blueprint("ctf", __name__) # Scoreboard - - @ctf.route("/scoreboard") @cache.cached(timeout=120, key_prefix="scoreboard") def scoreboard(): - users_scores = ( - User.query.with_entities(User.username, User.points) - .order_by(User.points.desc()) - .all() - ) + usersScores = User.query.all() + usersScores.sort(reverse=True, key=lambda user: user.points(id=user.id)) - return render_template("scoreboard.html", scores=users_scores) + return render_template("scoreboard.html", scores=usersScores) # Machines Info - - @ctf.route("/machines", methods=["GET", "POST"]) @login_required def machines(): userHashForm = UserHashForm() rootHashForm = RootHashForm() - boxes = Machine.get_all() - past_running_time = is_past_running_time() + is_finished = is_past_running_time() if request.method == "GET": + boxes = Machine.get_all() + completed = UserMachine.completed_machines(user_id=current_user.id) log = Logs.query.get(current_user.id) - # check if it is the first visit to machine page for user if log.visitedMachine is False: log.visitedMachine = True log.machineVisitTime = datetime.utcnow() db.session.commit() + return render_template( + "machines.html", + boxes=boxes, + completed=completed, + is_finished=is_finished, + userHashForm=userHashForm, + rootHashForm=rootHashForm, + ) + else: - if past_running_time: + if is_finished: flash("Sorry! CTF has ended.", "danger") return redirect(url_for("ctf.machines")) - """ - Todo: Get Object from UserMachine Model, dummy object given below - """ - # user_machine: object = { - # "machine_id": 1, - # "user_id": 1, - # "owned_user": False, - # "owned_root": False, - # } + machine_id = int(userHashForm.machine_id.data or rootHashForm.machine_id.data) - # if user_machine.owned_user: - # flash("You already own User.", "success") - # return redirect(url_for("ctf.machines")) + user_machine = UserMachine.query.filter_by( + user_id=current_user.id, machine_id=machine_id + ).first() - # elif user_machine.owned_root: - # flash("You already own System.", "success") - # return redirect(url_for("ctf.machines")) + if not user_machine: + user_machine = UserMachine(user_id=current_user.id, machine_id=machine_id) + db.session.add(user_machine) + db.session.commit() if userHashForm.submit_user_hash.data and userHashForm.validate_on_submit(): - box = Machine.query.get(int(userHashForm.machine_id.data)) - # user_machine.owned_user = True - current_user.points += box.user_points + if user_machine.owned_user: + flash("You already own User.", "success") + return redirect(url_for("ctf.machines")) + + user_machine.owned_user = True log = Logs.query.get(current_user.id) log.userSubmissionIP = request.access_route[0] log.userSubmissionTime = datetime.utcnow() log.userOwnTime = str(log.userSubmissionTime - log.machineVisitTime) db.session.commit() - cache.delete(key="scoreboard") + clear_points_cache(userId=current_user.id, mode="m") flash("Congrats! correct user hash.", "success") elif rootHashForm.submit_root_hash.data and rootHashForm.validate_on_submit(): - box = Machine.query.get(int(rootHashForm.machine_id.data)) - # user_machine.owned_root = True - current_user.points += box.root_points + if user_machine.owned_root: + flash("You already own System.", "success") + return redirect(url_for("ctf.machines")) + + user_machine.owned_root = True log = Logs.query.get(current_user.id) log.rootSubmissionIP = request.access_route[0] log.rootSubmissionTime = datetime.utcnow() log.rootOwnTime = str(log.rootSubmissionTime - log.machineVisitTime) db.session.commit() - cache.delete(key="scoreboard") + clear_points_cache(userId=current_user.id, mode="m") flash("Congrats! correct root hash.", "success") else: @@ -106,15 +110,8 @@ def machines(): return redirect(url_for("ctf.machines")) - return render_template( - "machines.html", - boxes=boxes, - past_running_time=past_running_time, - userHashForm=userHashForm, - rootHashForm=rootHashForm, - ) - +# New machine form @ctf.route("/machines/new", methods=["GET", "POST"]) @admin_only def new_machine(): @@ -137,6 +134,7 @@ def new_machine(): return redirect(request.url) +# Edit machine form @ctf.route("/machines/edit/", methods=["GET", "POST"]) @admin_only def edit_machine(id): @@ -156,3 +154,51 @@ def edit_machine(id): else: flash(form.errors, "danger") return redirect(request.url) + + +# Challenges Info +@ctf.route("/challenges", methods=["GET", "POST"]) +@login_required +def challenges(): + form = ChallengeFlagForm() + + if request.method == "GET": + categories = Category.get_challenges() + completed = UserChallenge.completed_challenges(user_id=current_user.id) + + return render_template( + "challenges.html", + categories=categories, + completed=completed, + form=form, + is_finished=is_past_running_time(), + ) + + else: + if is_past_running_time(): + flash("Sorry! CTF has ended.", "danger") + + elif form.validate_on_submit(): + ch_id = int(form.challenge_id.data) + user_ch = UserChallenge.query.filter_by( + user_id=current_user.id, challenge_id=ch_id + ).first() + if not user_ch: + user_ch = UserChallenge(user_id=current_user.id, challenge_id=ch_id) + db.session.add(user_ch) + elif user_ch.completed: + flash( + "You've already submitted the flag for this challenge.", "success" + ) + return redirect(request.url) + + user_ch.completed = True + db.session.commit() + clear_points_cache(userId=current_user.id, mode="c") + flash("Congrats! correct flag.", "success") + + else: + err = ", ".join(*form.errors.values()) + flash(err, "danger") + + return redirect(url_for("ctf.challenges")) diff --git a/src/FlaskRTBCTF/main/forms.py b/src/FlaskRTBCTF/main/forms.py index 2ff4150..42d024d 100644 --- a/src/FlaskRTBCTF/main/forms.py +++ b/src/FlaskRTBCTF/main/forms.py @@ -39,17 +39,17 @@ def setup(self): db.session.commit() + cache.delete(key="past_running_time") + cache.delete(key="settings") + return redirect(url_for("main.setup", step=3)) + except SQLAlchemyError: db.session.rollback() flash("Transaction failed. Please try again.", "danger") return redirect(url_for("main.setup"), step=2) - finally: - cache.delete(key="past_running_time") - cache.delete(key="settings") - return redirect(url_for("main.setup", step=3)) - else: + flash("Form validation failed. Please try again.", "danger") return redirect(url_for("main.setup", step=2)) diff --git a/src/FlaskRTBCTF/main/models.py b/src/FlaskRTBCTF/main/models.py index 43409c7..7dd15f5 100644 --- a/src/FlaskRTBCTF/main/models.py +++ b/src/FlaskRTBCTF/main/models.py @@ -5,28 +5,23 @@ from sqlalchemy.ext.hybrid import hybrid_property -from FlaskRTBCTF.utils import db, cache +from FlaskRTBCTF.utils.models import db, TimeMixin, ReprMixin +from FlaskRTBCTF.utils.cache import cache -# Notifications Table - - -class Notification(db.Model): +# Notifications Model +class Notification(TimeMixin, ReprMixin, db.Model): __tablename__ = "notification" + __repr_fields__ = ("title",) id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(64), nullable=False) body = db.Column(db.TEXT(), nullable=False) - timestamp = db.Column(db.DateTime, default=datetime.utcnow) - - def __repr__(self): - return f"Notif('{self.title}', '{self.body}')" - - -# Settings Table -class Settings(db.Model): +# Settings Model +class Settings(ReprMixin, db.Model): __tablename__ = "settings" + __repr_fields__ = ("ctf_name", "organization_name") id = db.Column(db.Integer, primary_key=True) dummy = db.Column(db.Boolean, nullable=False, default=True) ctf_name = db.Column(db.String(64), nullable=False, default="RootTheBox CTF") @@ -42,7 +37,7 @@ class Settings(db.Model): to_time = db.Column(db.Time, nullable=False, default=time()) @staticmethod - @cache.cached(timeout=3600 * 3, key_prefix="settings") + @cache.cached(timeout=3600 * 6, key_prefix="settings") def get_settings(): return Settings.query.get(1) @@ -54,27 +49,20 @@ def running_time_from(self): def running_time_to(self): return datetime.combine(self.to_date, self.to_time) - def __repr__(self): - return f"CTF('{self.ctf_name},'{self.organization_name}')" - -# Websites Table - - -class Website(db.Model): +# Websites Model +class Website(ReprMixin, db.Model): __tablename__ = "website" + __repr_fields__ = ("id", "name", "url") id = db.Column(db.Integer, primary_key=True) url = db.Column( - db.TEXT(), nullable=False, default="https://Abs0lut3Pwn4g3.github.io/" - ) - name = db.Column( - db.TEXT(), nullable=False, default="Official Abs0lut3Pwn4g3 Website" + db.TEXT(), + nullable=False, + default="https://github.com/Abs0lut3Pwn4g3/RTB-CTF-Framework", ) + name = db.Column(db.TEXT(), nullable=False, default="Source code on GitHub") @staticmethod @cache.cached(timeout=3600 * 6, key_prefix="websites") def get_websites(): return Website.query.all() - - def __repr__(self): - return f"Website('{self.name}','{self.url}')" diff --git a/src/FlaskRTBCTF/main/routes.py b/src/FlaskRTBCTF/main/routes.py index 913affd..dd3dbc1 100644 --- a/src/FlaskRTBCTF/main/routes.py +++ b/src/FlaskRTBCTF/main/routes.py @@ -1,5 +1,4 @@ from flask import render_template, Blueprint, redirect, url_for, request, flash -from flask_login import login_required from .models import Notification, Settings, Website from .forms import SettingsForm, WebsiteForm @@ -8,20 +7,23 @@ main = Blueprint("main", __name__) -""" Index page """ +""" Before app request processor """ -@main.before_request +@main.before_app_request def needs_setup(): settings = Settings.get_settings() if settings.dummy: - if request.endpoint not in ("main.setup", "users.login"): + if request.endpoint not in ("main.setup", "users.login", "static"): flash("Please setup the CTF, before accessing any routes.", "info") return redirect(url_for("main.setup")) else: return +""" Index page """ + + @main.route("/") @main.route("/home") def home(): @@ -36,13 +38,12 @@ def home(): @main.route("/notifications") def notifications(): - notifs = Notification.query.order_by(Notification.timestamp.desc()).all() + notifs = Notification.query.order_by(Notification.updated_on.desc()).all() return render_template("notifications.html", title="Notifications", notifs=notifs) @main.route("/setup", methods=["GET", "POST"]) -@login_required @admin_only def setup(): website_form_data = {"names": list(), "urls": list()} diff --git a/src/FlaskRTBCTF/static/main.css b/src/FlaskRTBCTF/static/main.css index 131bf8e..45a0400 100644 --- a/src/FlaskRTBCTF/static/main.css +++ b/src/FlaskRTBCTF/static/main.css @@ -190,4 +190,23 @@ ul.list-group a.list-group-item { .active .bs-stepper-circle { background-color: var(--light-color) !important; color: var(--dark-color) !important; +} + +/* challenges.html */ + +.tags-container { + display: flex; + justify-items: center; + align-items: center; +} + +.tags-container .tag { + margin: 4px 4px; + border-radius: 2px; + padding: 1px 2.5px; +} + +.card-top { + display: flex !important; + justify-content: space-between; } \ No newline at end of file diff --git a/src/FlaskRTBCTF/static/main.js b/src/FlaskRTBCTF/static/main.js index 3e6b361..1b3acbc 100644 --- a/src/FlaskRTBCTF/static/main.js +++ b/src/FlaskRTBCTF/static/main.js @@ -7,12 +7,27 @@ $(document).ready( function() { } ); - // modal - $('#m-form').on('show.bs.modal', function (event) { + // machine user hash submission modal + $('#m-form-user').on('show.bs.modal', function (event) { const button = $(event.relatedTarget); const boxId = button.data('boxid'); // Extract info from data-* attributes var modal = $(this); modal.find('#machine-id-user').val(boxId); + }) + + // machine root hash submission modal + $('#m-form-root').on('show.bs.modal', function (event) { + const button = $(event.relatedTarget); + const boxId = button.data('boxid'); // Extract info from data-* attributes + var modal = $(this); modal.find('#machine-id-root').val(boxId); }) + + // challenge flag submission + $('#c-form').on('show.bs.modal', function (event) { + const button = $(event.relatedTarget); + const challengeId = button.data('chid'); // Extract info from data-* attributes + $(this).find('#challenge-id').val(challengeId); + }) + }); \ No newline at end of file diff --git a/src/FlaskRTBCTF/templates/challenges.html b/src/FlaskRTBCTF/templates/challenges.html new file mode 100644 index 0000000..319185b --- /dev/null +++ b/src/FlaskRTBCTF/templates/challenges.html @@ -0,0 +1,92 @@ +{% extends "layout.html" %} +{% from "forms.html" import challenge_flag_submission_form %} +{% from "macros.html" import render_modal %} +{% block content %} + +{% if current_user.isAdmin %} +Add new +{% endif %} + + +{% for category in categories %} + +

{{ category.name }}

+ + {% with challenges = category.challenges %} + {% for chal in challenges %} +
+ +
+ + + + {{ chal['title'] }} + + + {% if chal['id'] in completed %} + + {% elif is_finished %} + + {% else %} + + + + + {% endif %} + + + + + +
+ + +
+
+ +
#{{ chal['id'] }}
+
+  - {{ chal['updated_on'].strftime("%Y-%m-%d %I:%M %p %Z") }} +
+
+ + {% for tag in chal['tags'] %} + {{ tag['label'] }} + + {% endfor %} + +
+
+

{{ chal['description'] |e }}

+
+
+
+ + {{ chal['url'] }} +
+
{{ chal['points'] }} points
+
{{ chal['difficulty'] }}
+
+
+ + +
+
+ +
+ + {% endfor %} + {% endwith %} + +{% endfor %} + + + +{{ render_modal( + modalId="c-form", title="Submit Flag", form_macro1=challenge_flag_submission_form, form1=form + ) +}} + +{% endblock content %} \ No newline at end of file diff --git a/src/FlaskRTBCTF/templates/forms.html b/src/FlaskRTBCTF/templates/forms.html index 2dab761..2743131 100644 --- a/src/FlaskRTBCTF/templates/forms.html +++ b/src/FlaskRTBCTF/templates/forms.html @@ -167,3 +167,25 @@ {% endmacro %} + + + +{% macro challenge_flag_submission_form(form) %} + +
+ {{ form.csrf_token() }} + {{ form.challenge_id(id="challenge-id") }} +
+ {{ form.flag.label(class="form-control-label") }} + {{ form.flag( + class="ml-3 mr-3 is-invalid", + size=28, + minlength=4, + autocomplete="off" + ) + }} + {{ form.submit_flag(class="btn btn-sm btn-dark btn-hover-red") }} +
+
+ +{% endmacro %} diff --git a/src/FlaskRTBCTF/templates/layout.html b/src/FlaskRTBCTF/templates/layout.html index f5e4884..488bc29 100644 --- a/src/FlaskRTBCTF/templates/layout.html +++ b/src/FlaskRTBCTF/templates/layout.html @@ -37,6 +37,7 @@ Notifications Scoreboard Machines + Challenges {% endfor %} - - - - -{% endblock content %} - + +{{ render_modal( + modalId="m-form-user", title="Submit User Flag", form_macro1=user_hash_form_macro, form1=userHashForm +) +}} +{{ render_modal( + modalId="m-form-root", title="Submit Root Flag", form_macro1=root_hash_form_macro, form1=rootHashForm +) +}} +{% endblock content %} \ No newline at end of file diff --git a/src/FlaskRTBCTF/templates/macros.html b/src/FlaskRTBCTF/templates/macros.html index e51b0bb..8b40a00 100644 --- a/src/FlaskRTBCTF/templates/macros.html +++ b/src/FlaskRTBCTF/templates/macros.html @@ -36,14 +36,35 @@ {{ field.label() }} {% if field.errors %} - {{ field(size=size, maxlength=size, class="m-auto", autocomplete="off") }} + {{ field(size=size, class="m-auto", autocomplete="off") }}
{% for error in field.errors %} {{ error }} {% endfor %}
{% else %} - {{ field(size=size, maxlength=size, class="m-auto", autocomplete="off") }} + {{ field(size=size, class="m-auto", autocomplete="off") }} {% endif %} +{% endmacro %} + + + +{% macro render_modal(modalId, title, form_macro1, form1, form_macro2=None, form2=None) %} + + + {% endmacro %} \ No newline at end of file diff --git a/src/FlaskRTBCTF/templates/new_machine.html b/src/FlaskRTBCTF/templates/new_machine.html index de85502..2045c2a 100644 --- a/src/FlaskRTBCTF/templates/new_machine.html +++ b/src/FlaskRTBCTF/templates/new_machine.html @@ -24,7 +24,7 @@ {{ inline_field(field=form.root_points, size=5) }}
- {{ form_select_field(field=form.hardness) }} + {{ form_select_field(field=form.difficulty) }}
{{ form.submit(class="btn btn-dark btn-hover-red") }} diff --git a/src/FlaskRTBCTF/templates/notifications.html b/src/FlaskRTBCTF/templates/notifications.html index a4e722f..76806b3 100644 --- a/src/FlaskRTBCTF/templates/notifications.html +++ b/src/FlaskRTBCTF/templates/notifications.html @@ -9,7 +9,7 @@

{{ notif['title'] }}

- Notification #{{ notif['id'] }} - posted at {{ notif['timestamp'].strftime("%Y-%m-%d %I:%M %p") }} UTC + Notification #{{ notif['id'] }} - posted at {{ notif['updated_on'].strftime("%Y-%m-%d %I:%M %p %Z") }}


{{ notif['body'] }}

diff --git a/src/FlaskRTBCTF/templates/scoreboard.html b/src/FlaskRTBCTF/templates/scoreboard.html index 165206a..bf17caa 100644 --- a/src/FlaskRTBCTF/templates/scoreboard.html +++ b/src/FlaskRTBCTF/templates/scoreboard.html @@ -16,19 +16,11 @@

Scoreboard

{% for score in scores %} - {% if ( loop.index % 2 != 0 ) %} - - {{ loop.index }} - {{ score[0] }} - {{ score[1] }} - - {% else %} - - {{ loop.index }} - {{ score[0] }} - {{ score[1] }} - - {% endif %} + + {{ loop.index }} + {{ score.username }} + {{ score.points(id=score.id) }} + {% endfor %} diff --git a/src/FlaskRTBCTF/users/models.py b/src/FlaskRTBCTF/users/models.py index 58cc8c2..3721aaf 100644 --- a/src/FlaskRTBCTF/users/models.py +++ b/src/FlaskRTBCTF/users/models.py @@ -4,7 +4,10 @@ from flask import current_app from flask_login import UserMixin -from FlaskRTBCTF import db, login_manager +from ..ctf.models import UserChallenge, Challenge, UserMachine, Machine +from ..utils.models import db +from ..utils.cache import cache +from ..utils.login_manager import login_manager @login_manager.user_loader @@ -12,9 +15,7 @@ def load_user(user_id): return User.query.get(int(user_id)) -# User Table - - +# User Model class User(db.Model, UserMixin): __tablename__ = "user" id = db.Column(db.Integer, primary_key=True) @@ -22,7 +23,6 @@ class User(db.Model, UserMixin): email = db.Column(db.String(88), unique=True, nullable=False) password = db.Column(db.String(64), nullable=False) isAdmin = db.Column(db.Boolean, default=False) - points = db.Column(db.Integer, nullable=False, default=0) logs = db.relationship("Logs", backref="user", lazy=True, uselist=False) def get_reset_token(self, expires_sec=1800): @@ -38,13 +38,37 @@ def verify_reset_token(token): return None return User.query.get(user_id) + @staticmethod + @cache.memoize(timeout=3600 * 3) + def points(id): + challenge_ids = UserChallenge.completed_challenges(user_id=id) + machine_ids = UserMachine.completed_machines(user_id=id) + points = 0 + for id in challenge_ids: + points += ( + Challenge.query.with_entities(Challenge.points) + .filter_by(id=id) + .scalar() + ) + for id in machine_ids["user"]: + points += ( + Machine.query.with_entities(Machine.user_points) + .filter_by(id=id) + .scalar() + ) + for id in machine_ids["root"]: + points += ( + Machine.query.with_entities(Machine.root_points) + .filter_by(id=id) + .scalar() + ) + return points + def __repr__(self): return f"User('{self.username}', '{self.email}'))" -# Logging Table - - +# User's Logs Model class Logs(db.Model): __tablename__ = "logs" user_id = db.Column( diff --git a/src/FlaskRTBCTF/utils/__init__.py b/src/FlaskRTBCTF/utils/__init__.py index 76ad020..272bfe0 100644 --- a/src/FlaskRTBCTF/utils/__init__.py +++ b/src/FlaskRTBCTF/utils/__init__.py @@ -1,4 +1,4 @@ -from .models import db +# flake8: noqa from .admin_manager import admin_manager from .bcrypt import bcrypt from .cache import cache @@ -8,6 +8,8 @@ handle_secret_key, is_past_running_time, inject_app_context, + clear_points_cache, ) from .login_manager import login_manager, admin_only from .mail import mail, send_reset_email +from .models import db diff --git a/src/FlaskRTBCTF/utils/helpers.py b/src/FlaskRTBCTF/utils/helpers.py index 2741a26..2c43301 100644 --- a/src/FlaskRTBCTF/utils/helpers.py +++ b/src/FlaskRTBCTF/utils/helpers.py @@ -5,7 +5,7 @@ from datetime import datetime from .cache import cache -from FlaskRTBCTF.main.models import Settings, Website +from ..main.models import Settings, Website def handle_secret_key(default="you-will-never-guess"): @@ -41,3 +41,15 @@ def is_past_running_time(): end_date_time = Settings.get_settings().running_time_to current_date_time = datetime.utcnow() return current_date_time > end_date_time + + +def clear_points_cache(userId, mode): + from ..ctf.models import UserChallenge, UserMachine + from ..users.models import User + + cache.delete(key="scoreboard") + if mode == "c": + cache.delete_memoized(UserChallenge.completed_challenges, UserChallenge, userId) + elif mode == "m": + cache.delete_memoized(UserMachine.completed_machines, UserMachine, userId) + cache.delete_memoized(User.points, userId) diff --git a/src/FlaskRTBCTF/utils/models.py b/src/FlaskRTBCTF/utils/models.py index f606adc..e7b6751 100644 --- a/src/FlaskRTBCTF/utils/models.py +++ b/src/FlaskRTBCTF/utils/models.py @@ -1,4 +1,23 @@ from flask_sqlalchemy import SQLAlchemy - db = SQLAlchemy() + + +class TimeMixin(object): + __mapper_args__ = {"always_refresh": True} + + updated_on = db.Column( + db.TIMESTAMP(timezone=True), + default=db.func.current_timestamp(), + onupdate=db.func.current_timestamp(), + ) + + +class ReprMixin(object): + """Provides a string representible form for objects.""" + + def __repr__(self): + fields = {f: getattr(self, f, "") for f in self.__repr_fields__} + pattern = ["{0}={{{0}}}".format(f) for f in self.__repr_fields__] + pattern = ", ".join(pattern).format(**fields) + return f"<{self.__class__.__name__}({pattern})>" diff --git a/src/create_db.dev.py b/src/create_db.dev.py deleted file mode 100644 index f73aff6..0000000 --- a/src/create_db.dev.py +++ /dev/null @@ -1,68 +0,0 @@ -import pytz -from datetime import datetime - -from FlaskRTBCTF import db, bcrypt, create_app -from FlaskRTBCTF import User, Notification, Machine, Settings, Website, Logs -from FlaskRTBCTF.utils import handle_admin_pass - - -app = create_app() - -# create_app().app_context().push() -with app.app_context(): - db.create_all() - - default_time = datetime.now(pytz.utc) - - web1 = Website( - name="Official Abs0lut3Pwn4g3 Website", url="https://Abs0lut3Pwn4g3.github.io/", - ) - web2 = Website(name="Twitter", url="https://twitter.com/Abs0lut3Pwn4g3",) - web3 = Website( - name="GitHub", url="https://github.com/Abs0lut3Pwn4g3/RTB-CTF-Framework" - ) - - db.session.add(web1) - db.session.add(web2) - db.session.add(web3) - - settings = Settings(dummy=False) - - db.session.add(settings) - - notif = Notification( - title=f"Welcome to {settings.ctf_name}", - body="The CTF is live now. Please read rules!", - ) - db.session.add(notif) - - box = Machine( - name="My Awesome Pwnable Box", - user_hash="A" * 32, - root_hash="B" * 32, - user_points=10, - root_points=20, - os="linux", - ip="127.0.0.1", - hardness="Easy", - ) - db.session.add(box) - - passwd = handle_admin_pass() - admin_user = User( - username="admin", - email="admin@admin.com", - password=bcrypt.generate_password_hash(passwd).decode("utf-8"), - isAdmin=True, - ) - db.session.add(admin_user) - - admin_log = Logs( - user=admin_user, - accountCreationTime=default_time, - visitedMachine=True, - machineVisitTime=default_time, - ) - db.session.add(admin_log) - - db.session.commit() diff --git a/src/create_db.py b/src/create_db.py index c8cccef..a8c1c7b 100644 --- a/src/create_db.py +++ b/src/create_db.py @@ -2,36 +2,90 @@ from datetime import datetime from FlaskRTBCTF import db, bcrypt, create_app -from FlaskRTBCTF import User, Machine, Logs -from FlaskRTBCTF.main.models import Settings, Website -from FlaskRTBCTF.utils import handle_admin_pass, handle_admin_email +from FlaskRTBCTF.main.models import Settings +from FlaskRTBCTF.ctf.models import Machine, Challenge, Tag, Category +from FlaskRTBCTF.users.models import User, Logs +from FlaskRTBCTF.utils.helpers import handle_admin_pass, handle_admin_email app = create_app() -with app.app_context(): - db.create_all() +def populate_tags(): + db.session.add(Tag(label="web", color="#2B2B52")) + db.session.add(Tag(label="pwn", color="#BB2CD9")) + db.session.add(Tag(label="reversing", color="#218F76")) + db.session.add(Tag(label="osint", color="#CB7303")) + db.session.add(Tag(label="binary", color="#AE1438")) + db.session.add(Tag(label="forensics", color="#2B2B52")) + + +def populate_categories(): + category_names = [ + "binary", + "web", + "forensics", + "steganography", + "cryptography", + "OSINT", + "misc", + ] + for name in category_names: + db.session.add(Category(name=name)) + + +def populate_websites(): + from FlaskRTBCTF.main.models import Website + + web1 = Website( + name="Official Abs0lut3Pwn4g3 Website", url="https://Abs0lut3Pwn4g3.github.io/", + ) + web2 = Website(name="Twitter", url="https://twitter.com/Abs0lut3Pwn4g3") + web3 = Website( + name="Source Code on GitHub", + url="https://github.com/Abs0lut3Pwn4g3/RTB-CTF-Framework", + ) + + db.session.add(web1) + db.session.add(web2) + db.session.add(web3) - default_time = datetime.now(pytz.utc) +def populate_challs(): box = Machine( - name="My Awesome Pwnable Box", + name="Dummy Box. Edit/Delete this.", user_hash="A" * 32, root_hash="B" * 32, user_points=10, root_points=20, os="linux", ip="127.0.0.1", - hardness="easy", + difficulty="easy", ) db.session.add(box) + ch1 = Challenge( + title="Dummy challenge. Edit/Delete this.", + description="blah blah", + flag="CTF{test}", + points="50", + url="https://ch1.example.com/", + difficulty="easy", + category=Category.query.get(2), + tags=[Tag.query.get(1), Tag.query.get(2)], + ) + db.session.add(ch1) + + +with app.app_context(): + db.create_all() + + default_time = datetime.now(pytz.utc) + passwd = handle_admin_pass() - email = handle_admin_email() admin_user = User( username="admin", - email=email, + email=handle_admin_email(), password=bcrypt.generate_password_hash(passwd).decode("utf-8"), isAdmin=True, ) @@ -45,20 +99,14 @@ ) db.session.add(admin_log) - web1 = Website( - name="Official Abs0lut3Pwn4g3 Website", url="https://Abs0lut3Pwn4g3.github.io/", - ) - web2 = Website(name="Twitter", url="https://twitter.com/Abs0lut3Pwn4g3",) - web3 = Website( - name="GitHub", url="https://github.com/Abs0lut3Pwn4g3/RTB-CTF-Framework" - ) + db.session.add(Settings(dummy=True)) - db.session.add(web1) - db.session.add(web2) - db.session.add(web3) + populate_tags() + populate_categories() + populate_websites() - settings = Settings(dummy=True) + db.session.commit() - db.session.add(settings) + populate_challs() db.session.commit() diff --git a/src/create_db.test.py b/src/create_db.test.py new file mode 100644 index 0000000..d9c4a69 --- /dev/null +++ b/src/create_db.test.py @@ -0,0 +1,13 @@ +from FlaskRTBCTF import db, create_app +from FlaskRTBCTF.main.models import Settings + + +app = create_app() + + +with app.app_context(): + db.create_all() + + db.session.add(Settings(dummy=True)) + + db.session.commit() diff --git a/src/docker-entrypoint.sh b/src/docker-entrypoint.sh index a10f345..ac6c150 100644 --- a/src/docker-entrypoint.sh +++ b/src/docker-entrypoint.sh @@ -1,8 +1,6 @@ #!/bin/sh -WORKERS=4 # change here to the change number of workers - -echo "Starting RTB-CTF-Framework" -exec gunicorn 'FlaskRTBCTF:create_app()' \ - --bind '0.0.0.0:8080' \ - --workers $WORKERS \ No newline at end of file +python create_db.py +exec gunicorn "FlaskRTBCTF:create_app()" \ + --bind "0.0.0.0:8000" \ + --workers $WORKERS \ No newline at end of file