<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Luke Young on Medium]]></title>
        <description><![CDATA[Stories by Luke Young on Medium]]></description>
        <link>https://medium.com/@bored.engineer?source=rss-6b69d1e4b01d------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*AFGYtbqZiVBrf5kCoV_2sA.jpeg</url>
            <title>Stories by Luke Young on Medium</title>
            <link>https://medium.com/@bored.engineer?source=rss-6b69d1e4b01d------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Thu, 25 Jun 2026 07:59:11 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@bored.engineer/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Building GitHub Canarytokens: A rant about Audit Log gaps]]></title>
            <link>https://blog.bored.engineer/github-canarytokens-5c9e36ad7ecf?source=rss-6b69d1e4b01d------2</link>
            <guid isPermaLink="false">https://medium.com/p/5c9e36ad7ecf</guid>
            <category><![CDATA[detection-engineering]]></category>
            <category><![CDATA[github-actions]]></category>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[security]]></category>
            <category><![CDATA[github]]></category>
            <dc:creator><![CDATA[Luke Young]]></dc:creator>
            <pubDate>Mon, 04 May 2026 13:46:10 GMT</pubDate>
            <atom:updated>2026-05-04T13:59:00.217Z</atom:updated>
            <content:encoded><![CDATA[<blockquote><em>Insights from my (mostly) successful attempt to create API tokens and SSH keys for </em><a href="https://github.com/"><em>github.com</em></a><em> that trigger an alert (including source IP/User-Agent) when a malicious actor discovers and attempts to abuse the credentials.</em></blockquote><figure><img alt="Screenshot of a Slack message posted by “GitHub Canarytokens” containing a “GitHub Audit Log Alert”. Message contains the timestamp, token used, URL, action (“api.request”), source IP and User Agent (“GitHub CLI 2.90.0”). The raw event is attached as well as comment indicating the location where the token was stored (“Stored in ~/.netrc on self-hosted ubuntu GitHub Actions runners”)" src="https://cdn-images-1.medium.com/max/1024/1*l_sX8KCKGg5DIYMk5xedEw.png" /></figure><h3>Background</h3><p>A few weeks ago I got lightly involved in the community response to the supply chain attack against the <a href="https://www.wiz.io/blog/trivy-compromised-teampcp-supply-chain-attack">Trivy vulnerability scanner</a>. On March 19th, 2026 <a href="https://www.wiz.io/blog/trivy-compromised-teampcp-supply-chain-attack">malicious versions of the Trivy binary and GitHub Action</a> were published as part of a larger attack that has <a href="https://ramimac.me/teampcp/">continued to expand in scope</a> to include popular NPM packages, LiteLLM, etc in the following weeks.</p><p>When the Trivy attack was first discovered, initial triage by the community began as a thread on the <a href="https://web.archive.org/web/20260307200451/https://github.com/aquasecurity/trivy/discussions/10265">(now deleted) discussion topic</a> from the <a href="https://web.archive.org/web/20260307200451/https://github.com/aquasecurity/trivy/discussions/10265"><em>previous</em> compromise of Trivy that had occurred in late February</a>. As TeamPCP realized they had been discovered, they used their GitHub access to delete the entire discussion thread, presumably in an attempt to slow down incident response by impacted users/companies.</p><p>When I <a href="https://github.com/aquasecurity/trivy/discussions/10420#discussioncomment-16216319">added a comment preserving the original IoCs</a> on a new GitHub discussion, TeamPCP was so kind as to send me 1000+ spam comments/emails from compromised GitHub accounts:</p><figure><img alt="https://github.com/aquasecurity/trivy/discussions/10420" src="https://cdn-images-1.medium.com/max/1024/1*Ew-XXawqcEEIZqIdxLc2Jw.png" /></figure><p>Of course the Trivy incident has kicked off the same conversations as previous supply-chain attacks about <a href="https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns">dependency cooldowns</a>, <a href="https://docs.pypi.org/trusted-publishers/">trusted publishers</a>, <a href="https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases">immutable releases</a>, etc. However, very little of the conversation has focused on what Aqua Security or the companies/users who unintentionally executed the malicious Trivy binary/GitHub Action (which included <a href="https://snyk.io/articles/trivy-github-actions-supply-chain-compromise/#the-full-credential-theft-scope">extensive credential harvesting malware</a>) could have done to <em>detect</em> the attack sooner.</p><p>In particular, I’ve haven’t seen many mentions of my favorite tools, <a href="https://canarytokens.org/nest/">Canarytokens</a> (aka <a href="https://www.crowdstrike.com/en-us/cybersecurity-101/identity-protection/honeytokens/">Honeytokens</a>). These are digital “tripwires” disguised as attractive targets such as fake files, API keys, or database entries that trigger an alert the moment a threat actor touches or opens them. They act as a proactive security alarm, notifying you of a breach by revealing exactly when and where an unauthorized user is snooping in your system.</p><h3>Goals</h3><p>There is already an excellent list of supported <a href="https://canarytokens.org/nest/">Canarytokens</a> including <a href="https://canarytokens.org/nest/create/aws-keys">AWS keys</a> and <a href="https://canarytokens.org/nest/create/kubeconfig">Kubeconfig</a> both of which were exfiltrated and later abused by TeamPCP. <strong>However, notably missing from this list of supported credentials (and from the various competitors) is a <em>GitHub</em> Canarytoken</strong>, the primary credential type used to stage the compromise of Trivy.</p><p>Our goal is to create an SSH key and API token for <a href="https://github.com">github.com</a> and then leave it somewhere an attacker/malware is likely to stumble upon it, for example:</p><ul><li>Within <a href="https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets">a GitHub Actions secret</a> with an interesting name like GH_ADMIN_TOKEN</li><li>Inside the ~/.ssh directory on a self-hosted GitHub actions runner image</li><li>A machine entry in the ~/.netrc file on a developer’s workstation</li><li>Inside Kubernetes <a href="https://kubernetes.io/docs/concepts/configuration/secret/">Secret</a>/<a href="https://kubernetes.io/docs/concepts/configuration/configmap/">ConfigMap</a> within a development environment</li></ul><p>If an attacker attempts to use the token/key, it should trigger an alert as quickly as possible, capturing the source IP address, User-Agent header, etc. Ideally, it shouldn’t be possible for an attacker to <a href="https://trufflesecurity.com/blog/canaries">identify these are canary credentials</a> without first detonating them.</p><p>The implementation will look something like this:</p><ul><li>Identify the types of credentials that are best suited for canarytokens (i.e. read-only) and how they are provisioned (ideally they can be created programmatically).</li><li>Create fake resources (repositories, issues, pull-requests), accessible via the credentials, that will look attractive for an attacker to interact with.</li><li>Configure audit logging using available features from the vendor, filter for events related to the canarytokens and trigger an alert, for example ‘paging’ via Slack.</li><li>If the audit logs are incomplete/broken (<em>foreshadowing</em>), additional complexity/hacks may be required to alert on <em>any</em> usage…</li></ul><h3>Security log vs Audit log</h3><p>If you’ve ever dug into your GitHub account settings (or had the misfortune of doing incident response on a GitHub org/user), you may already be familiar with the ‘<a href="https://github.com/settings/security-log">Security log</a>’ which records actions taken on a personal account, ex:</p><figure><img alt="A screenshot of the GitHub “Security log” view showing two ‘gist.create’ events containing a source IP/location and date/time." src="https://cdn-images-1.medium.com/max/1024/1*gSGr-LTHMdSkKm2Q0gNsLw.png" /></figure><p>While there are quite a few <a href="https://docs.github.com/en/enterprise-cloud@latest/authentication/keeping-your-account-and-data-secure/security-log-events">different event types</a> in this log, notably missing are any events for the <em>use</em> of an API token/SSH key, only the initial creation or deletion of credential <em>resources</em> will emit a ‘Security log’ event. Additionally, <a href="https://github.com/orgs/community/discussions/164789">there is no API</a>* for programmatically accessing ‘Security log’ events, only a manual export button in the GUI.</p><p>Of course this lack of visibility was not acceptable for ‘Enterprise’ customers so GitHub introduced a related feature: <a href="https://docs.github.com/en/enterprise-cloud@latest/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization">Audit logs</a>. These logs track actions taken against an <em>Organization</em> instead of a <em>User</em> (more on the limitations of this later). ‘Audit logs’ are far more verbose than ‘Security logs’ and <a href="https://docs.github.com/en/enterprise-cloud@latest/rest/orgs/orgs?apiVersion=2026-03-10#get-the-audit-log-for-an-organization">can be accessed programmatically</a>*. In particular, we will be interested in the <a href="https://docs.github.com/en/enterprise-cloud@latest/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization#git">git.clone or git.fetch</a> events which were introduced in <a href="https://github.blog/changelog/2020-12-10-audit-log-git-events-and-rest-api-now-available-in-limited-public-beta/">December 2020</a>, as well as the <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/audit-log-events-for-your-enterprise#api">api.request</a> event added in <a href="https://github.blog/changelog/2023-02-01-api-requests-are-available-via-audit-log-streaming-private-beta/">February 2023</a> which became generally available in <a href="https://github.blog/changelog/2025-01-13-audit-log-streaming-of-api-requests-is-generally-available/">January 2025</a>.</p><p>Unfortunately, <strong>GitHub Audit logs are not free</strong>. In order to gain access the events we care about, our <em>Organization</em>(s) must be part of a GitHub Enterprise Cloud (GHEC) <em>Enterprise</em>. This will run a minimum of $21/month (after the <a href="https://github.com/account/enterprises/new?users_type=metered_ghe">30 day trial expires</a>) for the single admin user required to setup the pipelines/credentials:</p><figure><img alt="A pre-filled sign-up page for GitHub Enteprise Cloud" src="https://cdn-images-1.medium.com/max/1024/1*DHQ1qOlvchoyR-b8CsI2mA.png" /></figure><h3>Configuring and Streaming Audit Logs</h3><p>Once our GitHub <em>Enterprise</em> is created, we need to configure the Audit Log settings which will be shared across every <em>Organization</em> in our <em>Enterprise</em>. In particular we need to enable <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/displaying-ip-addresses-in-the-audit-log-for-your-enterprise">source IP disclosure</a> and <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/streaming-the-audit-log-for-your-enterprise#enabling-audit-log-streaming-of-api-requests">API request events</a>:</p><figure><img alt="Configuration settings for GitHub audit log streaming with “Enable source IP disclosure” and “Enable API Request Events” checked" src="https://cdn-images-1.medium.com/max/1024/1*wU2H5CF3f83BrXZp4DyKfw.png" /></figure><p>It’s worth noting that GitHub has some (frankly absurd) <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/displaying-ip-addresses-in-the-audit-log-for-your-enterprise#events-that-display-ip-addresses-in-the-audit-log">opinions</a> about <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/displaying-ip-addresses-in-the-audit-log-for-your-enterprise#events-that-display-ip-addresses-in-the-audit-log">which audit events can contain source IPs</a>. You cannot see source IPs for <em>any</em> activity involving a <em>public</em> repository (you know, the ones being targeted in all these supply chain attacks). Generally, it’s a reasonable stance that I shouldn’t get to see the source IP/actor behind some unassociated GitHub user performing a ‘git clone’ of my public repository.</p><blockquote><em>However, refusing to include source IPs on </em><strong><em>write</em></strong><em> actions like a </em><em>git.push to a protected branch is unreasonable. Especially when the user is inherently a member/admin of the organization, acting on a public repository owned by said organization. This will force us to exclusively use </em>private/internal<em> repositories as our targets for our canarytokens.</em></blockquote><p>Although <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/audit-log-events-for-your-enterprise#api">git audit log events</a> can be obtained by polling <a href="https://docs.github.com/en/enterprise-cloud@latest/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/audit-log-events-for-your-organization#git">the REST API</a>, the <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/audit-log-events-for-your-enterprise#api">api.request</a> event is only available when using <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/streaming-the-audit-log-for-your-enterprise">audit log streaming</a>, largely due to the event volume. The <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/streaming-the-audit-log-for-your-enterprise">audit log streaming</a> feature supports <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/streaming-the-audit-log-for-your-enterprise#setting-up-audit-log-streaming">a variety of destinations</a> including AWS S3 buckets, Google Cloud Storage, Azure Event Hub, etc.</p><p>For the purposes of building a PoC, we want minimal setup/dependencies so I’m going to focus on the <a href="https://help.splunk.com/en/splunk-enterprise/get-started/get-data-in/10.2/get-data-with-http-event-collector/set-up-and-use-http-event-collector-in-splunk-web">Splunk HTTP Event Collector (HEC)</a> destination which can be configured with an arbitrary hostname/port. When events arrive, GitHub will emit them as an HTTP POST to the <a href="https://help.splunk.com/en/splunk-enterprise/leverage-rest-apis/rest-api-reference/10.2/input-endpoints/input-endpoint-descriptions#ariaid-title52">/services/collector POST endpoint</a>:</p><pre>POST /services/collector HTTP/1.1<br>Host: cf-github-canarytokens.bored-engineer.workers.dev:443<br>Accept-Encoding: gzip<br>Authorization: Splunk hunter2<br>Connection: close<br>Content-Length: 1337<br>Content-Type: application/json<br>User-Agent: Go-http-client/1.1<br><br>{&quot;event&quot;:{...},&quot;time&quot;:&quot;1776463522.955&quot;}{&quot;event&quot;:{...},&quot;time&quot;:&quot;1776463524.123&quot;}</pre><p>We can pretend to be a compliant Splunk HEC collector by returning a successful JSON response (as well as answering <a href="https://help.splunk.com/en/splunk-enterprise/leverage-rest-apis/rest-api-reference/10.2/input-endpoints/input-endpoint-descriptions#ariaid-title57">health-check requests</a>). Annoyingly, the events are delivered without any delimiter like a newline separating them which makes parsing using <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse">JSON.parse</a> fail. I wrote <a href="https://gist.github.com/bored-engineer/7e2008dc9e031bcab5089e37beb61f9a">a simple Cloudflare worker</a> which handles this format and just logs each incoming event:</p><figure><img alt="Configuration for a Splunk audit log destination in GitHub showing a Cloudflare worker domain/port." src="https://cdn-images-1.medium.com/max/1024/1*UEwLWrdu4xqvll2Je85OzA.png" /></figure><p>A few seconds later, audit events begin to arrive:</p><figure><img alt="Screenshot of the Cloudflare observability conosle showing successful delivery of a “audit_log_streaming.check” event" src="https://cdn-images-1.medium.com/max/1024/1*32OqR2XoylNZj2J5lWdsIw.png" /></figure><h3>Organization and Repository Setup</h3><p>With audit logs configured, we need to create our fake <em>Organization</em> under our Enterprise. I suggest a name that closely matches your actual public GitHub organization and repository which will appear enticing enough for an attacker to clone/access.</p><figure><img alt="Screenshot of the GitHub organization creation page with the name “contoso-private” pre-filled" src="https://cdn-images-1.medium.com/max/918/1*QRp11ndhzMa5Bt1-y8hYkA.png" /></figure><p>Next, we need to create an <em>internal/private</em> repository. I’d suggest populating the repository with some amount of content (ex: AI slop terraform) or at least a README.md so it’s not an empty/uninitialized repository:</p><figure><img alt="Screenshot of the GitHub repository creation page with a repostory named “infra-secrets” and description “Infrastructure Secrets Backup - Restricted Access”" src="https://cdn-images-1.medium.com/max/1024/1*C1vNJdoQsO0a9ZmTYTAQzg.png" /></figure><h3>Creating a SSH Deploy Key</h3><p>Now that we have all the audit logging components setup, we can create our first canary credential, starting with <a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent">an SSH key</a> as they are the easier to reason about than API tokens. Specifically, <a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys">deploy keys</a> are an SSH key that is specific to an individual GitHub repository instead of a GitHub user and default to read-only access<em>:</em></p><figure><img alt="Screenshot of the GitHub deploy key creation page with a SSH public key and a title of “canary”" src="https://cdn-images-1.medium.com/max/1024/1*cNN8atJeqrHfOjOSmHLszA.png" /></figure><p>If we SSH to <a href="https://github.com">github.com</a> using the newly created key (which <a href="https://docs.github.com/en/rest/deploy-keys/deploy-keys?apiVersion=2026-03-10#create-a-deploy-key">can also be created via the REST API</a>), the response will direct the attacker to our repository:</p><figure><img alt="Screenshot of an SSH session to “git@github.com” using “canary.key” which returns “Hi contoso-private/infra-secrets!” before exiting" src="https://cdn-images-1.medium.com/max/1024/1*f2VUY485P_y9B8KAkpqx2w.png" /></figure><p>When we use this SSH key to clone the repository, we receive a git.clone audit event that includes the source IP and even the build/version of git used!</p><pre>{<br>  &quot;repository_public&quot;: false,<br>  &quot;_document_id&quot;: &quot;PHelWiQ6to2YlxnWOph03A==&quot;,<br>  &quot;action&quot;: &quot;git.clone&quot;,<br>  &quot;actor&quot;: &quot;deploy_key&quot;,<br>  &quot;actor_ip&quot;: &quot;203.0.113.37&quot;,<br>  &quot;actor_location&quot;: {<br>    &quot;country_code&quot;: &quot;US&quot;<br>  },<br>  &quot;business&quot;: &quot;canary-bored-engineer&quot;,<br>  &quot;hashed_token&quot;: &quot;+qycZFngYHBoZcx/+Ri5lIYs8FnI8t0DPZpEECxeuLc&quot;,<br>  &quot;org&quot;: &quot;contoso-private&quot;,<br>  &quot;programmatic_access_type&quot;: &quot;Public Key (User/Deploy)&quot;,<br>  &quot;repo&quot;: &quot;contoso-private/infra-secrets&quot;,<br>  &quot;repository&quot;: &quot;contoso-private/infra-secrets&quot;,<br>  &quot;request_access_security_header&quot;: &quot;&quot;,<br>  &quot;request_id&quot;: &quot;d389dcd282ff50415c7b41c0d1fd09ed&quot;,<br>  &quot;transport_protocol_name&quot;: &quot;ssh&quot;,<br>  &quot;user&quot;: &quot;&quot;,<br>  &quot;user_agent&quot;: &quot;git/2.53.0-Darwin&quot;,<br>  &quot;@timestamp&quot;: 1776398159553,<br>  &quot;actor_id&quot;: 0,<br>  &quot;business_id&quot;: 588174,<br>  &quot;org_id&quot;: 275559016,<br>  &quot;repository_id&quot;: 1208913072,<br>  &quot;token_id&quot;: 0,<br>  &quot;transport_protocol&quot;: 2,<br>  &quot;user_id&quot;: 0<br>}</pre><h3>What about User SSH Keys?</h3><p>As a (more popular) alternative to <a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh/managing-deploy-keys">deploy keys</a>, every GitHub user <a href="https://github.com/settings/keys">can configure</a> SSH keys associated with their account instead of a repository. While this can also be done <a href="https://docs.github.com/en/rest/users/keys?apiVersion=2026-03-10#create-a-public-ssh-key-for-the-authenticated-user">programmatically via the REST API</a>, these SSH keys are more powerful and are publicly accessible making them poorly suited for canarytokens:</p><blockquote><em>Specifically, there are no fine-grained authorization controls for SSH keys on GitHub, every SSH key attached to your user will have full read/write access to every repository your GitHub user has access to (with the exception of </em><a href="https://docs.github.com/en/enterprise-cloud@latest/admin/managing-iam/using-saml-for-enterprise-iam/configuring-saml-single-sign-on-for-your-enterprise"><em>organizations that enforce SSO</em></a><em> which </em><a href="https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-single-sign-on/authorizing-an-ssh-key-for-use-with-single-sign-on"><em>require a one-time approval/authorization</em></a><em> before the SSH key can be used).</em></blockquote><p>Additionally, it’s entirely possible for an attacker who has found an SSH private key to check if it’s valid and what GitHub user it corresponds to without actually using it. The SSH (public) keys for a user can be trivially accessed by adding ‘.keys’ to their GitHub URL, ex: <a href="https://github.com/bored-engineer.keys">https://github.com/bored-engineer.keys</a></p><p>This feature is what allows you to <a href="https://www.jeffgeerling.com/blog/2025/setting-ubuntu-desktop-installation-ssh-quickly/">quickly add your SSH keys to a new Ubuntu install</a> and what powers the wonderful <a href="https://whoami.filippo.io">whoami.filippo.io</a>. Some secret scanners like <a href="https://trufflesecurity.com/blog/driftwood">trufflehog will do this out of the box</a>. An educated attacker may be able to identify patterns in our ‘canary’ GitHub users, especially if they are being shared across multiple unrelated GitHub organizations. This violates our goal of making it difficult to check the validity of the credential without detonating it.</p><p>A final problem is discoverability, when an SSH key is found by a secret scanner, there’s nothing that will immediately lead the attacker to the canary repository where we get audit events for clones. Some tools like <a href="https://github.com/trufflesecurity/trufflehog">trufflehog</a> will attempt to <a href="https://github.com/trufflesecurity/trufflehog/blob/794a6e5211d8b9c554a27a582c68e71189c5c33a/pkg/detectors/privatekey/ssh_integration.go#L117">directly SSH</a> to GitHub to obtain the username, but our attacker still won’t know the name of our private repository to clone and trigger an alert unlike with deploy keys:</p><figure><img alt="Screenshot of an SSH session to “git@github.com” using “canary.key” which returns “Hi bored-engineer!” before exiting" src="https://cdn-images-1.medium.com/max/1024/1*1_OmUxrw0CcgnQ8XpmBYGg.png" /></figure><p>As such, I would strongly suggest using deploy SSH keys instead of user SSH keys for canarytokens.</p><h3>Personal Access Tokens</h3><p>With SSH credentials figured out, we can move on to <a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens">personal access tokens (PATs)</a>. These are a far more interesting target for attackers as it allows them to enumerate private repositories, pulls, issues, etc. However, as we’ll soon discover, they are quite a bit messier to build alerting for compared to SSH keys.</p><p>Unfortunately, one of the biggest limitations with PATs is they cannot be created programmatically (at least without <a href="https://bpi.com/screen-scraping-what-is-it-and-how-does-it-work/">screen scraping</a> the main <a href="https://github.com">github.com</a> UI). This will significantly limit the ability for commercial Canary vendors to offer the tokens as it requires manual steps by a human to provision.</p><p>Most GitHub API users are familiar with “<a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic">classic</a>” PATs, <a href="https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/">identified by the ‘ghp_’ prefix</a>. These PATs use broad permissions such as ‘repo’ which grants read/write to all private repositories accessible to a user. Because this also permits dangerous actions like deleting the canary repository, I would not recommend classic PATs. With the said, it is possible to create “safer” classic PATs by using a dedicated GitHub user who has restricted permissions (read-only) for the canary GitHub organization.</p><p>Instead of classic PATs, <a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token">fine-grained personal access tokens</a> (identified by the github_pat_ prefix) can be used which grant permissions on specific GitHub repos:</p><figure><img alt="Screenshot of the fine-grained personal access token configuration page showing the “Contents” and “Metadata” permissions being granted to the “contoso-private/infra-secrets” repository" src="https://cdn-images-1.medium.com/max/1024/1*MzMfOP_ZcO5sKcZEucj4Pg.png" /></figure><p>We can then use this token via the <a href="https://docs.github.com/en/rest">REST API</a> or <a href="https://docs.github.com/en/graphql">GraphQL API</a> to access any details about our canary repository (or clone the contents) the same way an attacker might if they had discovered it:</p><figure><img alt="Screenshot of an terminal session with an exported GITHUB_TOKEN environment variable. Token is used to fetch the description of the contoso-private/infra-secrets repostory via the REST API and then perform a “git clone” that repository." src="https://cdn-images-1.medium.com/max/1024/1*SA65wob9pgKDTIq84RUwug.png" /></figure><p>Shortly after, an audit event will be sent containing the source IP, User-Agent and normalized request URL/body:</p><pre>{<br>  &quot;actor_is_bot&quot;: false,<br>  &quot;public_repo&quot;: false,<br>  &quot;_document_id&quot;: &quot;tWjPbax39pvd8MyeK-rZCA&quot;,<br>  &quot;action&quot;: &quot;api.request&quot;,<br>  &quot;actor&quot;: &quot;canary-bored-engineer&quot;,<br>  &quot;actor_ip&quot;: &quot;203.0.113.37&quot;,<br>  &quot;actor_location&quot;: {<br>    &quot;country_code&quot;: &quot;US&quot;<br>  },<br>  &quot;business&quot;: &quot;canary-bored-engineer&quot;,<br>  &quot;hashed_token&quot;: &quot;CUQ+aPiFMrcEOLXuuZ+0MDlCyaV5YXRoTkNgQMke5BE=&quot;,<br>  &quot;operation_type&quot;: &quot;access&quot;,<br>  &quot;org&quot;: &quot;contoso-private&quot;,<br>  &quot;programmatic_access_type&quot;: &quot;Fine-grained personal access token&quot;,<br>  &quot;query_string&quot;: &quot;&quot;,<br>  &quot;repo&quot;: &quot;contoso-private/infra-secrets&quot;,<br>  &quot;request_body&quot;: &quot;&quot;,<br>  &quot;request_id&quot;: &quot;A381:1F948A:17901F3:17F0724:69E1D1D7&quot;,<br>  &quot;request_method&quot;: &quot;GET&quot;,<br>  &quot;route&quot;: &quot;/repositories/:repository_id/pulls&quot;,<br>  &quot;url_path&quot;: &quot;/repositories/1208913072/pulls&quot;,<br>  &quot;user&quot;: &quot;canary-bored-engineer&quot;,<br>  &quot;user_agent&quot;: &quot;GitHub CLI 2.89.0&quot;,<br>  &quot;user_programmatic_access_name&quot;: &quot;ci-canary&quot;,<br>  &quot;@timestamp&quot;: 1776407000157,<br>  &quot;actor_id&quot;: 272395934,<br>  &quot;business_id&quot;: 588174,<br>  &quot;created_at&quot;: 1776407000157,<br>  &quot;org_id&quot;: 275559016,<br>  &quot;rate_limit_remaining&quot;: 4994,<br>  &quot;repo_id&quot;: 1208913072,<br>  &quot;status_code&quot;: 200,<br>  &quot;token_id&quot;: 13603103,<br>  &quot;user_id&quot;: 272395934<br>}</pre><h3>Cracks in API Audit Logs</h3><p>Unfortunately, the gaps in GitHub audit logs begin to surface as we move on to PATs. Because audit logs are associated with a GitHub <em>Organization, </em>we will only see events related to <em>Organization</em> resources.</p><p>For example, <a href="https://github.com/mongodb/kingfisher/blob/be0ce3bae0b14240bb2781ab6ee2b5c65e02144b/crates/kingfisher-rules/data/rules/github.yml#L80">the first request</a> made using a compromised API token is typically <a href="https://docs.github.com/en/rest/users">the /user endpoint</a> to obtain the id/login of the user (and associated permissions via the ‘x-oauth-scopes’ header). This is NOT an organization resource and as such no audit log event will be generated when this request is made.</p><p>What’s unexpected, is that some endpoints like <a href="https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#list-repositories-for-the-authenticated-user">/user/repos</a> will list every repository a user has access to (including organization ones) <em>without</em><strong> </strong>generating an audit log event. This is why it’s critical to create legitimate looking orgs/repositories that an attacker will be interesting in exploring further.</p><blockquote>Interestingly, <a href="https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo">forked repositories</a> are a weird middle-ground to this logic. If you fork a <em>private</em> organization repository into your personal account, it will continue to emit organization audit logs when you interact with the fork via git or the REST/GraphQL API.</blockquote><p>As best I can tell, GitHub only emits api.request audit logs from the REST API when the URL path includes /repos/{owner}/{repo} or /orgs/{org} (where the organization has audit logs enabled). This means in addition to <a href="https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#list-repositories-for-the-authenticated-user">/user/repos</a>, the entire <a href="https://docs.github.com/en/rest/search/search?apiVersion=2026-03-10">/search</a> family of endpoints can be used to access private organization code, commits, issues, pulls, etc using a basic org:contoso-private query without generating a single audit event.</p><p>Even within this simple logic, there appears to be gaps! Reading repository files <a href="https://docs.github.com/en/rest/repos/contents?apiVersion=2026-03-10#get-repository-content">via <em>/repos/{owner}/{repo}/contents/{path}</em></a><em>,</em> <strong>does not generate any audit events</strong>, despite the <a href="https://docs.github.com/en/rest/repos/contents?apiVersion=2026-03-10#download-a-repository-archive-tar">tarball</a> and <a href="https://docs.github.com/en/rest/repos/contents?apiVersion=2026-03-10#download-a-repository-archive-zip">zipball</a> APIs right next to it doing so!</p><p>The GraphQL API is even worse, as long as you avoid <em>directly</em> returning a <a href="https://docs.github.com/en/graphql/reference/objects#repository">Repository</a> or <a href="https://docs.github.com/en/graphql/reference/objects#organization">Organization</a> object, you can perform all the queries in the world without generating audit events. For example, using the <a href="https://docs.github.com/en/graphql/reference/queries#search">search query</a> with any ISSUES or DISCUSSION type grants access to issues, PRs (a sub-type of Issue), discussions, etc.</p><p>With a bit of knowledge about how GitHub GraphQL node_id values are constructed, as well as a repo ID (ex: 12345) obtained from <a href="https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#list-repositories-for-the-authenticated-user">/user/repos</a>, we can skip right past the <a href="https://docs.github.com/en/graphql/reference/objects#repository">Repository</a> object. For example, the node_id of a git <a href="https://docs.github.com/en/graphql/reference/objects#ref">Ref</a> is <a href="https://gchq.github.io/CyberChef/#recipe=To_MessagePack()To_Base64(&#39;A-Za-z0-9-_&#39;)Find_/_Replace(%7B&#39;option&#39;:&#39;Regex&#39;,&#39;string&#39;:&#39;%5E&#39;%7D,&#39;REF_&#39;,true,false,true,false)&amp;input=WwogICAgMCwKICAgIDEyMzQ1LAogICAgInJlZnMvaGVhZHMvbWFpbiIKXQ">simply</a> &quot;REF_&quot; + base64url(msgpack([0,12345,&quot;refs/heads/main&quot;]) which can be directly used in a <a href="https://docs.github.com/en/graphql/reference/queries#node">node</a> query to read repository contents:</p><pre>query {<br>  node(id:&quot;REF_kwDNMDmvcmVmcy9oZWFkcy9tYWlu&quot;) {<br>    ... on Ref {<br>      target {<br>        ... on Commit {<br>          file(path: &quot;README.md&quot;) {<br>            object {<br>              ... on Blob {<br>                text<br>              }<br>            }<br>          }<br>        }<br>      }<br>    }<br>  }<br>}</pre><p>These gaps extend to GraphQL <a href="https://docs.github.com/en/graphql/reference/mutations">mutations</a> as well, such as <a href="https://docs.github.com/en/graphql/reference/mutations#updateref">updateRef</a> which can be used to effectively force push branches/tags without any audit events (api.request or git.push) being emitted!</p><p>A draft of this post highlighting the gaps in audit logs was shared with GitHub on 4/19/2026 who stated they will take this research into account as they continue to build out the audit logging roadmap.</p><h3>Building a (broken) Safety Net</h3><p>Sadly, these sort of audit logging gaps are pretty common problem for multi-tenant SaaS providers. For example, in AWS there are multiple ways to <em>test</em> IAM credentials without generating a CloudTrail event (see <a href="https://www.youtube.com/watch?v=R75ZTBnUwXk">Nick Frichette’s wonderful talk at fwd:cloudsec</a>).</p><p>To solve this gap with AWS canarytokens, Thinkist developed the “<a href="https://blog.thinkst.com/2022/02/a-safety-net-for-aws-canarytokens.html">AWS Safety Net</a>” which periodically polls the <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_getting-report.html">credential report</a> API that returns a last_used_date for each IAM access token and is <em>always </em>updated when a token is used. You won’t get access to the IP address/User-Agent, but at least you’ll know the credential was compromised wherever it was stored and can begin to roll incident response.</p><p>We can see in the GitHub UI that a similar “last used” timestamp is tracked for PATs and SSH keys:</p><figure><img alt="Screenshot of the fine-grained personal access tokens page which shows a token named “canary” with a “Last used within the last week” subtext" src="https://cdn-images-1.medium.com/max/1024/1*m3XW_xQZpRh4W1nPyIAVXg.png" /></figure><p>After some digging, the raw timestamp is accessible via the <a href="https://docs.github.com/en/rest/orgs/personal-access-tokens?apiVersion=2026-03-10#list-fine-grained-personal-access-tokens-with-access-to-organization-resources">/orgs/{org}/personal-access-tokens REST API</a> which returns a token_last_used_at field. This API does come with a few restrictions, it only works when <a href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation">authenticated using a GitHub App installation</a> and only returns fine-grained PATs (no classic PATs). The <a href="https://docs.github.com/en/rest/users/keys?apiVersion=2026-03-10#list-public-ssh-keys-for-the-authenticated-user">/user/keys</a> and <a href="https://docs.github.com/en/rest/deploy-keys/deploy-keys?apiVersion=2026-03-10#list-deploy-keys">/repos/{owner}/{repo}/keys</a> REST APIs can be used for similar purposes for SSH keys though it has slightly less value.</p><blockquote><em>However, </em><strong><em>it seems the “last used” tracking for PATs is broken</em></strong><em>. For classic PATs the field is just never updated at all anymore, even when a token is actively being used to read (or even write) organization resources (hope you haven’t relied on that for any incident response!)</em></blockquote><p>For fine-grained PATs, the behavior is inconsistently broken. Some critical actions such as git clone/git fetch/git push or using <a href="https://docs.github.com/en/rest/repos/contents?apiVersion=2026-03-10#get-repository-content">the previously mentioned contents<em> REST API</em></a><em> </em>do <em>not</em> update the token_last_used_at field (the token will incorrectly show as “Never used” in the UI). Most other API calls <em>do</em> populate the field, however after it’s been populated it remains fixed at that initial timestamp.</p><p>What I suspect is happening here is that GitHub has implemented a roughly 1-week backoff for updates to this field, otherwise every API request (even read-only ones able to be served from cache) require a DB write. We can see this in the UI with the wording “Last used within the last week” rather than an exact timestamp, they’ve just forgotten that the raw timestamp is exposed via the API which <em>appears </em>to return per-second precision...</p><h3>A slow descent into madness</h3><p>At this point I was ready to give up, publish this blog post, and claim it was impossible to build the “perfect” GitHub canarytokens. At best we can use audit-logs to alert on <em>most</em> common actions, and maybe we can detect <em>some</em> other usage via the polling the “last used” for each credential with a granularity of ~1 week. To be clear, this is still pretty good and worth implementing, but we can do better….</p><p>In the middle of the night, the solution hit me: GitHub may not be willing to handle a DB write for every API request just for some “last used” field in the UI, but they can’t afford to ignore API abuse and scraping...</p><p>The GitHub REST and GraphQL APIs both have <a href="https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28#about-primary-rate-limits">primary rate limits</a> which limit the number of HTTP requests you can make per-hour. By default, this is 5,000 requests/points per hour for core/graphql resources. <em>Every</em> API request will consume a rate-limit by at least 1 point.</p><blockquote><em>We can use the raw/plaintext canarytoken PATs to frequently poll the </em><a href="https://docs.github.com/en/rest/rate-limit/rate-limit?apiVersion=2026-03-10#get-rate-limit-status-for-the-authenticated-user"><em>/rate_limit REST API</em></a><em> (which ironically does not consume any rate-limits) and compare with the last seen value. If the </em><em>used field for any rate type (core, graphql, etc) increases, the token has been compromised and we need to trigger an alert! Of course in practice, there are few other edge cases to account for:</em></blockquote><p>The rate limit resets every hour so there’s a small window where an attacker might use a token just before it resets and we’d never know as it’s still at 5,000 during the next poll. Thankfully, GitHub <a href="https://github.blog/engineering/infrastructure/how-we-scaled-github-api-sharded-replicated-rate-limiter-redis/">uses redis</a> to store these rate-limits, populating an expires_at property with the timestamp when the limit will reset. If a PAT is never actually used, no redis key gets created, and no reset gets scheduled. You can see this if you use a brand-new PAT to poll <a href="https://docs.github.com/en/rest/rate-limit/rate-limit?apiVersion=2026-03-10#get-rate-limit-status-for-the-authenticated-user">/rate_limit</a>, the reset timestamp is always 1 hour in the future, effectively a placeholder.</p><p>A really crafty attacker might think to abuse our PAT and then quickly use <a href="https://docs.github.com/en/rest/credentials/revoke?apiVersion=2026-03-10">the /credentials/revoke API</a> to invalidate it before our next scheduled poll of <a href="https://docs.github.com/en/rest/rate-limit/rate-limit?apiVersion=2026-03-10#get-rate-limit-status-for-the-authenticated-user">/rate_limit</a>, preventing us from identifying the number of requests made. So we’ll need to alert if the API response indicates a credential was revoked as well.</p><p>Finally, rate-limits (for PATs) are per-user, not per-token which means if we have multiple tokens owned by the same GitHub user, we won’t be able to identify which specific token was compromised/used. This also makes any canarytokens owned by a “real” user who consumes API rate-limit normally (such as via the GitHub CLI) impractical.</p><h3>Putting it all together into a PoC</h3><p>To build a end-to-end PoC, I modified the previously mentioned Cloudflare worker to publish basic alerts to Slack using <a href="https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/">incoming webhooks</a>:</p><p><a href="https://github.com/bored-engineer/cf-github-canarytokens">GitHub - bored-engineer/cf-github-canarytokens: GitHub Canarytoken PoC</a></p><p>Alerting on specific audit events is as simple as filtering on the incoming event.hashed_token field for the known canarytoken credentials (specified via GITHUB_CREDENTIALS enviroment variable/secret):</p><figure><img alt="Screenshot of a Slack message posted by “GitHub Canarytokens” containing a “GitHub Audit Log Alert”. Message contains the timestamp, token used, URL, action (“api.request”), source IP and User Agent (“GitHub CLI 2.90.0”). The raw event is attached as well as comment indicating the location where the token was stored (“Stored in ~/.netrc on self-hosted ubuntu GitHub Actions runners”)" src="https://cdn-images-1.medium.com/max/1024/1*l_sX8KCKGg5DIYMk5xedEw.png" /></figure><p>To implement the “Safety Net” functionality I used <a href="https://developers.cloudflare.com/kv/">Workers KV</a> to track the last response from /rate_limit per each token. When the <a href="https://developers.cloudflare.com/workers/configuration/cron-triggers/">Cron Trigger</a> runs (every 5 minutes) it alerts if the used field has increased for any resource type:</p><figure><img alt="Screenshot of a Slack message posted by “GitHub Canarytokens” containing a “GitHub Rate Limit Alert”. Message contains the timestamp, token used, rate-limit type (“graphql”). A comment indicating the location where the token was stored (“Stored in ~/.netrc on self-hosted ubuntu GitHub Actions runners”) is attached." src="https://cdn-images-1.medium.com/max/1024/1*7Vt7ZG99r84gXPCmmijhOA.png" /></figure><p>Revocation is also detected within the same process when polling the rate-limits fails:</p><figure><img alt="Screenshot of a Slack message posted by “GitHub Canarytokens” containing a “GitHub Token Revoked Alert”. Message contains the timestamp, token used and a comment indicating the location where the token was stored (“Stored in ~/.netrc on self-hosted ubuntu GitHub Actions runners”)." src="https://cdn-images-1.medium.com/max/1024/1*pbfr2hCAauTaP38smrtqCw.png" /></figure><p>I would love to see the Canarytoken vendors take this research and implement it into their products, if you’d like to do so and want to chat more, feel free to <a href="https://www.linkedin.com/in/bored-engineer/">reach out on LinkedIn</a>. For corporations that are already using the GitHub Audit Log streaming feature, it should be trivial to build similar alerting functionality directly in their SIEM with a small <a href="https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/">CronJob</a> to implement the safety check functionality.</p><h3>Recommendations for GitHub organizations</h3><p>If you own a GitHub organization/enterprise, the visibility gaps in audit logs discussed in this post may have scared you (and they probably should!) but there are still some things you can do today to reduce risk:</p><ul><li>Enable <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/monitoring-activity-in-your-enterprise/reviewing-audit-logs-for-your-enterprise/streaming-the-audit-log-for-your-enterprise">audit log streaming</a> on your enterprise including source IPs and API requests, even if it’s just going to an S3 bucket nobody looks at it, your incident response team will thank you later.</li><li>Enforce <a href="https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-single-sign-on/about-authentication-with-single-sign-on">the use of SSO on your GitHub organization</a>, not just because SSO is good but because it forces <a href="https://docs.github.com/en/enterprise-cloud@latest/authentication/authenticating-with-single-sign-on/authorizing-an-ssh-key-for-use-with-single-sign-on">an explicit authorization action</a> by users to grant an SSH key/PAT access to your organization resources, instead of granting access implicitly. That way the PAT created for someone’s weekend project won’t have access to your organization resources.</li><li>Enforce <a href="https://docs.github.com/en/enterprise-cloud@latest/organizations/keeping-your-organization-secure/managing-security-settings-for-your-organization/managing-allowed-ip-addresses-for-your-organization">an IP allowlist</a> for your organization from a set of known trusted VPN/corporate IPs. This is by-far the strongest control (and the most painful to rollout) as it will prevent stolen credentials (even if still valid) from being used by an attacker except on the intended systems where you (hopefully) have other visibility/alerting via EDR or related tooling.</li><li>If you can, <a href="https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization#restricting-access-by-personal-access-tokens">restrict access from personal access tokens to your organization resources</a>. Blocking classic PATs and enforcing a maximum expiration (ex: 3 months) on fine-grained PATs is a great way to reduce risk if you can’t <a href="https://edu.chainguard.dev/open-source/octo-sts/overview/">eliminate PATs altogether</a>.</li><li>If you use GitHub enterprise (on-prem), configure collection of <a href="https://docs.github.com/en/enterprise-server@3.16/admin/monitoring-and-managing-your-instance/monitoring-your-instance/about-system-logs#log-files-for-the-http-server">the raw HTTP access logs</a> in addition to native GitHub audit logs, it may prove critical during incident response.</li></ul><p>If you are <a href="https://xkcd.com/2347/">some random person in Nebraska</a> who maintains a critical repository under your GitHub user, please consider moving it under an organization so you can gain access to the above controls.</p><h3>My requests for GitHub</h3><p>In the hopes someone who works on audit logs at GitHub has made it this far into the post, please consider my (roughly prioritized) list of feature requests/fixes:</p><ul><li>Resolve the gaps in api.request audit log events from the REST API when the response returns any organization-owned resources (ex: <a href="https://docs.github.com/en/rest/repos/repos?apiVersion=2026-03-10#list-repositories-for-the-authenticated-user">/user/repos</a>, <a href="https://docs.github.com/en/rest/search/search?apiVersion=2026-03-10">/search</a>, <a href="https://docs.github.com/en/rest/repos/contents?apiVersion=2026-03-10#get-repository-content"><em>/repos/{owner}/{repo}/contents/{path}</em></a>, etc)</li><li>Resolve the gaps in api.request audit log events from the GraphQL API when the response returns or modifies organization-owned resources (ex: <a href="https://docs.github.com/en/graphql/reference/queries#node">node</a>, <a href="https://docs.github.com/en/graphql/reference/queries#nodes">nodes</a>, <a href="https://docs.github.com/en/graphql/reference/queries#resource">resource</a> queries or <a href="https://docs.github.com/en/graphql/reference/mutations#reopenpullrequest">mutations</a> that accept a node ID)</li><li>Resolve the bugs that are preventing updates to the last_used fields for SSH keys and PATs. <br>- For SSH keys this should include ssh git@github.com sessions used by secrets scanners that don’t invoke git-shell.<br>- For API keys this should include <a href="https://docs.github.com/en/rest/users">the /user endpoint</a> used by secret scanners.<br>- If the ~1 week backoff for updates to this field is intentional/necessary, please document it clearly.</li><li><em>Please</em> reconsider your position on source IP disclosure for audit log events involving public repositories, in particular if the actor is a member of the organization and/or has any privileged access to the repository.</li><li>Add more details to the git.{clone,fetch,push} audit events, in particular a list of OIDs being fetched/pushed could be incredibly helpful during incident response.</li><li>Emit a git.push audit event when a repository is modified using the REST/GraphQL APIs such as via the <a href="https://docs.github.com/en/graphql/reference/mutations#updateref">updateRef mutation</a>, or the <a href="https://docs.github.com/en/rest/repos/contents#create-or-update-file-contents">/repos/{owner}/{repo}/contents/{path}</a> endpoint.</li><li>Consider adding an API for <em>provisioning</em> of fine-grained PATs, this would at least allow automated rotation for APIs where <a href="https://edu.chainguard.dev/open-source/octo-sts/overview/">a GitHub app cannot be used</a> (and it would allow automated canarytoken provisioning).</li><li>Consider adding an audit log (streaming) feature for api.request and git.{clone,fetch,push} events by a GitHub app owners. This would allow app owners to pro-actively monitor for abuse of their GitHub app installation tokens/OAuth tokens (ex: <a href="https://www.heroku.com/blog/april-2022-incident-review/">the 2022 Heroku breach</a>)<br>- At the very least if a GitHub app has done the right thing and configured <a href="https://docs.github.com/en/apps/maintaining-github-apps/managing-allowed-ip-addresses-for-a-github-app">IP allowlisting</a>, send an email/alert when an otherwise valid token is blocked due to an invalid source IP, indicating compromise.</li><li>Consider allowing users to opt-in to audit logs as well (not just security logs). I don’t care if you have to charge money for it, or gate it behind owning repositories with enough stars, as we continue to see more supply chain attacks there’s too many high-value repositories owned by GitHub users instead of organizations.</li><li>I’ve avoided mention of <a href="https://docs.github.com/en/enterprise-cloud@latest/admin/concepts/identity-and-access-management/enterprise-managed-users">Enterprise Managed Users (EMUs)</a> (which actually allows programatic access to <a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/reviewing-your-security-log">security log</a> events via audit log streaming) to reduce confusion and because they don’t add much additional value in the context of canarytokens. But they could! I would <em>love</em> to receive an api.request for <em>every</em> API call (regardless of if it targets an organization resource) when the actor is an EMU user and I think there’s a strong argument for supporting this.</li></ul><p>If you’re at GitHub and want to chat more about these, please don’t hesitate to <a href="https://www.linkedin.com/in/bored-engineer/">reach out</a>!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=5c9e36ad7ecf" width="1" height="1" alt=""><hr><p><a href="https://blog.bored.engineer/github-canarytokens-5c9e36ad7ecf">Building GitHub Canarytokens: A rant about Audit Log gaps</a> was originally published in <a href="https://blog.bored.engineer">bored.engineer</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[XSS on account.leagueoflegends.com via easyXDM [2016]]]></title>
            <link>https://blog.bored.engineer/xss-on-account-leagueoflegends-com-via-easyxdm-2016-75bcf9d582b5?source=rss-6b69d1e4b01d------2</link>
            <guid isPermaLink="false">https://medium.com/p/75bcf9d582b5</guid>
            <category><![CDATA[security]]></category>
            <category><![CDATA[information-security]]></category>
            <category><![CDATA[bug-bounty]]></category>
            <category><![CDATA[xss-vulnerability]]></category>
            <category><![CDATA[application-security]]></category>
            <dc:creator><![CDATA[Luke Young]]></dc:creator>
            <pubDate>Thu, 01 Dec 2022 16:55:18 GMT</pubDate>
            <atom:updated>2026-04-27T00:55:10.847Z</atom:updated>
            <content:encoded><![CDATA[<p><em>This post contains a chain of vulnerabilities I responsibly disclosed to </em><a href="https://hackerone.com/riot"><em>Riot Games</em></a><em> in November of 2016. I’m publicly disclosing it now as it’s an interesting and technically complex vulnerability. The issue has long since been resolved, so long ago that most of the infrastructure referenced in the report has been replaced.</em></p><figure><img alt="A browser alert box from ‘account.leagueoflegends.com’ indicating successful exploitation of the XSS issue" src="https://cdn-images-1.medium.com/max/1024/1*sVLywJQ-aZeEg1vm6GcRRQ.png" /></figure><h3>Background</h3><p>Various <a href="https://hackerone.com/riot">Riot Games</a> ‘<a href="https://www.leagueoflegends.com/en-us/">League of Legends</a>’ webpages need to access metadata about the currently logged-in player to properly function. Sometimes these webpages are not located on the same (sub)domain as account.leagueoflegends.com and therefore must access the information <a href="https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy">cross-origin</a>.</p><p>These days, this would be accomplished via <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS">Cross-Origin Resource Sharing (CORS)</a> and/or <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage">window.postMessage</a>, however back in 2016 browser support was inconsistent, <em>particularly if you needed to support users on much older browsers</em>. Many companies at the time, including Riot Games, turned to a library called <a href="http://easyxdm.net/">easyXDM</a>:</p><blockquote>easyXDM is a Javascript library that enables you as a developer to easily work around the limitation set in place by the Same Origin Policy, in turn making it easy to communicate and expose javascript API’s across domain boundaries. — <a href="http://easyxdm.net/">easyXDM.net</a></blockquote><p>This JavaScript library provides a normalized interface for cross-origin communication that is backed by different browser transports (examples include window.postMessage, Flash&#39;s LocalConnection, HashTransport, etc). The transport is selected based on which is “best” supported by the user’s browser.</p><p>When using easyXDM, there is a <em>producer</em> and a <em>consumer</em> webpage. The <em>producer</em> page exports one or more JavaScript functions which can then be invoked (from another origin) by the <em>consumer</em> page which receives the result. For Riot Games the <em>producer</em> was account.leagueoflegends.com/pm.html which exported the following methods:</p><ul><li>send: A wrapper function around jQuery.ajax allowing access to make requests/responses cross-origin</li><li>get-cookies: Retrieves cookies by name from document.cookie</li><li>set-cookies: Sets a cookie on the base domain via document.cookie</li></ul><p>Even on the surface, these functions appear quite dangerous so predictably there were some protections against access/abuse by arbitrary webpages:</p><p>When the pm.html page was loaded, the document.referrer was checked to verify the top level domain matched an allowlist:</p><ul><li>leagueoflegends.com</li><li>riotgames.com</li><li>lolesports.com</li><li>pvp.net</li><li>leagueoflegends.com.tr</li><li>lolespor.com</li><li>lolguilds.ru</li></ul><p>Additionally, when receiving a cross-origin message from the easyXDM transport, the message origin (as reported by easyXDM) was checked against the same allowlist before executing the corresponding function.</p><h3>A quick introduction to easyXDM</h3><p>Before launching into the vulnerabilities, I need to take a moment to explain how easyXDM works and establish some terminology. easyXDM webpages obtain context about their configuration from a series of query parameters in the URL:</p><ul><li>xdm_e (<em>config.remote</em>): The URL to load if the current page is a <em>consumer</em> or the URL of the parent page if the current page is a <em>producer</em></li><li>xdm_c (<em>config.channel</em>): The channel to use when sending messages</li><li>xdm_s (<em>config.secret</em>): The secret to use to validate both parties are known</li><li>xdm_p (<em>config.protocol</em>): The id of which protocol transport to use for communication as defined in <a href="https://github.com/oyvindkinsey/easyXDM/blob/7f23f9013745cfaef3fe90f0cb74a9abd00c7949/src/Core.js#L695">Core.js#L695</a></li></ul><p>Because this context/configuration is obtained from the query parameters it is possible for a malicious actor to manipulate these values which I’ll need later.</p><h3>Bypassing the Referrer Check</h3><p>First I needed to bypass the referrer check. One way this could be accomplished is by posting a link on the <a href="https://boards.na.leagueoflegends.com/">boards.na.leagueoflegends.com</a> forum (which matches the *.leagueoflegends.com referrer check) and hoping that a player clicks the link. However, this significantly limits the possible impact of any exploit as it would require specific user interaction.</p><p>A better exploit would be to utilize an <a href="https://portswigger.net/kb/issues/00500100_open-redirection-reflected">open-redirect</a> on any of the allowlisted domains. Unfortunately (or fortunately in this case for Riot) browsers have stopped carrying the Referrer header most on 301, 302, etc Location: based redirects. This leaves me looking for a JavaScript-based open redirect such as: window.location.href = &quot;${open_redirect}&quot;;.</p><p>Thankfully, it is possible to abuse easyXDM to accomplish this. There is a vulnerability with option handling in <a href="https://github.com/oyvindkinsey/easyXDM/blob/7f23f9013745cfaef3fe90f0cb74a9abd00c7949/src/stack/FrameElementTransport.js#L89-L92">FrameElementTransport.js</a>. After the transport has loaded it will perform the following check:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/71cc6d4ac8556e8bda0967af917caf72/href">https://medium.com/media/71cc6d4ac8556e8bda0967af917caf72/href</a></iframe><p>Since document.referrer won&#39;t match the provided xdm_e parameter it will redirect the top window. This is intended to prevent spoofing origins when using the FrameElementTransport. I can force easyXDM to use this vulnerable transport by specifying a xdm_p parameter like this:</p><pre><a href="http://provider.easyxdm.net/current/example/remotetransport.html?xdm_e=https%3A%2F%2Fattackerdoma.in&amp;xdm_c=channel&amp;xdm_p=5">http://provider.easyxdm.net/current/example/remotetransport.html?xdm_e=https%3A%2F%2Fattackerdoma.in&amp;xdm_c=channel&amp;xdm_p=5</a></pre><p>This method requires document.referrer to have a value for the payload to work. <em>Since 2016, browsers have gotten more aggressive about removing referrers so this may no longer work in 2022, at least without further changes.</em></p><p>Great, now I have a JS-based open-redirect, however I can’t use it on pm.html because there is a <a href="https://en.wikipedia.org/wiki/Catch-22_(logic)">catch-22</a> problem with the referrer checks. I needed to locate a <em>different</em> easyXDM consumer on one of the allowlisted domains to abuse. Thankfully there was another one at: <a href="http://apollo.na.leagueoflegends.com/apollo/cors/index.html">apollo.na.leagueoflegends.com/apollo/cors/index.html</a>.</p><p>Putting it all together, I have the following PoC which will load pm.html via the easyXDM open redirect on apollo.na.leagueoflegends.com, bypassing the referrer check:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/861dc813e3ce9294b28ceb846774f9ab/href">https://medium.com/media/861dc813e3ce9294b28ceb846774f9ab/href</a></iframe><h3>Bypassing Origin Check</h3><p>Next, I needed to bypass the origin checks on each message. Since the origin is provided by easyXDM, I needed to identify another bug/vulnerability in one of the transport implementations.</p><p>Eventually, I found one in <a href="https://github.com/oyvindkinsey/easyXDM/blob/master/src/stack/HashTransport.js">HashTransport</a> which works by passing data from a child iFrame to a parent window via window.location.hash. It works a little something like this:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/5f173e2467f090c12ef51d57212e4e28/href">https://medium.com/media/5f173e2467f090c12ef51d57212e4e28/href</a></iframe><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/88bb7f7eb26bc9f3fcb671386514b0f3/href">https://medium.com/media/88bb7f7eb26bc9f3fcb671386514b0f3/href</a></iframe><p>Because this implementation is quite a workaround, it is not possible for the parent page (subdomain1) to determine <em>who</em> updated location.hash, unlike other transports such as window.parent.postMessage which have a event.origin property. To get around this, easyXDM assumes all messages come from config.remote (see <a href="https://github.com/oyvindkinsey/easyXDM/blob/7f23f9013745cfaef3fe90f0cb74a9abd00c7949/src/stack/HashTransport.js#L113">HashTransport.js</a>).</p><p>This is great and exactly the type of bug I needed, however there is an issue with <em>where</em> our exploit code gets loaded in the chain. If I set config.remote to <a href="https://attackerdoma.in/">attackerdoma.in</a> to trigger our malicious page to load, all messages will have a non-allowlisted origin. But if I set it to a webpage on an allowlisted domain, our own webpage will never be loaded leaving us with no way to exploit the bug...</p><h4><a href="http://callbackhell.com/">Callback hell</a> with iFrames:</h4><p>Looping back around to our referrer bypass, I can use a very similar technique to get around this issue. I can’t use the exact same open redirect bug since it calls window.top.location (replacing the top level window) which would break our exploit chain. However, I can use one of the other protocols (in this case <a href="https://github.com/oyvindkinsey/easyXDM/blob/7f23f9013745cfaef3fe90f0cb74a9abd00c7949/src/stack/HashTransport.js">HashTransport</a> again) to force the apollo <em>consumer </em>to frame our attacker page, like this:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/965405fbb0c9a4cb8e7918449fa03f54/href">https://medium.com/media/965405fbb0c9a4cb8e7918449fa03f54/href</a></iframe><p>This results in the following nested frame layout:</p><pre><a href="https://account.leagueoflegends.com/pm.html">https://account.leagueoflegends.com/pm.html</a><br>└── <a href="https://apollo.na.leagueoflegends.com/apollo/cors/index.html">https://apollo.na.leagueoflegends.com/apollo/cors/index.html</a><br>    └── <a href="https://attackerdoma.in/">https://attackerdoma.in/</a>...</pre><p>At which point my script on <a href="https://attackerdoma.in/">attackerdoma.in</a> can send messages to the parent frame like this:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/d198c093e8c54659554e3e8701a4baf1/href">https://medium.com/media/d198c093e8c54659554e3e8701a4baf1/href</a></iframe><p>This approach isn’t perfect though: when pm.html wants to send a message back to apollo it will set location.hash for the second level iframe which I can&#39;t access from <a href="https://attackerdoma.in/">attackerdoma.in</a> context leaving me blind; I can send messages but not receive the reply...</p><h4>Bypassing VerifyBehavior.js:</h4><p>To throw another roadblock into the mix, easyXDM has already considered this attack and created <a href="https://github.com/oyvindkinsey/easyXDM/blob/7f23f9013745cfaef3fe90f0cb74a9abd00c7949/src/stack/VerifyBehavior.js">VerifyBehavior.js</a>. In their own words:</p><blockquote><em>This behavior will verify that communication with the remote end is possible, and will also sign all outgoing, and verify all incoming messages. This removes the risk of someone hijacking the iframe to send malicious messages.</em></blockquote><p>The good news is the implementation is also vulnerable and only protects against a scenario where the second level iframe is replaced mid-communication, not replaced from the start. The implementation looks roughly like this:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/4d09848ff43e694a52ad39dee6699393/href">https://medium.com/media/4d09848ff43e694a52ad39dee6699393/href</a></iframe><p>By sending a message first to establish a value for theirSecret:</p><pre>1_1,2_0_theirSecret</pre><p>Then waiting a few hundred milliseconds before sending a second message the request will continue:</p><pre>1_1,3_0_theirSecret_${encodeURIComponent(message_payload)}</pre><h4>Turning jQuery.ajax into XSS:</h4><p>At this point I can call any of the exposed methods mentioned earlier, but not receive the result. This leaves me with the ability to set cookies and make XHR requests but not read the response which is not particularly impactful. Thankfully, I can use a <a href="http://stackoverflow.com/a/29186757">little-known “feature</a>” of jQuery to abuse the XHR method. When calling jQuery.ajax if the url ends in =? jQuery will attempt to load the request as a JSONP call (even if dataType: &quot;json&quot; is set). You can test this out yourself (at least <a href="https://github.com/jquery/jquery/issues/3376">until jQuery v4</a>):</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/ca6dad88e4fc0693809eded34c00620e/href">https://medium.com/media/ca6dad88e4fc0693809eded34c00620e/href</a></iframe><p>So the crafted message payload to pm.html to trigger XSS becomes:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/9f3d2b3f3ad548820582d4fc643360bb/href">https://medium.com/media/9f3d2b3f3ad548820582d4fc643360bb/href</a></iframe><h3>Putting it all together</h3><p>At this point I have a rather complex chain of vulnerabilities:</p><ol><li>Victim opens <a href="https://attackerdoma.in/85f32147-76c1-44e2-8aa8-a1f9fd8e2ed3.html">85f32147–76c1–44e2–8aa8-a1f9fd8e2ed3.html</a></li><li>Redirect to <a href="https://apollo.na.leagueoflegends.com/apollo/cors/index.html">https://apollo.na.leagueoflegends.com/apollo/cors/index.html</a> occurs</li><li>Apollo redirects to <a href="https://account.leagueoflegends.com/pm.html">https://account.leagueoflegends.com/pm.html</a> with document.referrer set to a whitelisted domain</li><li>pm.html loads <a href="https://apollo.na.leagueoflegends.com/apollo/cors/index.html?xdm_e=https%3A%2F%2Fattackerdoma.in%2F8723f98f-e1f1-41cc-bc1e-f2afb4c8d933.html&amp;xdm_c=channel&amp;xdm_s=secret&amp;xdm_p=0">https://apollo.na.leagueoflegends.com/apollo/cors/index.html</a> which is a whitelisted domain for messages</li><li>Apollo loads the nested frame of <a href="https://attackerdoma.in/8723f98f-e1f1-41cc-bc1e-f2afb4c8d933.html">8723f98f-e1f1–41cc-bc1e-f2afb4c8d933.html</a></li><li>8723... sends a message to pm.html setting theirSecret for the session</li><li>8723... sends a message to pm.html using theirSecret to call the send method</li><li>pm.html calls send which triggers a JSONP call to <a href="https://attackerdoma.in/e26e42c0-08b7-4998-8e62-e9d8d6025d9e.js?cb=?">e26e42c0-08b7-4998-8e62-e9d8d6025d9e.js</a></li><li>XSS Payload fires</li></ol><p>All of these vulnerabilities were privately reported to Riot Games with the above description and functional proof of concept.</p><h3>Bypassing the mitigation</h3><p>After some time, the Riot team indicated a partial mitigation had been rolled out, with more fixes on the way. This mitigation was an update to <a href="https://apollo.na.leagueoflegends.com/apollo/cors/index.html">/apollo/cors/index.html</a> to check if document.referrer is an allowlisted domain:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/98dd4369106cee36f2f0a875637857ea/href">https://medium.com/media/98dd4369106cee36f2f0a875637857ea/href</a></iframe><p>To bypass this, I needed to find another open-redirect from one of the allowlisted domains. Taking a look at the login process for leagueoflegends.com, the user is directed through an auth flow on auth.riotgames.com via login.riotgames.com using the following URL (assuming the user is in the NA region):</p><pre><a href="https://login.leagueoflegends.com/?region=na&amp;lang=en_US&amp;redirect_uri=http%3A%2F%2Fna.leagueoflegends.com%2F">https://login.leagueoflegends.com/?region=na&amp;lang=en_US&amp;redirect_uri=http%3A%2F%2Fna.leagueoflegends.com%2F</a></pre><p>While the redirect_uri parameter can&#39;t be manipulated to any URL making it an <em>open</em>-redirect, it could be modified to any <em>subdomain</em> of <a href="https://leagueoflegends.com/">leagueoflegends.com</a>, including <a href="https://apollo.na.leagueoflegends.com/">apollo.na.leagueoflegends.com</a>. This meant I could use this endpoint to redirect to the apollo page which will bypass the allowlist check like this:</p><pre><a href="https://login.leagueoflegends.com/?region=na&amp;lang=en_US&amp;redirect_uri=https%3A%2F%2Fapollo.na.leagueoflegends.com%2Fapollo%2Fcors%2Findex.html">https://login.leagueoflegends.com/?region=na&amp;lang=en_US&amp;redirect_uri=https%3A%2F%2Fapollo.na.leagueoflegends.com%2Fapollo%2Fcors%2Findex.html</a></pre><p>Because this is abusing the login functionality, the victim has to be already logged-in to trigger the PoC.</p><p>This left me with a final vulnerability chain:</p><ol><li>Victim opens <a href="https://attackerdoma.in/a06250dd-ffd0-4a7e-8fb2-cf163021fe61.html">a06250dd-ffd0–4a7e-8fb2-cf163021fe61.html</a></li><li>Redirect to <a href="https://login.leagueoflegends.com/?region=na&amp;lang=en_US&amp;redirect_uri=https%3A%2F%2Fapollo.na.leagueoflegends.com%2Fapollo%2Fcors%2Findex.html%3Fxdm_e%3Dhttps%253A%252F%252Faccount.leagueoflegends.com%252Fpm.html%253Fxdm_e%253Dhttps%25253A%25252F%25252Fapollo.na.leagueoflegends.com%25252Fapollo%25252Fcors%25252Findex.html%25253Fxdm_e%25253Dhttps%2525253A%2525252F%2525252Fattackerdoma.in%2525252F8723f98f-e1f1-41cc-bc1e-f2afb4c8d933.html%252526xdm_c%25253Dchannel%252526xdm_s%25253Dsecret%252526xdm_p%25253D0%2526xdm_c%253Dchannel%2526xdm_s%253Dsecret%2526xdm_p%253D0%26xdm_c%3Dchannel%26xdm_s%3Dsecret%26xdm_p%3D5">https://login.leagueoflegends.com/</a></li><li>Redirect to <a href="https://auth.riotgames.com/authorize">https://auth.riotgames.com/authorize</a></li><li>Redirect to <a href="https://login.leagueoflegends.com/oauth2-callback">https://login.leagueoflegends.com/oauth2-callback</a></li><li>Redirect to <a href="https://login.lolesports.com/sso/login">https://login.lolesports.com/sso/login</a></li><li>Redirect to <a href="https://login.leagueoflegends.com/sso/callback">https://login.leagueoflegends.com/sso/callback</a></li><li>Redirect to <a href="https://apollo.na.leagueoflegends.com/apollo/cors/index.html?xdm_e=https%3A%2F%2Faccount.leagueoflegends.com%2Fpm.html%3Fxdm_e%3Dhttps%253A%252F%252Fapollo.na.leagueoflegends.com%252Fapollo%252Fcors%252Findex.html%253Fxdm_e%253Dhttps%25253A%25252F%25252Fattackerdoma.in%25252F8723f98f-e1f1-41cc-bc1e-f2afb4c8d933.html%2526xdm_c%253Dchannel%2526xdm_s%253Dsecret%2526xdm_p%253D0%26xdm_c%3Dchannel%26xdm_s%3Dsecret%26xdm_p%3D0&amp;xdm_c=channel&amp;xdm_s=secret&amp;xdm_p=5">https://apollo.na.leagueoflegends.com/apollo/cors/index.html</a> occurs</li><li>Apollo redirects to <a href="https://account.leagueoflegends.com/pm.html?xdm_e=https%3A%2F%2Fapollo.na.leagueoflegends.com%2Fapollo%2Fcors%2Findex.html%3Fxdm_e%3Dhttps%253A%252F%252Fattackerdoma.in%252F8723f98f-e1f1-41cc-bc1e-f2afb4c8d933.html%26xdm_c%3Dchannel%26xdm_s%3Dsecret%26xdm_p%3D0&amp;xdm_c=channel&amp;xdm_s=secret&amp;xdm_p=0">https://account.leagueoflegends.com/pm.html</a> with document.referrer set to a whitelisted domain</li><li>pm.html loads <a href="https://apollo.na.leagueoflegends.com/apollo/cors/index.html?xdm_e=https%3A%2F%2Fattackerdoma.in%2F8723f98f-e1f1-41cc-bc1e-f2afb4c8d933.html&amp;xdm_c=channel&amp;xdm_s=secret&amp;xdm_p=0">https://apollo.na.leagueoflegends.com/apollo/cors/index.html</a> which is a whitelisted domain for messages</li><li>Apollo loads the nested frame of <a href="https://attackerdoma.in/8723f98f-e1f1-41cc-bc1e-f2afb4c8d933.html">8723f98f-e1f1–41cc-bc1e-f2afb4c8d933.html</a></li><li>8723... sends a message to pm.html setting theirSecret for the session</li><li>8723... sends a message to pm.html using theirSecret to call the send method</li><li>pm.html calls send which triggers a JSONP call to <a href="https://attackerdoma.in/e26e42c0-08b7-4998-8e62-e9d8d6025d9e.js?cb=?">e26e42c0-08b7-4998-8e62-e9d8d6025d9e.js</a></li><li>XSS Payload fires</li></ol><p>This bypass of the mitigation was also reported to Riot Games.</p><h3>Accidentally Disclosing an 0-day</h3><p>When drafting this post and verifying the original payloads in 2022, I realized that the latest release of easyXDM was still vulnerable to the message origin spoofing via HashTransport as well as the (limited) open-redirect vulnerabilities!</p><p>Before publishing these vulnerabilities publicly, I reached out to the easyXDM author to see if the project was still maintained and if a new security release would even make sense, they responded:</p><blockquote>This project is not actively maintained as browsers have caught up to provide the core features natively.</blockquote><p>And I would agree with this stance, the <a href="https://caniuse.com/mdn-api_window_postmessage">browser support for window.postMessage</a> is pervasive and has been for 10+ years at this point. Newly developed software should be using the native <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage">window.postMessage</a> functionality without compatibility shims like easyXDM, even if native postMessage still leaves <a href="https://hackerone.com/reports/231053">plenty of room for its own <em>different </em>complex vulnerabilities</a>.</p><h3>Conclusion</h3><p>This was a pretty fun chain of vulnerabilities to work on at the time, and really shows the hidden level of complexity you can inherit when you import seemingly straight-forward JavaScript compatibility shims.</p><p>P.S. Thanks to the Riot Games security team for giving me their blessing to publicly disclose this report when I reached out 6 years after the fact :)</p><h3>Timeline</h3><ul><li><em>November 26th, 2016:</em> Initial report to Riot Games via HackerOne</li><li><em>November 28th, 2016: </em>Report<em> </em>acknowledged by triage team</li><li><em>November 29th, 2016:</em> Riot confirms successful reproduction</li><li><em>February 21st, 2017:</em> Riot indicates a patch was deployed</li><li><em>February 21st, 2017:</em> Response that I am still able to reproduce, presumably because the patch is not completely rolled out yet</li><li><em>March 18th, 2017:</em> Follow-up that I am still able to reproduce using the initial payload from the report</li><li><em>April 28th, 2017:</em> Riot apologies for the confusion, indicates the patch is currently deployed</li><li><em>April 29th, 2017:</em> Bypass of the patch is identified and reported to Riot</li><li><em>May 8th, 2017:</em> Riot acknowledges the bypass</li><li><em>November 2nd, 2017: </em>Follow-up on the report asking for updates</li><li><em>November 6th, 2017: </em>Riot acknowledges the ping and indicates the issue has been fixed, although one individual vulnerability is remaining (but the full chain is broken at this point)</li><li><em>May 31st, 2018: </em>Bounty ($2,000) awarded and report closed as resolved</li><li><em>November 3rd, 2022: </em>Email sent to easyXDM author</li><li><em>November 19th, 2022: </em>LinkedIn message to easyXDM author</li><li><em>November 19th, 2022: </em>Response from easyXDM author indicating the project is not actively maintained</li><li><em>December 1st, 2022: </em>Publication of this post</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=75bcf9d582b5" width="1" height="1" alt=""><hr><p><a href="https://blog.bored.engineer/xss-on-account-leagueoflegends-com-via-easyxdm-2016-75bcf9d582b5">XSS on account.leagueoflegends.com via easyXDM [2016]</a> was originally published in <a href="https://blog.bored.engineer">bored.engineer</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a WebAuthn Click Farm — Are CAPTCHAs Obsolete?]]></title>
            <link>https://betterappsec.com/building-a-webauthn-click-farm-are-captchas-obsolete-bfab07bb798c?source=rss-6b69d1e4b01d------2</link>
            <guid isPermaLink="false">https://medium.com/p/bfab07bb798c</guid>
            <category><![CDATA[web-app-security]]></category>
            <category><![CDATA[application-security]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[webauthn]]></category>
            <category><![CDATA[information-security]]></category>
            <dc:creator><![CDATA[Luke Young]]></dc:creator>
            <pubDate>Thu, 10 Jun 2021 10:07:44 GMT</pubDate>
            <atom:updated>2021-06-10T10:07:44.336Z</atom:updated>
            <content:encoded><![CDATA[<h3>Building a WebAuthn Click Farm — Are CAPTCHAs Obsolete?</h3><p>How I built a <a href="https://en.wikipedia.org/wiki/Click_farm">click farm</a> to “bypass” Cloudflare’s <a href="https://blog.cloudflare.com/introducing-cryptographic-attestation-of-personhood/">CAPTCHA killer</a> with some cheap USB security keys, an Arduino, and a bit of python.</p><p><em>Any opinions stated here are my own, not necessarily those of any past, present, or future employer.</em></p><figure><img alt="6 powered HyperFIDO keys connected to a USB hub and attached to a Arduino" src="https://cdn-images-1.medium.com/max/1024/1*7Vfr_SiPRcCuVmrGHl3oZQ.jpeg" /></figure><h3>What is Attestation of Personhood?</h3><p><a href="https://www.cloudflare.com/">Cloudflare</a> recently published <a href="https://blog.cloudflare.com/introducing-cryptographic-attestation-of-personhood/">a blog post</a> about a <em>potential</em> replacement for<a href="https://en.wikipedia.org/wiki/CAPTCHA"> CAPTCHAs</a> by utilizing signatures from hardware security keys and<a href="https://www.w3.org/TR/webauthn-2/"> WebAuthn</a> they are calling “Attestation of Personhood”. The post triggered a good bit of<a href="https://news.ycombinator.com/item?id=27141593"> discussion</a> online, particularly around the<a href="https://news.ycombinator.com/item?id=27144284"> threat of automation</a> mentioned near the end of the post:</p><blockquote><em>We also have to consider the possibility of facing automated button-pressing systems. A</em><a href="https://en.wikipedia.org/wiki/Drinking_bird"><em> drinking bird</em></a><em> able to press the capacitive touch sensor could pass the Cryptographic Attestation of Personhood. At best, the bird solving rate matches the time it takes for the hardware to generate an attestation. With our current set of trusted manufacturers, this would be slower than the solving rate of professional CAPTCHA-solving services, while allowing legitimate users to pass through with certainty.</em></blockquote><figure><img alt="Slack Screenshot: “The idea is super simple. Imagine the normal use case. Human puts yubikey into machine, presses the little capacitive button, they pass the human check. Now imagine just automating that process and having something nonhuman to trigger the capacitive touch, like a drinking bird or wet paper towel or something”" src="https://cdn-images-1.medium.com/max/1024/1*uFylt8PRmB5uKE3ePrwXrg.png" /></figure><p>After a bit of brainstorming and discussion on Slack, I decided it would be a fun weekend project to test this out with actual hardware and see just how difficult it would be.</p><h3>Acquiring compatible hardware</h3><figure><img alt="6 HyperFIDO hardware FIDO tokens still in packaging" src="https://cdn-images-1.medium.com/max/1024/0*j7T19DXb8QzIuwzc" /></figure><p>Because WebAuthn is an <a href="https://www.w3.org/TR/webauthn-2/">open standard</a> it’s of course trivial to build a<a href="https://github.com/bodik/soft-webauthn"> software token</a> and use it to sign requests. However, Cloudflare mitigates this by requiring a “hardware attestation” signature from specific manufacturers:</p><blockquote><em>our initial rollout is limited to a few devices: YubiKeys, which we had the chance to use and test; HyperFIDO keys; and Thetis FIDO U2F keys.</em></blockquote><p>This attestation is essentially a unique key per batch/manufacturer (minimum of 100k devices) baked into the device which (in theory) would be as difficult to extract as any other private keys from a device. I love my<a href="https://www.yubico.com/"> Yubikeys</a> but there’s one thing you can’t deny, they’re expensive at $50+ apiece. Thankfully, HyperFIDO keys are much cheaper at $16 each, so I bought $100 worth of keys which were helpfully available with Amazon’s one-day shipping.</p><h3>Prototyping with a SoloKey</h3><p>With limited confidence in my soldering abilities, I wanted to prototype the software side of the equation while waiting for the keys to be delivered. Coincidentally, I recently acquired a<a href="https://solokeys.com/"> SoloKey</a> which is an <a href="https://github.com/solokeys/solo">open-source</a> FIDO2 security key that allows the user to recompile and flash replacement user firmware. Predictably this key isn’t trusted by Cloudflare, but it will allow me to soft-disable the <a href="https://w3c.github.io/webauthn/#test-of-user-presence">user presence test</a> (physically pressing the key) required by the WebAuthn standard while still developing software that interacts with a physical USB-based key.</p><p>Thankfully this is a pretty trivial task, replacing the<a href="https://github.com/solokeys/solo/blob/2884f95ff4d75d8934522ec49ea418a5457f8617/targets/stm32l432/src/device.c#L705"> ctap_user_presence_test</a> function with one that always succeeds. In fact, this is already implemented for use during testing via the “<a href="https://github.com/solokeys/solo/blob/2884f95ff4d75d8934522ec49ea418a5457f8617/targets/stm32l432/src/device.c#L728">SKIP_BUTTON_CHECK_FAST</a>” ifdef. Enabling it and compiling a new firmware release gets us a hardware token that signs any incoming WebAuthn request instantly (or as fast as the hardware will support).</p><p>Developing an actual server to expose the USB key and its operations to the internet is quite simple thanks to Yubico’s<a href="https://github.com/Yubico/python-fido2"> python-fido2</a> library, which abstracts the OS-specific USB interactions to a common interface. A basic HTTP server with no threading/locking/error handling can be implemented as follows:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/8d67bc9ae733f11aa4d700fdf1a3b608/href">https://medium.com/media/8d67bc9ae733f11aa4d700fdf1a3b608/href</a></iframe><h3>Building an electronic version of the drinking bird</h3><p>While experimenting with the SoloKey, the actual HyperFIDO keys arrived. Using some<a href="https://www.youtube.com/watch?v=AICoq0T_7MA"> YouTube videos</a> it was easy to get the keys apart and the internal circuit board exposed. Thankfully, going cheap on hardware actually paid off: instead of a capacitance-based presence detection like those seen on a Yubikey/SoloKey, they have a physical button that can be “easily” soldered for debugging.</p><figure><img alt="A bare circuit board with a small wire being soldered to the button on the edge" src="https://cdn-images-1.medium.com/max/1024/0*FrIQLkh5hzcN50I8" /></figure><p>Interestingly the button seems to be normally open at 3.3 volts and is brought <em>down</em> to 0 volts (ground) when pressed. The “proper” way to automate this would be to use a<a href="https://en.wikipedia.org/wiki/Relay"> relay</a> connected to either side of the button circuit allowing each button to be independently triggered without interference.</p><p>However, it took a solid hour to get all the keys disassembled and “reliably” soldered on one side, and I had no desire to do it twice. My hacky solution was to connect the wire to a GPIO pin on a<a href="https://www.raspberrypi.org/"> Raspberry Pi</a> (eventually replaced by an<a href="https://www.arduino.cc/"> Arduino</a>). By switching the pin between INPUT mode (floating) and OUTPUT_LOW (pull-down) it can cause each USB key to believe a button press has occurred. This works surprisingly well for a prototype but resulted in a few (in retrospect obvious) learnings:</p><ul><li>If the output is ever accidentally set to OUTPUT_HIGH it may damage/destroy the USB key and if the normal/input voltage is higher than expected (3.3v) it may damage the Raspberry Pi.</li><li>It is critical that the ground between the USB key and Raspberry Pi/Arduino is tied together. The easiest way to do this is to power all devices from the same USB hub.</li></ul><figure><img alt="6 lite HyperFIDO keys connected to a USB hub and attached to a Raspberry Pi" src="https://cdn-images-1.medium.com/max/1024/0*crf4xvl2Ph3LVWrN" /></figure><h3>Pressing buttons is tricky</h3><p>My <a href="https://gist.github.com/bored-engineer/e5c9bc289963ab6cb9abf137113afa74">initial button press script</a> was quite primitive, triggering each button iteratively as fast as possible. However, as I decreased the interval between button presses I quickly ran into a problem: when a user presence test wasn’t pending, each press triggered the “slot 1” behavior on the USB key generating a 6 digit numeric TOTP token (‘888888’ by default). This meant each key was outputting useless key presses to the connected host at a rate that actually started to result in software problems, particularly on macOS where I was doing local development.</p><p>To resolve this issue, I replaced the Raspberry Pi with an unused<a href="https://store.arduino.cc/usa/arduino-uno-rev3"> Arduino Uno</a> I had. This allowed me to simplify the setup (no systemd/python just to toggle GPIO pins) and allowed me to move from a loop to an event-driven architecture. By reading which key to “press” via the (USB) serial interface on the Arduino, I could trigger a button press only when necessary. Perhaps a better solution would have been to disable the “slot 1” TOTP behavior altogether (which is possible on a Yubikey), but I don’t believe it’s possible to reprogram this on the HyperFIDO keys.</p><h3>Putting it all together</h3><p>At this point, I have all the ingredients necessary to build an internet-facing web service with the ability to answer WebAuthn signing requests that “automatically” bypass the <a href="https://w3c.github.io/webauthn/#test-of-user-presence">user presence test</a> on-demand. With a few more modifications the server became multi-threaded and supported exclusive locking per device:<a href="https://github.com/bored-engineer/fido-farm"> github.com/bored-engineer/fido-farm</a>. Benchmarking the server I got some surprising (at least to me)<a href="https://gist.github.com/bored-engineer/2d6dc18a039e2426a9ffb20e534ba957"> results</a>:</p><figure><img alt="Concurrency Level: 12 Time taken for tests: 34.343 seconds Complete requests: 1072 Failed requests: 825 (Connect: 0, Receive: 0, Length: 825, Exceptions: 0) Total transferred: 2496604 bytes Total body sent: 978852 HTML transferred: 2364748 bytes Requests per second: 31.21 [#/sec] (mean) Time per request: 384.436 [ms] (mean) Time per request: 32.036 [ms] (mean, across all concurrent requests) Transfer rate: 70.99 [Kbytes/sec] receive" src="https://cdn-images-1.medium.com/max/524/0*YTb6X7YA63kOwCk7" /></figure><p>The server is capable of reliably handling just under 28 signatures/sec, or roughly 4.6 requests per key/sec which is much better than I expected. However, it is roughly on par with the benchmarking of an individual key multiplied by 6 so it makes sense.</p><p>If an attacker has automated attacks (e.g. DDOS, mass goods purchasing, etc.) that need to bypass the attestation of personhood this is a reliable way to do it with relatively low cost and effort. For a few hours of work and a hundred dollars of hardware keys, an attacker could make a reusable system that could support theoretically limitless automated requests that could successfully bypass the challenge.</p><h3>Does this mean “Attestation of Personhood” broken?</h3><p>In my opinion, no. Starting with the obvious, Cloudflare has clearly considered this attack vector as they mentioned it in the post and decided it still raises the cost of an attack over the current CAPTCHA model:</p><blockquote>Another issue that we keep a close eye on is security. The security of this challenge depends on the underlying hardware provided by trusted manufacturers. We have confidence they are secured. If any breach were to occur, we would be able to quickly deauthorize manufacturers’ public keys at various levels of granularity.</blockquote><p>CAPTCHA solving services charge a variety of prices differentiating their services based on price-per-image, response time, and success rate. Of course, our solution is 100% reliable (until the batch of keys is banned) and fast (~430ms/request) but comes at a significant up-front cost. Assuming a similar price point as CAPTCHA solving services of 50 cents per 1000 challenge images, our service would need to answer over 24,000 challenges per key before getting banned/detected just to cover the cost of the key let alone the additional hardware/bandwidth. And given the probability that all of the hardware keys are from the same batch, this wouldn’t be an event that can be easily recovered by just swapping out an individual key.</p><p>Another aspect to consider is that challenge flows are often only one of many factors used when determining if a request should be allowed. Factors like the reputation of the source IP/network, capabilities of the browser (ex: is JavaScript enabled), user behavior on the site such as how quickly was a captcha solved or did the user actually click the element or trigger the function automatically can be used before a decision is made to allow/deny the request.</p><h3>How could you detect this type of abuse?</h3><p>Perhaps one of the reasons Cloudflare has confidence in this solution is brute-force hardware automation like the kind developed here is likely much easier to detect than you’d think, (and if not, here’s a free suggestion):</p><p>While an attestation certificate is shared amongst at least 100k devices, an additional attribute is provided as part of the attestation called a “<a href="https://www.w3.org/TR/webauthn-2/#sctn-sign-counter">signature counter</a>”, this is a unique counter that is incremented each time an operation is performed:</p><blockquote><em>Authenticators SHOULD implement a signature counter feature. These counters are conceptually stored for each credential by the authenticator, or globally for the authenticator as a whole. The initial value of a credential’s signature counter is specified in the signCount value of the authenticator data returned by authenticatorMakeCredential.</em></blockquote><p>In the case of the HyperFIDO tokens, this counter is global which means it increments each time the crypto module is used. Unless the attestation key is extracted from the device allowing complete control over the signed authenticator data, this global counter will increment with every credential operation performed:</p><figure><img alt="Counter: (uint32) 366" src="https://cdn-images-1.medium.com/max/937/0*Vj309tGJ5I0cnudF" /></figure><p>This type of unnatural/consistent growth within a batch of keys could be very easy for Cloudflare to detect if they chose to track the values across a batch of keys. Even a basic privacy-preserving detection blocking all requests (or sending to an alternate challenge) that denies requests when the signature counter is greater than a reasonable human threshold (ex: 20k) could be effective.</p><figure><img alt="A graph showing a sharp upwards increment in signature counter values for multiple keys" src="https://cdn-images-1.medium.com/max/1024/0*kOf9Kz5n95TkElaD" /></figure><p>It will be interesting to see if over time Cloudflare finds it necessary to implement additional detections for this type of physical automation, as well as how quickly and how broadly they disable entire batches of keys when inevitably an attestation private key is successfully extracted in the future.</p><h3>Takeaways</h3><p>It is easy to build software that intercepts WebAuthn requests and sends them to a remote FIDO hardware key to be solved. With a bit of soldering, hardware FIDO keys can be modified so the <a href="https://w3c.github.io/webauthn/#test-of-user-presence">user presence test</a> (physically touching the key) is bypassed on-demand.</p><p>By combining these components, it is possible to automate Cloudflare’s <a href="https://blog.cloudflare.com/introducing-cryptographic-attestation-of-personhood/">Attestation of Personhood</a> challenge. However, this comes at a significantly higher cost to attackers than CAPTCHAs and can be relatively easily detected (at least with the keys I tested).</p><h3>Try it out yourself</h3><p>For a final bit of fun, I’ve exposed this entire setup to the internet (via Cloudflare of course) and you can test it out yourself. Simply visit<a href="http://cloudflarechallenge.com/"> cloudflarechallenge.com</a> and run the following in your browser’s developer console, then attempt to complete the challenge, it should be automatically solved by the hardware farm I set up:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/47d89a1d4c66bb20427a9f9ad63ada91/href">https://medium.com/media/47d89a1d4c66bb20427a9f9ad63ada91/href</a></iframe><p><strong>Contributions and Thanks</strong></p><p>A special thanks to those who helped peer review and make this post as useful as it is: <a href="https://www.linkedin.com/in/lukemat/">Luke Matarazzo</a> and <a href="https://www.linkedin.com/in/jameschip/">James Chiappetta</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=bfab07bb798c" width="1" height="1" alt=""><hr><p><a href="https://betterappsec.com/building-a-webauthn-click-farm-are-captchas-obsolete-bfab07bb798c">Building a WebAuthn Click Farm — Are CAPTCHAs Obsolete?</a> was originally published in <a href="https://betterappsec.com">better appsec</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[DEF CON 25: Slides and Source Code]]></title>
            <link>https://blog.bored.engineer/def-con-25-slides-and-source-code-2f937f09724b?source=rss-6b69d1e4b01d------2</link>
            <guid isPermaLink="false">https://medium.com/p/2f937f09724b</guid>
            <dc:creator><![CDATA[Luke Young]]></dc:creator>
            <pubDate>Wed, 02 Aug 2017 22:53:22 GMT</pubDate>
            <atom:updated>2017-08-02T22:53:22.301Z</atom:updated>
            <content:encoded><![CDATA[<iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.slideshare.net%2Fslideshow%2Fembed_code%2Fkey%2FHdtmpvlyYLsUQn&amp;url=https%3A%2F%2Fwww.slideshare.net%2FLukeYoung3%2Ftheres-no-place-like-127001-achieving-reliable-dns-rebinding-in-modern-browsers&amp;image=https%3A%2F%2Fcdn.slidesharecdn.com%2Fss_thumbnails%2Fjaqen-170802224027-thumbnail-4.jpg%3Fcb%3D1501713712&amp;key=a19fcc184b9711e1b4764040d3dc5c07&amp;type=text%2Fhtml&amp;schema=slideshare" width="600" height="500" frameborder="0" scrolling="no"><a href="https://medium.com/media/2539898458a9b013c7e7db99cc971e86/href">https://medium.com/media/2539898458a9b013c7e7db99cc971e86/href</a></iframe><p><a href="https://github.com/linkedin/jaqen">linkedin/jaqen</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2f937f09724b" width="1" height="1" alt=""><hr><p><a href="https://blog.bored.engineer/def-con-25-slides-and-source-code-2f937f09724b">DEF CON 25: Slides and Source Code</a> was originally published in <a href="https://blog.bored.engineer">bored.engineer</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[DEF CON 24: Slides and Exploit]]></title>
            <link>https://blog.bored.engineer/defcon-24https-drive-google-com-a-bored-engineer-file-d-0b376yxl0vxukqk1nrtj-463f6e6376bd?source=rss-6b69d1e4b01d------2</link>
            <guid isPermaLink="false">https://medium.com/p/463f6e6376bd</guid>
            <dc:creator><![CDATA[Luke Young]]></dc:creator>
            <pubDate>Sat, 06 Aug 2016 17:16:40 GMT</pubDate>
            <atom:updated>2016-08-09T03:47:44.889Z</atom:updated>
            <content:encoded><![CDATA[<p>Here’s the slides and exploits from the DEF CON 24 talk in Las Vegas, NV. Video to follow in a few weeks.</p><p><a href="https://drive.google.com/file/d/0B376YxL0VXuKQk1nRTJJVlctQlU/view?usp=sharing">defcon24.pdf</a></p><p><strong>Update on the slides, these issues have all been resolved, the slides were not updated before upload to the DEF CON server</strong></p><ul><li><a href="https://github.com/bored-engineer/ps-exploits">bored-engineer/ps-exploits</a></li><li><a href="https://github.com/bored-engineer/ps-splunk">bored-engineer/ps-splunk</a></li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=463f6e6376bd" width="1" height="1" alt=""><hr><p><a href="https://blog.bored.engineer/defcon-24https-drive-google-com-a-bored-engineer-file-d-0b376yxl0vxukqk1nrtj-463f6e6376bd">DEF CON 24: Slides and Exploit</a> was originally published in <a href="https://blog.bored.engineer">bored.engineer</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[git init && git commit -a -m “Initial Commit”]]></title>
            <link>https://blog.bored.engineer/git-init-git-commit-m-initial-commit-18a51978673?source=rss-6b69d1e4b01d------2</link>
            <guid isPermaLink="false">https://medium.com/p/18a51978673</guid>
            <dc:creator><![CDATA[Luke Young]]></dc:creator>
            <pubDate>Fri, 22 Jul 2016 14:45:00 GMT</pubDate>
            <atom:updated>2016-07-22T14:58:10.636Z</atom:updated>
            <content:encoded><![CDATA[<p>I decided to relaunch my blog with my recent domain name change. It’s unlikely I will migrate the old content, but look forward to my incoherent ramblings about security bugs and the state of the industry in the future.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=18a51978673" width="1" height="1" alt=""><hr><p><a href="https://blog.bored.engineer/git-init-git-commit-m-initial-commit-18a51978673">git init &amp;&amp; git commit -a -m “Initial Commit”</a> was originally published in <a href="https://blog.bored.engineer">bored.engineer</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>