diff --git a/handlers/AdminHandlers/AdminGameObjectHandlers.py b/handlers/AdminHandlers/AdminGameObjectHandlers.py index d72fcb60d..f3ab25bc3 100644 --- a/handlers/AdminHandlers/AdminGameObjectHandlers.py +++ b/handlers/AdminHandlers/AdminGameObjectHandlers.py @@ -48,6 +48,8 @@ FLAG_FILE, FLAG_REGEX, FLAG_STATIC, + FLAG_REMOTE, + FLAG_REMOTESTRING, Flag, ) from models.FlagAttachment import FlagAttachment @@ -80,6 +82,8 @@ def get(self, *args, **kwargs): "flag/static": "admin/create/flag-static.html", "flag/datetime": "admin/create/flag-datetime.html", "flag/choice": "admin/create/flag-choice.html", + "flag/remote": "admin/create/flag-remote.html", + "flag/remotestring": "admin/create/flag-remotestring.html", "game_level": "admin/create/game_level.html", "hint": "admin/create/hint.html", "team": "admin/create/team.html", @@ -107,6 +111,8 @@ def post(self, *args, **kwargs): "flag/static": self.create_flag_static, "flag/datetime": self.create_flag_datetime, "flag/choice": self.create_flag_choice, + "flag/remote": self.create_flag_remote, + "flag/remotestring": self.create_flag_remotestring, "game_level": self.create_game_level, "hint": self.create_hint, "team": self.create_team, @@ -270,6 +276,25 @@ def create_flag_datetime(self): "admin/create/flag-datetime.html", errors=[str(error)], box=None ) + def create_flag_remote(self): + """Create a regex flag""" + try: + self._mkflag(FLAG_REMOTE) + except ValidationError as error: + self.render( + "admin/create/flag-remote.html", errors=[str(error)], box=None + ) + + def create_flag_remotestring(self): + """Create a remote string flag""" + try: + self._mkflag(FLAG_REMOTESTRING) + except ValidationError as error: + self.render( + "admin/create/flag-remotestring.html", errors=[str(error)], box=None + ) + + def create_game_level(self): """ Creates a new level in the database, the levels are basically a diff --git a/handlers/MissionsHandler.py b/handlers/MissionsHandler.py index ed051735a..4a4ba9a2f 100644 --- a/handlers/MissionsHandler.py +++ b/handlers/MissionsHandler.py @@ -27,6 +27,7 @@ import json import logging from builtins import next, str +from uuid import uuid4 from past.utils import old_div from tornado.options import options @@ -144,7 +145,7 @@ def get(self, *args, **kwargs): @authenticated @game_started - def post(self, *args, **kwargs): + async def post(self, *args, **kwargs): """Check validity of flag submissions""" box_id = self.get_argument("box_id", None) uuid = self.get_argument("uuid", "") @@ -189,6 +190,8 @@ def post(self, *args, **kwargs): if flag is not None and flag.is_file: if hasattr(self.request, "files") and "flag" in self.request.files: submission = self.request.files["flag"][0]["body"] + elif flag is not None and flag.is_remote: + submission = f"Remote Submission: {uuid4()}" else: submission = self.get_argument("token", "").replace("__quote__", '"') if len(submission) == 0: @@ -197,7 +200,7 @@ def post(self, *args, **kwargs): ) return old_reward = flag.dynamic_value(user.team) if flag is not None else 0 - if flag is not None and self.attempt_capture(flag, submission): + if flag is not None and await self.attempt_capture(flag, submission): self.add_content_policy("script", "'unsafe-eval'") success = self.success_capture(user, flag, old_reward) if options.story_mode: @@ -219,11 +222,23 @@ def post(self, *args, **kwargs): self.render_page_by_flag(flag, success=success) return else: + if flag is not None and flag in user.team.flags: + self.render_page_by_flag(flag) + return self.failed_attempt(flag, user, submission, box_id) else: self.render("public/404.html") def failed_attempt(self, flag, user, submission, box_id): + remote_error = ( + flag is not None + and (flag.is_remote or flag.is_remotestring) + and flag.status == "error" + ) + if remote_error: + msg = f"Error: {flag.message} please inform the trainer" + self.render_page_by_flag(flag, info=[msg]) + return if flag is None or Penalty.by_token_count(flag, user.team, submission) == 0: if options.teams: teamval = "team's " @@ -252,6 +267,8 @@ def failed_attempt(self, flag, user, submission, box_id): + teamval + "score." ) + if flag is not None and (flag.is_remote or flag.is_remotestring): + penalty_dialog = f"{penalty_dialog} Message: {flag.message}" if flag is None: self.render_page_by_box_id(box_id, errors=[penalty_dialog]) else: @@ -278,6 +295,8 @@ def success_capture(self, user, flag, old_reward=None): teamval = "" old_reward = flag.dynamic_value(user.team) if old_reward is None else old_reward reward_dialog = flag.name + " answered correctly. " + if flag is not None and (flag.is_remote or flag.is_remotestring): + reward_dialog = f"{reward_dialog} Message: {flag.message} " if options.banking: reward_added_str_template = ( "${} has been added to your " + teamval + "account." @@ -405,7 +424,7 @@ def failed_capture(self, flag, submission): return penalty return False - def attempt_capture(self, flag, submission): + async def attempt_capture(self, flag, submission): """Compares a user provided token to the token in the db""" user = self.get_current_user() team = user.team @@ -413,7 +432,10 @@ def attempt_capture(self, flag, submission): "%s (%s) capture the flag '%s'" % (user.handle, team.name, flag.name) ) if submission is not None and flag not in team.flags: - if flag.capture(submission): + captured = await flag.capture_async( + submission, player_ip=self.request.remote_ip + ) + if captured and flag not in team.flags: flag_value = flag.dynamic_value(team) if ( options.dynamic_flag_value diff --git a/locale/de.csv b/locale/de.csv index 07db2b03f..b8631632c 100644 --- a/locale/de.csv +++ b/locale/de.csv @@ -163,6 +163,8 @@ Create New Paste,Neuen Paste erstellen Create Notification,Benachrichtigung erstellen Create Regex Flag,Regex-Flagge erstellen Create Static Flag,Statische Flagge erstellen +Create Remote Flag,Remote-Flagge erstellen +Create Remote String Flag,Remote-String-Flagge erstellen Create Team,Team erstellen Created,Erstellt Current algorithm,Aktueller Algorithmus @@ -258,6 +260,7 @@ Enter a Matching Flag,Geben Sie eine passende Flagge ein Enter Choice,Auswahl eingeben Enter the same password as before,Geben Sie dasselbe Passwort wie zuvor ein ERROR,ERROR +Error, Fehler Expire,Verlopen Export Game Objects,Spielobjekte exportieren Export of gameplay settings does not include the entire server and database configuration.,Der Export der Gameplay-Einstellungen umfasst nicht die gesamte Server- und Datenbankkonfiguration. @@ -282,6 +285,7 @@ Flag Decrease Value,Flaggenwert verringern Flag Details,Flaggendetails Flag File,Flaggendatei Flag Hints,Flaggenhinweise +Flag ID, Flaggen ID Flag Minimum Value,Vlag Minimumwaarde Flag Name,Flaggenname Flag Penalty,Flaggenstrafe @@ -338,6 +342,7 @@ In Progress,In Bearbeitung "In this case, the flag is a Date / Time string. This flag type will try to match on common datetime format variations and deal with differences like slashes, dashes, padding, 12hr, 24hr, etc. It can handle a date, time, or a datetime.","In diesem Fall ist die Flagge eine Datums- / Uhrzeitzeichenfolge. Dieser Flaggentyp versucht, mit gängigen Datums- / Uhrzeit-Formatvariationen übereinzustimmen und behandelt Unterschiede wie Schrägstriche, Bindestriche, Auffüllungen, 12 Stunden, 24 Stunden usw. Er kann ein Datum, eine Uhrzeit oder eine Datums- / Uhrzeitangabe verarbeiten." "In this case, the flag is a multiple choice question where the selected option is the token.","In diesem Fall ist die Flagge eine Multiple-Choice-Frage, bei der die ausgewählte Option das Token ist." "In this case, the flag is a regular expression. The user must submit the a string which matches the pattern to capture the flag. Matches can be insensitive. Please be sure to test your regex, and ensure its not too broad.","In diesem Fall ist die Flagge ein regulärer Ausdruck. Der Benutzer muss eine Zeichenfolge übermitteln, die mit dem Muster übereinstimmt, um die Flagge zu erobern. Übereinstimmungen können unempfindlich sein. Bitte stellen Sie sicher, dass Sie Ihren regulären Ausdruck testen, und stellen Sie sicher, dass er nicht zu breit angelegt ist." +"In this case, the flag is a remote Flag, the ID is submited to the flag-check-server.","In diesem Fall ist die Flagge eine Remote-Flagge, die ID wird an den Flaggen-Test-Server übermittelt" "In this case, the flag is a static string. The user must submit the exact token to capture the flag. Whitespace at the beginning and end are stripped.","In diesem Fall ist die Flagge eine statische Zeichenfolge. Der Benutzer muss das genaue Token senden, um die Flagge zu erobern. Leerzeichen am Anfang und Ende werden entfernt." Include gameplay settings (such as those under ,Das Einbeziehen von Gameplay-Einstellungen (z. B. beim Exportieren von Gameplay-Einstellungen) umfasst nicht die gesamte Server- und Datenbankkonfiguration. Income,Einkommen @@ -483,6 +488,7 @@ Player Arrested!,Spieler verhaftet! Player Email,Spieler E-Mail Player Name,Spielername "Player's bank account passwords are also available, allowing you to crack each other's passwords and steal the money.","Passwörter für das Bankkonto des Spielers sind ebenfalls verfügbar, sodass Sie die Passwörter des anderen knacken und das Geld stehlen können." +please inform your trainer ,Bitte informieren sie den Referenten. Players,Spieler Please do NOT enter your real bank account password.,Bitte geben Sie NICHT Ihr echtes Bankkonto-Passwort ein. points,Punkte diff --git a/models/Flag.py b/models/Flag.py index f03a6df51..60769c32d 100644 --- a/models/Flag.py +++ b/models/Flag.py @@ -23,8 +23,9 @@ import hashlib import json import re -import xml.etree.cElementTree as ET +import xml.etree.ElementTree as ET from builtins import str +from urllib.parse import urlencode from uuid import uuid4 from dateutil.parser import parse @@ -32,6 +33,7 @@ from sqlalchemy import Column, ForeignKey from sqlalchemy.orm import backref, relationship from sqlalchemy.types import Boolean, Integer, String, Unicode +from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest from tornado.options import options from libs.ValidationError import ValidationError @@ -50,7 +52,18 @@ FLAG_FILE = "file" FLAG_DATETIME = "datetime" FLAG_CHOICE = "choice" -FLAG_TYPES = [FLAG_STATIC, FLAG_REGEX, FLAG_FILE, FLAG_DATETIME, FLAG_CHOICE] +FLAG_REMOTE = "remote" +FLAG_REMOTESTRING = "remotestring" +FLAG_TYPES = [ + FLAG_STATIC, + FLAG_REGEX, + FLAG_FILE, + FLAG_DATETIME, + FLAG_CHOICE, + FLAG_REMOTE, + FLAG_REMOTESTRING, +] +REMOTE_FLAG_STATUSES = frozenset(("success", "fail", "error")) class Flag(DatabaseObject): @@ -63,6 +76,8 @@ class Flag(DatabaseObject): -datetime -file -choice + -remote + -remotestring Depending on the cls._type value. For more information see the wiki. """ @@ -83,6 +98,9 @@ class Flag(DatabaseObject): _type = Column(Unicode(16), default=False) _locked = Column(Boolean, default=False, nullable=False) + status = "" + message = "Default Message" + flag_attachments = relationship( "FlagAttachment", backref=backref("flag", lazy="select"), @@ -107,7 +125,15 @@ class Flag(DatabaseObject): cascade="all,delete,delete-orphan", ) - FLAG_TYPES = [FLAG_FILE, FLAG_REGEX, FLAG_STATIC, FLAG_DATETIME, FLAG_CHOICE] + FLAG_TYPES = [ + FLAG_FILE, + FLAG_REGEX, + FLAG_STATIC, + FLAG_DATETIME, + FLAG_CHOICE, + FLAG_REMOTE, + FLAG_REMOTESTRING, + ] @classmethod def all(cls): @@ -165,6 +191,8 @@ def create_flag(cls, _type, box, name, raw_token, description, value): FLAG_FILE: cls._create_flag_file, FLAG_DATETIME: cls._create_flag_datetime, FLAG_CHOICE: cls._create_flag_choice, + FLAG_REMOTE: cls._create_flag_remote, + FLAG_REMOTESTRING: cls._create_flag_remotestring, } # TODO Don't understand why this is here - name is not unique value # and you could simply name questions per box, like "Question 1" - ElJefe 6/1/2018 @@ -235,6 +263,29 @@ def _create_flag_choice(cls, box, name, raw_token, description, value): value=value, ) + @classmethod + def _create_flag_remote(cls, box, name, raw_token, description, value): + """Check flag remote specific parameters""" + return cls( + box_id=box.id, + name=name, + token=raw_token, + description=description, + value=value, + ) + + @classmethod + def _create_flag_remotestring(cls, box, name, raw_token, description, value): + """Check flag remotestring specific parameters""" + return cls( + box_id=box.id, + name=name, + token=raw_token, + description=description, + value=value, + ) + + @classmethod def digest(self, data): """Token is SHA1 of data""" @@ -382,6 +433,14 @@ def is_static(self): def is_file(self): return self._type == FLAG_FILE + @property + def is_remote(self): + return self._type == FLAG_REMOTE + + @property + def is_remotestring(self): + return self._type == FLAG_REMOTESTRING + @property def box(self): return Box.by_id(self.box_id) @@ -425,7 +484,7 @@ def choicelist(self): choices.append(flagchoice.choice) return json.dumps(choices) - def capture(self, submission): + def capture(self, submission, **kwargs): if self._type == FLAG_STATIC: if self._case_sensitive == 0: return ( @@ -450,9 +509,130 @@ def capture(self, submission): return parse(self.token) == parse(submission) except: return False + elif self._type in (FLAG_REMOTE, FLAG_REMOTESTRING): + raise ValueError("Remote flags must be captured asynchronously") else: raise ValueError("Invalid flag type, cannot capture") + async def capture_async(self, submission, **kwargs): + """Capture a flag without blocking the Tornado event loop.""" + if self._type in (FLAG_REMOTE, FLAG_REMOTESTRING): + return await self.capture_remote_flag(submission, **kwargs) + return self.capture(submission, **kwargs) + + async def capture_remote_flag(self, submission, **kwargs): + """Submit this flag to the configured remote flag server. + + Validates the flag type, sends the request to the remote server, and + updates ``self.status`` and ``self.message`` with the result. + + Possible values for self.status: + success, fail, error + + Args: + submission: The submitted flag value. It is sent only for + ``FLAG_REMOTESTRING`` flags. + **kwargs: Optional request metadata. If ``player_ip`` is provided, + it is included in the request payload. + + Returns: + bool: ``True`` if the remote server returns a ``"success"`` status; + otherwise ``False``. + """ + + if not self._validate_remote_flag_request(): + return False + + data = self._build_remote_flag_data(submission, **kwargs) + reply = await self._send_remote_flag_request(data) + + if reply is None: + return False + + return self._process_remote_flag_response(reply) + + def _validate_remote_flag_request(self): + if self._type not in (FLAG_REMOTE, FLAG_REMOTESTRING): + return self._set_remote_error("Wrong flagtype for remoteflag") + + return True + + def _set_remote_error(self, message): + self.status = "error" + self.message = message + return False + + def _build_remote_flag_data(self, submission, **kwargs): + data = { + "flag_token": self.token, + } + + if "player_ip" in kwargs: + data["player_ip"] = kwargs["player_ip"] + + if self._type == FLAG_REMOTESTRING: + data["submission"] = submission + + return data + + def _get_remote_flag_url(self): + return ( + f"{options.remote_protocol}://" + f"{options.remote_domain}:" + f"{options.remote_port}" + f"{options.remote_path}" + ) + + async def _send_remote_flag_request(self, data): + try: + request = HTTPRequest( + url=self._get_remote_flag_url(), + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=urlencode(data).encode("utf-8"), + request_timeout=options.remote_timeout, + ) + return await AsyncHTTPClient().fetch(request, raise_error=False) + except (HTTPClientError, ValueError) as error: + self._set_remote_error(f"Request to Flagserver failed: {error}") + + return None + + def _process_remote_flag_response(self, reply): + if reply.code != 200: + return self._set_remote_error( + f"Request to Flagserver returned status {reply.code}" + ) + + try: + response_data = json.loads(reply.body.decode("utf-8")) + except (AttributeError, UnicodeDecodeError, json.JSONDecodeError): + return self._set_remote_error( + "Reply from FlagCheckServer is not valid JSON" + ) + + if not isinstance(response_data, dict): + return self._set_remote_error( + "Reply from FlagCheckServer is not a JSON object" + ) + + status = response_data.get("status") + if not isinstance(status, str) or status not in REMOTE_FLAG_STATUSES: + return self._set_remote_error( + "Reply from FlagCheckServer contains an invalid status" + ) + + message = response_data.get("message", "No message from FlagServer") + if not isinstance(message, str): + return self._set_remote_error( + "Reply from FlagCheckServer contains an invalid message" + ) + + self.status = status + self.message = message + + return self.status == "success" + def to_xml(self, parent): """Write attributes to XML doc""" flag_elem = ET.SubElement(parent, "flag") diff --git a/rootthebox.py b/rootthebox.py index 36fbdeb21..8a304ce2e 100644 --- a/rootthebox.py +++ b/rootthebox.py @@ -1143,6 +1143,47 @@ def help(): define("tests", default=False, help="runs the unit tests", type=bool) +# remote Flag server +define( + "remote_protocol", + default="http", + group="remote", + help="protocol to access the remote flag server" +) + +define( + "remote_domain", + default="FlagCheckServer", + group="remote", + help="domain or IP to access the remote flag server" +) + +define( + "remote_path", + default="/UserFlagCheck", + group="remote", + help="path to check remote flags on the remote flag server" +) + +define( + "remote_port", + default= 8080, + group="remote", + help="port to access the remote flag server", + type=int +) + +define( + "remote_timeout", + default= 60, + group="remote", + help="time to wait for reply from the remote flag server in seconds", + type=int +) + + + + if __name__ == "__main__": # We need this to pull the --config option diff --git a/static/js/pages/missions/box.js b/static/js/pages/missions/box.js index 66a6d9910..9afa664de 100644 --- a/static/js/pages/missions/box.js +++ b/static/js/pages/missions/box.js @@ -34,6 +34,18 @@ $(document).ready(function() { $("#capture-text-flag-form").submit(); }); + $("#capture-remote-flag-modal").on('shown.bs.modal', function () { + $("#flag-token").focus() + }); + + $("a[id^=capture-remote-flag-button]").click(function() { + $("#capture-remote-flag-uuid").val($(this).data("uuid")); + }); + + $("#capture-remote-flag-submit").click(function() { + $("#capture-remote-flag-form").submit(); + }); + $("a[id^=capture-choice-flag-button]").click(function() { $("#capture-choice-flag-uuid").val($(this).data("uuid")); $("#choiceinput").empty(); diff --git a/templates/admin/create/flag-remote.html b/templates/admin/create/flag-remote.html new file mode 100644 index 000000000..539668f66 --- /dev/null +++ b/templates/admin/create/flag-remote.html @@ -0,0 +1,126 @@ +{% extends "../../main.html" %} + +{% block title %}{{_("Create Flag")}}{% end %} + +{% block header %} + + + + +{% end %} + +{% block content %} +{% from models.Box import Box %} +{% from tornado.options import options %} +
+

+ + {{_("Create Remote Flag")}} +

+
+ {% if errors is not None and len(errors) != 0 %} + {% for error in errors %} +
+ × +

{{_("ERROR")}}

+ {{ error }} +
+ {% end %} + {% end %} +
+
+
+
+ {% raw xsrf_form_html() %} + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+ {% if options.story_mode %} +
+ +
+ +
+
+ {% end %} +
+
+ +
+
+
+
+
+

+

+ + {{_("Tokens")}}: + {{_("In this case, the flag is a remote Flag, the ID is submited to the flag-check-server")}} + +
+

+
+
+
+
+{% end %} diff --git a/templates/admin/create/flag-remotestring.html b/templates/admin/create/flag-remotestring.html new file mode 100644 index 000000000..4ac3b4eac --- /dev/null +++ b/templates/admin/create/flag-remotestring.html @@ -0,0 +1,157 @@ +{% extends "../../main.html" %} + +{% block title %}{{_("Create Flag")}}{% end %} + +{% block header %} + + + + +{% end %} + +{% block content %} +{% from models.Box import Box %} +{% from tornado.options import options %} +
+

+ + {{_("Create Remote String Flag")}} +

+
+ {% if errors is not None and len(errors) != 0 %} + {% for error in errors %} +
+ × +

{{_("ERROR")}}

+ {{ error }} +
+ {% end %} + {% end %} +
+
+
+
+ {% raw xsrf_form_html() %} + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+ + +
+
+
+
+ +
+ +
+
+
+ +
+ + + + + +
+
+
+ +
+ +
+
+ {% if options.story_mode %} +
+ +
+ +
+
+ {% end %} +
+
+ +
+
+
+
+
+

+

+ + {{_("Tokens")}}: + {{_("In this case, the flag is a static string. The user must submit the exact token to capture the flag. Whitespace at the beginning and end are stripped.")}} + +
+

+
+
+
+
+{% end %} diff --git a/templates/admin/create/flag.html b/templates/admin/create/flag.html index efda358e7..d549c0f6f 100644 --- a/templates/admin/create/flag.html +++ b/templates/admin/create/flag.html @@ -30,6 +30,14 @@

{{_("Create Multiple Choice Flag")}} » + + + {{_("Create Remote Flag")}} » + + + + {{_("Create Remote String Flag")}} » +
{% end %} diff --git a/templates/menu/admin.html b/templates/menu/admin.html index 8ed429103..abc0a42ac 100644 --- a/templates/menu/admin.html +++ b/templates/menu/admin.html @@ -97,6 +97,18 @@ {{ _("Multiple Choice Flag") }} +
  • + + + {{ _("Remote Flag") }} + +
  • +
  • + + + {{ _("RemoteString Flag") }} + +
  • diff --git a/templates/missions/box.html b/templates/missions/box.html index e1e406fc4..bd08c00e1 100644 --- a/templates/missions/box.html +++ b/templates/missions/box.html @@ -17,6 +17,8 @@ {% from models.Flag import Flag %} {% from models.Flag import FLAG_FILE %} {% from models.Flag import FLAG_CHOICE %} +{% from models.Flag import FLAG_REMOTE %} +{% from models.Flag import FLAG_REMOTESTRING %} {% from models.FlagChoice import FlagChoice %} {% from models.Box import FlagsSubmissionType %} {% from handlers.MaterialsHandler import has_materials,has_box_materials %} @@ -96,6 +98,28 @@

    {{ _("Submit Flag") }}

    + + + +