Blog Security How GitLab's Red Team automates C2 testing
Published on: November 28, 2023
14 min read

How GitLab's Red Team automates C2 testing

Learn how to apply professional development practices to Red Teams using open source command and control tools.

built-in-security.jpeg

At GitLab, our Red Team conducts security exercises that emulate real-world threats. By emulating real-world threats, we help assess and improve the effectiveness of the people, processes, and technologies used to keep our organization secure. To operate effectively, we must utilize professional development practices like the threat actors we emulate.

Threat actors use open source command and control (C2) tools such as Merlin. While convenient, these tools have intentionally detectable features to discourage illegitimate use. Red Teams often need to customize and combine different open source options to evade detections in the environments they target.

In this blog, you'll learn how our team applies professional development practices to using open source C2 tools. We'll share how we implement continuous testing for the Mythic framework, our design philosophy, and a public project you can fork and use yourself.

Our solution, available in this public project, improves our Red Team operations in two ways. First, it contains a suite of pytest tests for the Mythic C2 framework. These validate functionality of both the Mythic server and multiple Mythic-compatible agents. Second, it leverages GitLab CI/CD pipelines to automatically run these tests after each code change. This enables iterative development and rapid validation of updates to Mythic or Mythic-compatible C2 agents.

Prerequisites

Currently, a few prerequisites fall outside the scope of test automation:

  • A Linux VM with Mythic, its Python requirements, and the HTTP profile installed. See the Mythic installation guide. We suggest binding Mythic's admin interface to localhost only.
  • A fork of the ContinuousMage GitLab project in GitLab.com or your own GitLab instance. You'll build on top of this to run your own automation. We highly suggest making this fork private, so you don't expose your test infrastructure or C2 code changes.
  • GitLab Runner installed on the VM (configured with the shell executor) and registered with your GitLab instance. See the docs on installing and registering a runner or follow the instructions provided when configuring your pipeline later in this blog. You'll assign this runner to your project when we configure CI/CD.
  • Your forked project cloned onto your VM. This allows testing code changes (or new tests) before triggering the pipeline.

Project structure

The project contains three main portions that we will detail in this blog post:

  1. pytest test code for running integration tests for Mythic and Mythic-compatible C2 agents
  2. The source of those Mythic-compatible C2 agents, as git submodules
  3. The GitLab CI/CD pipeline configuration that ties it all together

Part 1: pytests

pytest is a framework for writing tests in Python. We can leverage pytest to do integration testing of Mythic since it has its own Python package. The test suite goals are:

  1. Be simple and atomic.
  2. Provide adequate coverage to validate tool readiness.

We'll walk through a simple test verifying an agent can run the ls command, highlighting key code sections for customization.

Implementation

pytest file

When run on a directory, pytest automatically discovers tests in files prefixed with test_ and test functions starting with test_. Our tests are asynchronous, needing the pytest.mark.asyncio decorator, because the Mythic APIs we are testing are asynchronous. If your machine is missing test dependencies, run python3 -m pip install mythic pytest pytest-asyncio.

A test function skeleton is as follows:

@pytest.mark.asyncio
async def test_agent_ls():
    # Will do the test here
    continue

The GlMythic class

The GlMythic class wraps Mythic APIs for ease of use in testing. Because its init function is async, a coroutine creates the object:

@pytest.mark.asyncio
async def test_agent_ls():
    glmythic = await gl_mythic.create_glmythic()

By default, it connects to the Mythic DB using the MYTHIC_ADMIN_PASSWORD environment variable and is configured to test the agent specified via the AGENT_TYPE environment variable. We will set these in the CI/CD config later.

Interacting with Mythic via GlMythic

We'll include the remainder of the test code here, with comments, and then discuss the most important parts.

As a reminder, one of the key goals of this project was to make completely atomic tests. Each test only relies on a running Mythic server with the specific agent and HTTP containers loaded. As the test suite grows, it may be worth running a secondary set of tasks that relies on an already-existing agent connection. Currently, every test creates, downloads, and executes a new agent.

Test and deploy

@pytest.mark.asyncio
async def test_agent_ls():

    glmythic = await gl_mythic.create_glmythic()

    # Unique payload_path per test
    payload_path = "/tmp/test_agent_ls"

    # Wraps agent create, download, and execute
    proc = await glmythic.generate_and_run(payload_path=payload_path)

    # Wait for callback
    time.sleep(10)

    # Uses the display_id field to determine most recent callback
    # Assumes that the most recent callback is the one created by this test
    callback = await glmythic.get_latest_callback()

    # Issue the ls command, blocking on output
    output = await mythic.issue_task_and_waitfor_task_output(
        mythic=glmythic.mythic_instance,
        command_name="ls",
        parameters="",
        callback_display_id=callback["display_id"],
        timeout=20,
    )

    # Clean up (no longer need the agent)
    proc.terminate() 
    os.remove(payload_path)

    # If the ls failed, there will be no output
    # This test could also look for files in the repo (where the agent runs)
    assert len(output) > 0

The longest running portion of this test will be the call to generate_and_run, as agent builds within Mythic can take from seconds to minutes or even hang altogether. For your initial set of tests, sign in to the Mythic server and watch the Payloads screen for potential issues. In our testing, agent builds failed to complete around 5% of the time, depending on the agent. If you experience repeated build failures, reload your agent container with sudo ./mythic-cli install folder <agent_directory> -f.

To run the tests, run pytest <testfile_directory>.

Part 2: Agent source as submodules

Because Mythic agents are often updated, we include the agent repos as git submodules in our test project. This allows us to update to new agent versions when they are released and use our project's version control to keep tool versions static for known good builds. These submodules are all located in the agents folder.

We'll discuss adding more agents to this project later in this blog.

Part 3: GitLab CI/CD pipeline

Now that you have working pytests, you can automate your tests to run whenever you want. In our case, we chose to run our tests on merge requests and tagged commits (which are likely to be tool releases). We will be using GitLab CI/CD pipelines to perform our automated tests.

Configuring the pipeline

Now is the time to set your GitLab CI/CD settings. To find these settings, go to your repository -> Settings -> CI/CD.

The first setting you'll want to set is your Runner. If you set up a runner as one of your prerequisite steps earlier, you can assign it here. If not, click New project runner and work through that process to create and set up your runner on your Mythic server. When you are prompted to choose a runner type on install, choose the shell executor. If your team uses shared runners for other CI/CD pipelines, you will want to make sure that shared runners are disabled for this project, given that your shared runners are unlikely to be able to talk to Mythic directly.

runner-settings

Next, you need to set your Variables. The GlMythic class uses the MYTHIC_ADMIN_PASSWORD environment variable to be able to actually sign into Mythic, so you need to make sure that the pipeline runner's environment is set up correctly.

To do this, click the Add variable button and add the MYTHIC_ADMIN_PASSWORD variable with the appropriate value. If you don't know your Mythic admin password, on the Mythic server in the directory where you installed Mythic, cat .env | grep MYTHIC_ADMIN_PASSWORD will give you the password.

Because GitLab handles merge requests in a detached state, you need to unclick the Protect Variable box, because that would prevent the pipeline from viewing the variable on a merge request otherwise. Because the variable is not protected, any branch committed back to your server can access your CI variables. This may pose a security risk if you allow remote access to your Mythic server (versus binding to localhost) and if you allow arbitrary users to access your repository. For this reason, our public repo does not have the environment variables. We use a private copy to perform testing, and suggest you do the same.

Additionally, set the AGENT_TYPE variable to the name of the agent you want to use. At time of release, valid agent types are poseidon or merlin. The section about adding more agents to the test suite will go into more detail.

You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.

Now that the pipeline is configured to use the runner and pick up the environment variables that you need, the only thing left to do is to set up your pipeline. This step is quite simple: If you add the .gitlab-ci.yml file to the root of your repository, GitLab will pick that up as the pipeline config on your next commit. Here is our example pipeline, which we will explain momentarily.

install:
  stage: install
  script:
    - sudo /opt/Mythic/mythic-cli install folder "${CI_PROJECT_DIR}"/agents/"${AGENT_TYPE}" -f
  rules:
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
    - if: $CI_COMMIT_TAG

test:
  stage: test
  script:
    - pytest "${CI_PROJECT_DIR}"/mythic-test
  rules:
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
    - if: $CI_COMMIT_TAG

All of the variables set above are made available by GitLab as part of every pipeline. This pipeline has two stages, install and test. Both stages are set to only run on merge requests or if the commit being evaluated has a specific tag. The install stage will install your C2 agent into Mythic using its local folder install. This makes sure that the Mythic server has your latest C2 code changes installed. Next, the test stage runs the set of pytest tests that we created. The install stage will run very quickly, and the test stage will run a little more slowly, given that it's doing the work of creating and interacting with Mythic agents.

Pipeline in action

You can do a couple of things to validate that your pipeline is working. First, if you are performing a merge request, there will be a section at the beginning of the merge request that will link to the pipeline. The screenshot below shows that the pipeline has passed, but you can click into the pipeline by clicking on its number even when it's running.

Pipeline passing

You can then click into the stage that's running (or one that has already run) to view its output.

Pipeline task output

And there you are! You now have working pytest tests for a Mythic agent that run every time you make a merge request.

Adapting for other agents

We tested our test suite against Poseidon and Merlin. Although the initial tests (generate, download and exec, ls) work the same for both agents, Poseidon and Merlin require different parameters for their upload commands. Unfortunately, this means that not all tests will be agent agnostic.

As a result, each GlMythic object that is created is told what type of agent it is testing. The coroutine for creating an object allows you to pass in the agent type as a variable, and defaults to using the AGENT_TYPE environment variable to determine which agent is being tested.

async def create_glmythic(  username="mythic_admin",
                            password=os.getenv("MYTHIC_ADMIN_PASSWORD"),
                            server_ip="127.0.0.1",
                            server_port=7443,
                            agent_type=os.getenv("AGENT_TYPE")):

Agent source

To add more agents for testing, the first thing to do is to import your agent as a git submodule:

cd agents
git submodule add "${URL_TO_YOUR_AGENT}"

Commit your changes, and your agent is tracked as part of the repo.

Test compatibility

You'll need to validate that existing tests work with your agent. For tests to work, the parameters passed to the commands must match those in the test suite, with upload to be most likely to fail.

This is okay! Within the test_agent_upload test function, you'll see example code that specifies a different upload command for Merlin and Poseidon. Simply follow this structure for your own agent, passing your agent's parameters to the mythic.issue_task_and_waitfor_task_output function call.

If you are using another open source C2 and are unsure of the correct parameters to pass, you can use the Mythic UI. Interact with one of your agents and run the upload command to see what params you need to pass. If you do this for Poseidon, it will look like the following:

upload-parameters

Our test suite should be pretty easy to add to any Linux-based Mythic agent that supports the HTTP C2 profile. Because the GitLab Runner installs the agent into Mythic (and Mythic is made to run on Linux), the runner is expecting to be on a Linux machine. Additional effort and test modifications will be required to run the test suite against a Windows or MacOS agent.

A quick win

As we worked on this project, we were continuously running our test suite against both Poseidon and Merlin. Unexpectedly, in early October 2023, our test for Poseidon's upload function started to fail. After a quick investigation, we identified that a bug had been introduced, present in Poseidon 2.0.2, that caused file uploads to fail.

We took our information to one of the Poseidon developers, Cody Thomas (@its_a_feature_), and he quickly identified the underlying issue and fixed the problem.

This highlights the usefulness of continuous testing. Instead of running into a potential bug during a Red Team exercise, we identified the issue beforehand and were able to report the bug so the issue was fixed.

We sincerely thank the Mythic, Merlin, and Poseidon developers for open sourcing their hard work. Many Red Teams around the world are able to perform high-quality security assessments in part because of the hard work of C2 developers who open source their tools. We also want to specifically thank Cody Thomas for addressing this bug within 20 minutes of notification. His responsiveness and attention to detail are unmatched.

Share your feedback

This post has demonstrated both the value of continuous testing and shown how to implement continuous testing for your own use, using GitLab. If you have worked alongside these examples, you've implemented some continuous testing for the Mythic framework and have tests that you can use for Merlin, Poseidon, or your own Mythic agent(s).

At GitLab, we always seek feedback on our work. If you have any questions or comments, please open an issue on our project. You can also propose improvements via a merge request. We believe that everyone should be able to contribute, so we welcome any contributions, big or small.

Try GitLab Ultimate for free today.

We want to hear from you

Enjoyed reading this blog post or have questions or feedback? Share your thoughts by creating a new topic in the GitLab community forum. Share your feedback

Ready to get started?

See what your team could do with a unified DevSecOps Platform.

Get free trial

Find out which plan works best for your team

Learn about pricing

Learn about what GitLab can do for your team

Talk to an expert