├── .gitignore ├── LICENSE ├── README.md ├── bh-owned.rb ├── customqueries.json └── example-files ├── 1st-wave.txt ├── 2nd-wave.txt ├── 3rd-wave.txt ├── 4th-wave.txt ├── BREYES-password-reuse.txt ├── blacklist-nodes.txt ├── blacklist-rels.txt ├── common-local-admins.txt └── owned-no-wave.txt /.gitignore: -------------------------------------------------------------------------------- 1 | dev/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### BloodHound Owned 2 | 3 | A collection of files for adding and leveraging custom properties in BloodHound. A thorough overview of the ideas that led to these Custom Queries & Ruby script can be found in this blog post: http://porterhau5.com/blog/extending-bloodhound-track-and-visualize-your-compromise/ 4 | 5 | These are intended, although not required, to be used with a forked version of BloodHound found here: https://github.com/porterhau5/BloodHound 6 | 7 | Files in the `example-files` directory can be used with the Ruby script and the [BloodHoundExampleDB.graphdb](https://github.com/BloodHoundAD/BloodHound/tree/master/BloodHoundExampleDB.graphdb) for demonstration or development purposes. Usage examples are shown below. 8 | 9 | #### Quickstart 10 | 11 | Using these requires Neo4j, a populated database, and a BloodHound app. 12 | 13 | The Ruby script (`bh-owned.rb`) and custom queries (`customqueries.json`) can be used with the official BloodHound app. However, the UI customizations are currently only available in the customized app found in this [forked BloodHound repo](https://github.com/porterhau5/BloodHound). 14 | 15 | Current UI customizations include: 16 | * Node highlighting 17 | * Custom properties displayed on Node Info tab 18 | * Run custom queries from Node Info tab 19 | * Adding or removing nodes from blacklist via tooltip 20 | * Marking or unmarking nodes as owned via tooltip 21 | 22 | If you'd like to try out the features added in the customized BloodHound app, then either download a [pre-compiled binary here](https://github.com/porterhau5/BloodHound/releases) or build the app from source. If building from source, then follow the official BloodHound [install directions](https://github.com/BloodHoundAD/BloodHound/wiki/Getting-started) but substitute the forked repo URL (https://github.com/porterhau5/BloodHound) for the official repo URL in step 2 (cloning the repository). The building instructions are on [BloodHound's wiki](https://github.com/BloodHoundAD/BloodHound/wiki/Building-BloodHound-from-source). 23 | 24 | To use the custom queries, first copy the `customqueries.json` file to the Electron project's home folder: 25 | * Windows: `~\AppData\Roaming\bloodhound\` 26 | * Mac: `~/Library/Application Support/bloodhound/` 27 | * Linux: I'm not sure. If someone does this on Linux and figures this out then let me know. 28 | 29 | Refresh or restart BloodHound for the changes to take effect. Custom Queries can be found in the Search Container (top-left) on the Queries tab underneath the Custom Queries header. 30 | 31 | #### bh-owned.rb Usage 32 | 33 | This script is the primary means for updating the Neo4j database to support the custom queries and UI enhancements. 34 | ``` 35 | $ ruby bh-owned.rb 36 | Usage: ruby bh-owned.rb [options] 37 | Server Details: 38 | -u, --username Neo4j database username (default: 'neo4j') 39 | -p, --password Neo4j database password (default: 'BloodHound') 40 | -U, --url URL of Neo4j RESTful host (default: 'http://127.0.0.1:7474/') 41 | Owned/Wave/SPW: 42 | -a, --add add 'owned' and 'wave' property to nodes in 43 | -A, --add-no-wave add 'owned' property to nodes in (skip 'wave' property) 44 | -w, --wave value to set 'wave' property (override default behavior) 45 | -s, --spw add 'SharesPasswordWith' relationship between all nodes in 46 | Blacklisting: 47 | -b, --bl-node add 'blacklist' property to nodes in 48 | -B, --bl-rel add 'blacklist' property to relationships in 49 | -r, --remove-bl-node remove 'blacklist' property from nodes in 50 | -R, --remove-bl-rel remove 'blacklist' property from relationships in 51 | Connections: 52 | -c, --connections add connection info from netstat 53 | -d, --dns contains DNS mapping of IP to computer name (10.2.3.4,srv1.int.local) 54 | Misc Queries: 55 | -n, --nodes get all node names 56 | -e, --examples reference doc of custom Cypher queries for BloodHound 57 | --reset remove all custom properties and SharesPasswordWith relationships 58 | ``` 59 | It helps to create a few new indexes to help with query performance. This can be done using Neo4j's web browser or BloodHound's Raw Query feature (I recommend Neo4j's web browser for this): 60 | ``` 61 | CREATE INDEX ON :Group(wave) 62 | CREATE INDEX ON :User(wave) 63 | CREATE INDEX ON :Computer(wave) 64 | CREATE INDEX ON :Group(blacklist) 65 | CREATE INDEX ON :User(blacklist) 66 | CREATE INDEX ON :Computer(blacklist) 67 | ``` 68 | 69 | ##### Owned/Wave 70 | 71 | Data is ingested using the script's `-a` flag with a file passed as an argument. Files should be in CSV format with the name of the compromised node first, followed by the method of compromise. If no method of compromise is provided then it will default to "Not specified" (these files can be found in the `example-files` dir): 72 | ``` 73 | $ cat 1st-wave.txt 74 | BLOPER@INTERNAL.LOCAL,LLMNR wpad 75 | JCARNEAL@INTERNAL.LOCAL,NBNS wpad 76 | 77 | $ ruby bh-owned.rb -a 1st-wave.txt 78 | [*] Using default username: neo4j 79 | [*] Using default password: BloodHound 80 | [*] Using default URL: http://127.0.0.1:7474/ 81 | [*] No previously owned nodes found, setting wave to 1 82 | [+] Success, marked 'BLOPER@INTERNAL.LOCAL' as owned in wave '1' via 'LLMNR wpad' 83 | [+] Success, marked 'JCARNEAL@INTERNAL.LOCAL' as owned in wave '1' via 'NBNS wpad' 84 | [*] Finding spread of compromise for wave 1 85 | [+] 2 nodes found: 86 | DOMAIN USERS@INTERNAL.LOCAL 87 | SYSTEM38.INTERNAL.LOCAL 88 | ``` 89 | The script will first query the database and determine the latest wave added. It then increments it by one so that the incoming additions will be in the new wave. You can override this behavior by setting the `-w` flag to the preferred wave value. 90 | 91 | Once the wave number is determined, the script takes the following steps: 92 | 93 | * Creates the Cypher queries to add the nodes 94 | * Creates the Cypher query to find the spread of compromise for the new wave 95 | * Wraps it all in JSON 96 | * POSTs the request to the REST endpoint 97 | 98 | Until more options and error-checking is thrown in, each wave should be added separately: 99 | ``` 100 | $ cat 2nd-wave.txt 101 | ZDEVENS@INTERNAL.LOCAL,Password spray 102 | BPICKEREL@INTERNAL.LOCAL,Password spray 103 | 104 | $ ruby bh-owned.rb -a 2nd-wave.txt 105 | [*] Using default username: neo4j 106 | [*] Using default password: BloodHound 107 | [*] Using default URL: http://127.0.0.1:7474/ 108 | [+] Success, marked 'ZDEVENS@INTERNAL.LOCAL' as owned in wave '2' via 'Password spray' 109 | [+] Success, marked 'BPICKEREL@INTERNAL.LOCAL' as owned in wave '2' via 'Password spray' 110 | [*] Finding spread of compromise for wave 2 111 | [+] 5 nodes found: 112 | BACKUP3@INTERNAL.LOCAL 113 | BACKUP_SVC@INTERNAL.LOCAL 114 | CONTRACTINGS@INTERNAL.LOCAL 115 | DATABASE5.INTERNAL.LOCAL 116 | MANAGEMENT3.INTERNAL.LOCAL 117 | ``` 118 | 119 | The `-A` flag will skip the wave process entirely. This is useful if you just want to mark nodes as owned and move on: 120 | 121 | ``` 122 | $ cat example-files/owned-no-wave.txt 123 | AMURPHY@INTERNAL.LOCAL,Social engineered 124 | OPIERCE@INTERNAL.LOCAL 125 | TROBINSON@INTERNAL.LOCAL,Phish 126 | $ ruby bh-owned.rb -A example-files/owned-no-wave.txt 127 | [*] Using default username: neo4j 128 | [*] Using default password: BloodHound 129 | [*] Using default URL: http://127.0.0.1:7474/ 130 | [+] Success, marked 'AMURPHY@INTERNAL.LOCAL' as owned via 'Social engineered' 131 | [+] Success, marked 'OPIERCE@INTERNAL.LOCAL' as owned via 'Not specified' 132 | [+] Success, marked 'TROBINSON@INTERNAL.LOCAL' as owned via 'Phish' 133 | ``` 134 | 135 | 136 | ##### SharesPasswordWith 137 | 138 | Use `-s` to add a file containing a list of nodes with the same password. This will create a new relationship, "SharesPasswordWith", between each node in the list. Useful for representing Computers with a common local admin password, or Users that use the same password for multiple accounts: 139 | ``` 140 | $ cat common-local-admins.txt 141 | MANAGEMENT3.INTERNAL.LOCAL 142 | FILESERVER6.INTERNAL.LOCAL 143 | SYSTEM38.INTERNAL.LOCAL 144 | DESKTOP40.EXTERNAL.LOCAL 145 | 146 | $ ruby bh-owned.rb -s common-local-admins.txt 147 | [*] Using default username: neo4j 148 | [*] Using default password: BloodHound 149 | [*] Using default URL: http://127.0.0.1:7474/ 150 | [+] Created SharesPasswordWith relationship between 'MANAGEMENT3.INTERNAL.LOCAL' and 'FILESERVER6.INTERNAL.LOCAL' 151 | [+] Created SharesPasswordWith relationship between 'MANAGEMENT3.INTERNAL.LOCAL' and 'SYSTEM38.INTERNAL.LOCAL' 152 | [+] Created SharesPasswordWith relationship between 'MANAGEMENT3.INTERNAL.LOCAL' and 'DESKTOP40.EXTERNAL.LOCAL' 153 | [+] Created SharesPasswordWith relationship between 'FILESERVER6.INTERNAL.LOCAL' and 'SYSTEM38.INTERNAL.LOCAL' 154 | [+] Created SharesPasswordWith relationship between 'FILESERVER6.INTERNAL.LOCAL' and 'DESKTOP40.EXTERNAL.LOCAL' 155 | [+] Created SharesPasswordWith relationship between 'SYSTEM38.INTERNAL.LOCAL' and 'DESKTOP40.EXTERNAL.LOCAL' 156 | ``` 157 | 158 | #### Blacklisting 159 | 160 | Specific nodes or relationships can be added to the blacklist. For adding nodes, put the name of each node in a newline-delimited file and add it using the `-b` flag. Note that blacklist features are only compatible with the custom queries: 161 | ``` 162 | $ cat blacklist-nodes.txt 163 | MANAGEMENT3.INTERNAL.LOCAL 164 | BGRIFFIN@EXTERNAL.LOCAL 165 | BPICKEREL@INTERNAL.LOCAL 166 | DESKTOP20.EXTERNAL.LOCAL 167 | 168 | ruby bh-owned.rb -b blacklist-nodes.txt 169 | [*] Using default username: neo4j 170 | [*] Using default password: BloodHound 171 | [*] Using default URL: http://127.0.0.1:7474/ 172 | [+] Success, marked 'MANAGEMENT3.INTERNAL.LOCAL' as blacklisted 173 | [+] Success, marked 'BGRIFFIN@EXTERNAL.LOCAL' as blacklisted 174 | [+] Success, marked 'BPICKEREL@INTERNAL.LOCAL' as blacklisted 175 | [+] Success, marked 'DESKTOP20.EXTERNAL.LOCAL' as blacklisted 176 | ``` 177 | Relationships are added to the blacklist by specifying the start node and end node for the desired relationship. Each relationship should be in CSV format with the start node first, relationship second, and end node last, with one path per line. They are added to the blacklist using the `-B` flag: 178 | ``` 179 | $ cat blacklist-rels.txt 180 | ZDEVENS@INTERNAL.LOCAL,AdminTo,MANAGEMENT3.INTERNAL.LOCAL 181 | DESKTOP21.EXTERNAL.LOCAL,HasSession,JANTHONY@EXTERNAL.LOCAL 182 | 183 | $ ruby bh-owned.rb -B blacklist-rels.txt 184 | [*] Using default username: neo4j 185 | [*] Using default password: BloodHound 186 | [*] Using default URL: http://127.0.0.1:7474/ 187 | [+] Success, marked 'AdminTo' as blacklisted from 'ZDEVENS@INTERNAL.LOCAL' to 'MANAGEMENT3.INTERNAL.LOCAL' 188 | [+] Success, marked 'HasSession' as blacklisted from 'DESKTOP21.EXTERNAL.LOCAL' to 'JANTHONY@EXTERNAL.LOCAL' 189 | ``` 190 | Nodes are removed from the blacklist in the same manner they are added, but use the `-r` flag. Use `-R` to remove relationships from the blacklist: 191 | ``` 192 | $ ruby bh-owned.rb -r blacklist-nodes.txt 193 | [*] Using default username: neo4j 194 | [*] Using default password: BloodHound 195 | [*] Using default URL: http://127.0.0.1:7474/ 196 | [*] Removing blacklist property from 'MANAGEMENT3.INTERNAL.LOCAL' 197 | [*] Removing blacklist property from 'BGRIFFIN@EXTERNAL.LOCAL' 198 | [*] Removing blacklist property from 'BPICKEREL@INTERNAL.LOCAL' 199 | [*] Removing blacklist property from 'DESKTOP20.EXTERNAL.LOCAL' 200 | 201 | ruby bh-owned.rb -R blacklist-rels.txt 202 | [*] Using default username: neo4j 203 | [*] Using default password: BloodHound 204 | [*] Using default URL: http://127.0.0.1:7474/ 205 | [*] Removing blacklist property from relationship 'AdminTo' between 'ZDEVENS@INTERNAL.LOCAL' and 'MANAGEMENT3.INTERNAL.LOCAL' 206 | [*] Removing blacklist property from relationship 'HasSession' between 'DESKTOP21.EXTERNAL.LOCAL' and 'JANTHONY@EXTERNAL.LOCAL' 207 | ``` 208 | 209 | ##### Miscellaneous options 210 | 211 | The `-n` flag can be used to dump the names of all nodes from the database: 212 | ``` 213 | $ ruby bh-owned.rb -n 214 | [*] Using default username: neo4j 215 | [*] Using default password: BloodHound 216 | [*] Using default URL: http://127.0.0.1:7474/ 217 | AANSTETT@EXTERNAL.LOCAL 218 | ABRENES@INTERNAL.LOCAL 219 | ABROOKS@EXTERNAL.LOCAL 220 | ABROOKS_A@EXTERNAL.LOCAL 221 | ACASTERLINE@INTERNAL.LOCAL 222 | ACHAVARIN@EXTERNAL.LOCAL 223 | ACLAUSS@INTERNAL.LOCAL 224 | 225 | ``` 226 | The `-e` flag can be used to show examples of Cypher queries leveraging the custom properties. The juicy ones have been rolled up into the 'Custom Queries' available in the app: 227 | ``` 228 | $ ruby bh-owned.rb -e 229 | Find all owned Domain Admins: 230 | MATCH (n:Group) WHERE n.name =~ '.*DOMAIN ADMINS.*' WITH n MATCH p=(n)<-[r:MemberOf*1..]-(m) WHERE exists(m.owned) RETURN nodes(p),relationships(p) 231 | 232 | Find Shortest Path from owned node to Domain Admins: 233 | MATCH p=shortestPath((n)-[*1..]->(m)) WHERE exists(n.owned) AND m.name=~ '.*DOMAIN ADMINS.*' RETURN p 234 | 235 | List all directly owned nodes: 236 | MATCH (n) WHERE exists(n.owned) RETURN n 237 | 238 | Find all nodes in wave $num: 239 | MATCH (n)-[r]->(m) WHERE n.wave=$num AND m.wave=$num RETURN n,r,m 240 | 241 | Show all waves up to and including wave $num: 242 | MATCH (n)-[r]->(m) WHERE n.wave<=$num RETURN n,r,m 243 | 244 | Set owned and wave properties for a node (named $name, compromised via $method in wave $num): 245 | MATCH (n) WHERE (n.name = '$name') SET n.owned = '$method', n.wave = $num 246 | 247 | Find spread of compromise for owned nodes in wave $num: 248 | OPTIONAL MATCH (n1:User {wave:$num}) WITH collect(distinct n1) as c1 OPTIONAL MATCH (n2:Computer {wave:$num}) WITH collect(distinct n2) + c1 as c2 UNWIND c2 as n OPTIONAL MATCH p=shortestPath((n)-[*..20]->(m)) WHERE not(exists(m.wave)) WITH DISTINCT(m) SET m.wave=$num 249 | 250 | Show clusters of password reuse: 251 | MATCH p=(n)-[r:SharesPasswordWith]-(m) RETURN p 252 | 253 | Show blacklisted nodes: 254 | MATCH (n) WHERE exists(n.blacklist) RETURN n 255 | 256 | Show blacklisted relationships: 257 | MATCH (n)-[r]->(m) WHERE exists(r.blacklist) RETURN n,r,m 258 | ``` 259 | 260 | If you want to start over and remove any custom properties & relationships from your database nodes, use `--reset`: 261 | ``` 262 | $ ruby bh-owned.rb --reset 263 | [*] Using default username: neo4j 264 | [*] Using default password: BloodHound 265 | [*] Using default URL: http://127.0.0.1:7474/ 266 | [*] Removing all custom properties and SharesPasswordWith relationships 267 | ``` 268 | 269 | #### Custom Queries 270 | There are the current custom queries in this set: 271 | * __Find all owned Domain Admins__: Same as the "Find all Domain Admins" query, but instead only show Users with `owned` property. 272 | * __Find Shortest Paths from owned node to Domain Admins__: Same as the "Find Shortest Paths to Domain Admins" query, but instead only show paths originating from an `owned` node. 273 | * __Show wave__: Show only the nodes compromised in a selected wave. Useful for focusing in on newly-compromised nodes. 274 | * __Highlight delta for wave__: Show all compromised nodes up to a selected wave, and will highlight the nodes gained in that wave. Useful for visualizing privilege gains as access expands. 275 | * __Find clusters of password reuse__: Show all nodes with a SharesPasswordWith relationship to another node. 276 | * __Show blacklisted nodes__: Show all nodes with the `blacklist` property set. 277 | * __Show blacklisted relationships__: Show all relationships (and their start and end node) with the `blacklist` property set. 278 | * __Show blacklist__: Show all nodes and relationships with the `blacklist` property set. 279 | * __Show owned nodes__: Show all nodes with the `owned` property set. 280 | 281 | All queries (except for the blacklist-specific queries) will filter out nodes & relationships from the blacklist. 282 | 283 | Check out the "[UI Customizations and Custom Queries](http://porterhau5.com/blog/extending-bloodhound-track-and-visualize-your-compromise/#ui-customizations-and-custom-queries)" section of the blog post to see examples of these custom queries in action. 284 | 285 | #### Known Issues and Future Development 286 | * Add additional queries to pull specific nodes from database and write to output file 287 | * Showing deltas for large waves can be stressful on the UI, should add ability to LIMIT results to a user-defined threshold 288 | * General HTTP error-checking 289 | * More, broader ideas [here](http://porterhau5.com/blog/extending-bloodhound-track-and-visualize-your-compromise/#next-steps) 290 | 291 | #### Acknowledgements 292 | [hackerjiv](https://twitter.com/hackerjiv) and [pfizzell](https://twitter.com/pfizzell) for the sound advice and feedback. [CptJesus](https://twitter.com/CptJesus), [_wald0](https://twitter.com/_wald0), and [harmj0y](https://twitter.com/harmj0y) for making a tremendous platform. 293 | -------------------------------------------------------------------------------- /bh-owned.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby env 2 | #Encoding: UTF-8 3 | 4 | # Written by: @porterhau5 - 10/22/17 5 | 6 | require 'net/http' 7 | require 'uri' 8 | require 'json' 9 | require 'optparse' 10 | require 'set' 11 | 12 | # Recommended to create the following indexes: 13 | # CREATE INDEX ON :Group(wave) 14 | # CREATE INDEX ON :User(wave) 15 | # CREATE INDEX ON :Computer(wave) 16 | # CREATE INDEX ON :Group(blacklist) 17 | # CREATE INDEX ON :User(blacklist) 18 | # CREATE INDEX ON :Computer(blacklist) 19 | # Show indexes with ":schema" 20 | 21 | # This method changes text color to a supplied integer value which correlates to Ruby's color representation 22 | def colorize(text, color_code) 23 | "\e[#{color_code}m#{text}\e[0m" 24 | end 25 | 26 | # This method changes text color to red 27 | def red(text) 28 | colorize(text, 31) 29 | end 30 | 31 | # This method changes text color to blue 32 | def blue(text) 33 | colorize(text, 34) 34 | end 35 | 36 | # This method changes text color to green 37 | def green(text) 38 | colorize(text, 32) 39 | end 40 | 41 | def examples() 42 | puts "Find all owned Domain Admins:" 43 | puts "MATCH (n:Group) WHERE n.name =~ '.*DOMAIN ADMINS.*' WITH n MATCH p=(n)<-[r:MemberOf*1..]-(m) WHERE exists(m.owned) RETURN nodes(p),relationships(p)" 44 | puts "" 45 | puts "Find Shortest Path from owned node to Domain Admins:" 46 | puts "MATCH p=shortestPath((n)-[*1..]->(m)) WHERE exists(n.owned) AND m.name=~ '.*DOMAIN ADMINS.*' RETURN p" 47 | puts "" 48 | puts "List all directly owned nodes:" 49 | puts "MATCH (n) WHERE exists(n.owned) RETURN n" 50 | puts "" 51 | puts "Find all nodes in wave $num:" 52 | puts "MATCH (n)-[r]->(m) WHERE n.wave=$num AND m.wave=$num RETURN n,r,m" 53 | puts "" 54 | puts "Show all waves up to and including wave $num:" 55 | puts "MATCH (n)-[r]->(m) WHERE n.wave<=$num RETURN n,r,m" 56 | puts "" 57 | puts "Set owned and wave properties for a node (named $name, compromised via $method in wave $num):" 58 | puts "MATCH (n) WHERE (n.name = '$name') SET n.owned = '$method', n.wave = $num" 59 | puts "" 60 | puts "Find spread of compromise for owned nodes in wave $num:" 61 | puts "OPTIONAL MATCH (n1:User {wave:$num}) WITH collect(distinct n1) as c1 OPTIONAL MATCH (n2:Computer {wave:$num}) WITH collect(distinct n2) + c1 as c2 UNWIND c2 as n OPTIONAL MATCH p=shortestPath((n)-[*..20]->(m)) WHERE not(exists(m.wave)) WITH DISTINCT(m) SET m.wave=$num" 62 | puts "" 63 | puts "Show clusters of password reuse:" 64 | puts "MATCH p=(n)-[r:SharesPasswordWith]->(m) RETURN p" 65 | puts "" 66 | puts "Show blacklisted nodes:" 67 | puts "MATCH (n) WHERE exists(n.blacklist) RETURN n" 68 | puts "" 69 | puts "Show blacklisted relationships:" 70 | puts "MATCH (n)-[r]->(m) WHERE exists(r.blacklist) RETURN n,r,m" 71 | exit 72 | end 73 | 74 | def craft(options) 75 | # get names of all nodes 76 | hash = Hash.new { |h,k| h[k] = [] } 77 | if options.nodes 78 | hash['statements'] << {'statement' => "MATCH (n) RETURN (n.name)"} 79 | return hash.to_json 80 | 81 | # remove owned/wave/blacklist properties, delete SharesPasswordWith relationships 82 | elsif options.reset 83 | puts blue("[*]") + " Removing all custom properties and custom relationships" 84 | hash['statements'] << {'statement' => "MATCH (n) WHERE exists(n.wave) OR exists(n.owned) OR exists(n.blacklist) REMOVE n.wave, n.owned, n.blacklist"} 85 | hash['statements'] << {'statement' => "MATCH (n)-[r:SharesPasswordWith]-(m) DELETE r"} 86 | hash['statements'] << {'statement' => "MATCH (n)-[r {blacklist:true}]-(m) REMOVE r.blacklist"} 87 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_22]-(m) DELETE r"} 88 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_80]-(m) DELETE r"} 89 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_135]-(m) DELETE r"} 90 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_139]-(m) DELETE r"} 91 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_389]-(m) DELETE r"} 92 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_443]-(m) DELETE r"} 93 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_445]-(m) DELETE r"} 94 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_1433]-(m) DELETE r"} 95 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_1521]-(m) DELETE r"} 96 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_3306]-(m) DELETE r"} 97 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_3389]-(m) DELETE r"} 98 | hash['statements'] << {'statement' => "MATCH (n)-[r:Connected_5432]-(m) DELETE r"} 99 | return hash.to_json 100 | 101 | # once nodes are added, set 'wave' for newly owned nodes 102 | elsif options.spread 103 | hash['statements'] << {'statement' => "OPTIONAL MATCH (n1:User {wave:#{options.wave}}) WITH collect(distinct n1) as c1 OPTIONAL MATCH (n2:Computer {wave:#{options.wave}}) WITH collect(distinct n2) + c1 as c2 UNWIND c2 as n OPTIONAL MATCH p=shortestPath((n)-[*..20]->(m)) WHERE not(exists(m.wave)) WITH DISTINCT(m) SET m.wave=#{options.wave} RETURN m.name, #{options.wave}", 'includeStats' => true} 104 | return hash.to_json 105 | 106 | # add 'owned' and 'wave' properties to nodes from file 107 | elsif options.add 108 | File.foreach(options.add) do |node| 109 | name, method = node.split(',', 2) 110 | if method.nil? 111 | method = "Not specified" 112 | end 113 | # if -w flag set, then overwrite previous property if it exists, otherwise don't overwrite 114 | if options.forceWave.nil? 115 | hash['statements'] << {'statement' => "MATCH (n) WHERE (n.name = \"#{name.chomp}\") SET n.owned = \"#{method.chomp}\", n.wave = #{options.wave} RETURN \'#{name.chomp}\', \'#{options.wave}\', \'#{method.chomp}\'", 'includeStats' => true} 116 | else 117 | # this uses a Cypher hack for doing if/else conditionals 118 | hash['statements'] << {'statement' => "MATCH (n) WHERE (n.name = \"#{name.chomp}\") FOREACH (ignoreMe in CASE WHEN exists(n.wave) THEN [1] ELSE [] END | SET n.wave=n.wave) FOREACH (ignoreMe in CASE WHEN not(exists(n.wave)) THEN [1] ELSE [] END | SET n.owned = \"#{method.chomp}\", n.wave = #{options.wave}) RETURN \'#{name.chomp}\',\'#{options.wave}\',\'#{method.chomp}\'", 'includeStats' => true} 119 | end 120 | end 121 | return hash.to_json 122 | 123 | # add 'owned' property to nodes from file 124 | elsif options.addnowave 125 | File.foreach(options.addnowave) do |node| 126 | name, method = node.split(',', 2) 127 | if method.nil? 128 | method = "Not specified" 129 | end 130 | hash['statements'] << {'statement' => "MATCH (n) WHERE (n.name = \"#{name.chomp}\") SET n.owned = \"#{method.chomp}\" RETURN \'#{name.chomp}\', \'#{method.chomp}\'", 'includeStats' => true} 131 | end 132 | return hash.to_json 133 | 134 | # Create SharesPasswordWith relationships between all nodes in file 135 | elsif options.spw 136 | nodes = [] 137 | File.foreach(options.spw) do |node| 138 | nodes.push(node) 139 | end 140 | nodes.combination(2).to_a.each do |n,m| 141 | hash['statements'] << {'statement' => "MATCH (n {name:\"#{n.chomp}\"}),(m {name:\"#{m.chomp}\"}) WITH n,m MERGE (n)-[:SharesPasswordWith]->(m) WITH n,m MERGE (n)<-[:SharesPasswordWith]-(m) RETURN \'#{n.chomp}\',\'#{m.chomp}\'", 'includeStats' => true} 142 | end 143 | return hash.to_json 144 | 145 | # add 'blacklist' property to each node 146 | elsif options.blacklistn 147 | File.foreach(options.blacklistn) do |node| 148 | hash['statements'] << {'statement' => "MATCH (n {name:\"#{node.chomp}\"}) SET n.blacklist = true RETURN \'#{node.chomp}\'", 'includeStats' => true} 149 | end 150 | return hash.to_json 151 | 152 | # add 'blacklist' property to each relationship 153 | elsif options.blacklistr 154 | File.foreach(options.blacklistr) do |path| 155 | first, rel, last = path.split(',', 3) 156 | hash['statements'] << {'statement' => "MATCH (n {name:\"#{first.chomp}\"})-[r:#{rel.chomp}]->(m {name:\"#{last.chomp}\"}) SET r.blacklist = true RETURN \'#{first.chomp}\',\'#{rel.chomp}\',\'#{last.chomp}\' ", 'includeStats' => true} 157 | end 158 | return hash.to_json 159 | 160 | # remove 'blacklist' property from each node 161 | elsif options.rblacklistn 162 | File.foreach(options.rblacklistn) do |node| 163 | puts blue("[*]") + " Removing blacklist property from \'#{node.chomp}\'" 164 | hash['statements'] << {'statement' => "MATCH (n {name:\"#{node.chomp}\"}) REMOVE n.blacklist RETURN \'#{node.chomp}\'", 'includeStats' => true} 165 | end 166 | return hash.to_json 167 | 168 | # remove 'blacklist' property from each relationship 169 | elsif options.rblacklistr 170 | File.foreach(options.rblacklistr) do |path| 171 | first, rel, last = path.split(',', 3) 172 | puts blue("[*]") + " Removing blacklist property from relationship \'#{rel.chomp}\' between \'#{first.chomp}\' and \'#{last.chomp}\'" 173 | hash['statements'] << {'statement' => "MATCH (n {name:\"#{first.chomp}\"})-[r:#{rel.chomp}]->(m {name:\"#{last.chomp}\"}) REMOVE r.blacklist RETURN \'#{first.chomp}\',\'#{rel.chomp}\',\'#{last.chomp}\' ", 'includeStats' => true} 174 | end 175 | return hash.to_json 176 | 177 | # add connection 178 | elsif options.connection 179 | # initial list of interesting ports 180 | ports = ["22","80","135","139","389","443","445","1433","1521","3306","3389","5432"] 181 | edges = Set.new 182 | nodes = {} 183 | File.foreach(options.connection) do |conn| 184 | conn.chomp! 185 | fields = conn.split() 186 | (src, sport) = fields[1].split(/:/) 187 | (dst, dport) = fields[2].split(/:/) 188 | # assumption: ports indicated the destination port 189 | if ports.member? sport 190 | edges << [dst, src, sport] 191 | elsif ports.member? dport 192 | edges << [src, dst, dport] 193 | end 194 | end 195 | 196 | dns = [] 197 | if options.dns 198 | dns = File.readlines(options.dns) 199 | end 200 | 201 | edges.each do |edge| 202 | sname = "" 203 | dname = "" 204 | # if source IP is in DNS, get Computer name 205 | if options.dns and dns.select{ |x| x.match(edge[0]) }.length > 0 206 | record = dns.select{ |x| x.match(edge[0]) }.last.split("\"") 207 | sname = record[1] 208 | # otherwise use IP as node name 209 | else 210 | sname = edge[0] 211 | end 212 | # if dest IP is in DNS, get Computer name 213 | if options.dns and dns.select{ |x| x.match(edge[1]) }.length > 0 214 | record = dns.select{ |x| x.match(edge[1]) }.last.split("\"") 215 | dname = record[1] 216 | # otherwise use IP as node name 217 | else 218 | dname = edge[1] 219 | end 220 | 221 | hash['statements'] << {'statement' => "MERGE (s:Computer {name:\"#{sname}\"}) MERGE (d:Computer {name:\"#{dname}\"}) MERGE (s)-[:Connected_#{edge[2]}]->(d)", 'includeStats' => true} 222 | end 223 | return hash.to_json 224 | 225 | end 226 | end 227 | 228 | def sendrequest(options) 229 | uri = URI.parse(options.url) 230 | 231 | # Create the HTTP object 232 | http = Net::HTTP.new(uri.host, uri.port) 233 | http.read_timeout = 120 234 | request = Net::HTTP::Post.new(uri.request_uri) 235 | request["Accept"] = "application/json; charset=UTF-8" 236 | request.content_type = "application/json" 237 | request.basic_auth options.username, options.password 238 | if options.wave == -1 239 | hash = Hash.new { |h,k| h[k] = [] } 240 | hash['statements'] << {'statement' => "MATCH (n) WHERE exists(n.owned) RETURN max(n.wave)"} 241 | request.body = hash.to_json 242 | else 243 | request.body = craft(options) 244 | end 245 | 246 | # Send the request 247 | response = http.request(request) 248 | 249 | parse(options, response) 250 | end 251 | 252 | def parse(options, response) 253 | # 254 | # print all nodes 255 | # 256 | if options.nodes 257 | out = [] 258 | data = JSON.parse(response.body) 259 | # cycle through results (should only be 1) 260 | data['results'].each do |r| 261 | # cycle through rows returned 262 | r['data'].each do |d| 263 | # add to array 264 | out.push(d['row']) 265 | end if r['data'].any? 266 | end if data['results'].any? 267 | # sort, uniq, display 268 | puts out.sort.uniq 269 | # 270 | # determine wave number 271 | # 272 | elsif options.wave == -1 273 | resp = JSON.parse(response.body) 274 | resp['results'].each do |r| 275 | r['data'].each do |d| 276 | if d['row'] == [nil] 277 | puts blue("[*]") + " No previously owned nodes found, setting wave to 1" 278 | options.wave = 1 279 | else 280 | options.wave = d['row'][0].to_i + 1 281 | end 282 | end if r['data'].any? 283 | end if resp['results'].any? 284 | # 285 | # parse spread of compromise 286 | # 287 | elsif options.spread 288 | resp = JSON.parse(response.body) 289 | resp['results'].each do |r| 290 | puts blue("[*]") + " Finding spread of compromise for wave #{options.wave}" 291 | r['stats'].each do |s| 292 | # check stats to see if properties were set 293 | if s.first == "properties_set" and s.last == 0 294 | # if there are records in data, then properties already set 295 | if r['data'].any? 296 | r['data'].each do |d| 297 | puts blue("[*]") + " No additional nodes found for wave #{options.wave}" 298 | end 299 | else 300 | puts red("[-]") + " No additional nodes found for wave #{options.wave}" 301 | end 302 | elsif s.first == "properties_set" and s.last != 0 303 | out = [] 304 | count = 0 305 | # cycle through rows returned 306 | r['data'].each do |d| 307 | next unless not d['row'][0].to_s.empty? 308 | out.push(d['row'][0]) 309 | count += 1 310 | end if r['data'].any? 311 | puts green("[+]") + " #{count} nodes found:" 312 | puts out.sort.uniq 313 | end 314 | end if r['stats'].any? 315 | end if resp['results'].any? 316 | # 317 | # parse nodes added 318 | # 319 | elsif options.add 320 | success = true 321 | resp = JSON.parse(response.body) 322 | resp['results'].each do |r| 323 | # node names provided are returned as columns 324 | names = [] 325 | r['columns'].each do |c| 326 | names.push(c) 327 | end 328 | r['stats'].each do |s| 329 | # check stats to see if properties were set 330 | if s.first == "properties_set" and s.last == 0 331 | # if there are records in data, then properties already set 332 | if not r['data'].any? 333 | puts red("[-]") + " Properties not added for #{names.first} (node not found, check spelling?)" 334 | success = false 335 | end 336 | elsif s.first == "properties_set" and s.last == 2 337 | puts green("[+]") + " Success, marked #{names.first} as owned in wave #{names[1]} via #{names.last}" 338 | elsif s.first == "properties_set" and s.last == 1 339 | puts blue("[*]") + " Properties already exist for #{names.first}, skipping (overwrite with flag -w )" 340 | end 341 | end if r['stats'].any? 342 | end if resp['results'].any? 343 | # if all nodes were added successfully or skipped, find spread for new nodes 344 | if success 345 | options.spread = true 346 | sendrequest(options) 347 | else 348 | puts red("[-]") + " Skipping finding spread of compromise due to \"node not found\" error" 349 | end 350 | # 351 | # parse nodes added (no wave) 352 | # 353 | elsif options.addnowave 354 | resp = JSON.parse(response.body) 355 | resp['results'].each do |r| 356 | # node names provided are returned as columns 357 | names = [] 358 | r['columns'].each do |c| 359 | names.push(c) 360 | end 361 | r['stats'].each do |s| 362 | # check stats to see if properties were set 363 | if s.first == "properties_set" and s.last == 0 364 | # if there are records in data, then properties already set 365 | if not r['data'].any? 366 | puts red("[-]") + " owned property not added for #{names.first} (node not found, check spelling?)" 367 | end 368 | elsif s.first == "properties_set" and s.last == 1 369 | puts green("[+]") + " Success, marked #{names.first} as owned via #{names.last}" 370 | end 371 | end if r['stats'].any? 372 | end if resp['results'].any? 373 | # 374 | # parse blacklist node added 375 | # 376 | elsif options.blacklistn 377 | resp = JSON.parse(response.body) 378 | resp['results'].each do |r| 379 | # node names provided are returned as columns 380 | names = [] 381 | r['columns'].each do |c| 382 | names.push(c) 383 | end 384 | r['stats'].each do |s| 385 | # check stats to see if properties were set 386 | if s.first == "properties_set" and s.last == 0 387 | # if there are records in data, then properties already set 388 | if not r['data'].any? 389 | puts red("[-]") + " Property not added for #{names.first} (node not found, check spelling?)" 390 | end 391 | elsif s.first == "properties_set" and s.last == 1 392 | puts green("[+]") + " Success, marked #{names.first} as blacklisted" 393 | #elsif s.first == "properties_set" and s.last == 1 394 | # puts blue("[*]") + " Property already exist for #{names.first}, skipping" 395 | end 396 | end if r['stats'].any? 397 | end if resp['results'].any? 398 | # 399 | # parse blacklist rel added 400 | # 401 | elsif options.blacklistr 402 | resp = JSON.parse(response.body) 403 | resp['results'].each do |r| 404 | # node names provided are returned as columns 405 | names = [] 406 | r['columns'].each do |c| 407 | names.push(c) 408 | end 409 | r['stats'].each do |s| 410 | # check stats to see if properties were set 411 | if s.first == "properties_set" and s.last == 0 412 | # if there are records in data, then properties already set 413 | if not r['data'].any? 414 | puts red("[-]") + " Property not added for (#{names.first})-[:#{names[1]}]->(#{names.last}), (node or relationship not found, check spelling?)" 415 | end 416 | elsif s.first == "properties_set" and s.last == 1 417 | puts green("[+]") + " Success, marked #{names[1]} as blacklisted from #{names.first} to #{names.last}" 418 | #elsif s.first == "properties_set" and s.last == 1 419 | # puts blue("[*]") + " Property already exist for #{names.first}, skipping" 420 | end 421 | end if r['stats'].any? 422 | end if resp['results'].any? 423 | # 424 | # parse SharesPasswordWith 425 | # 426 | elsif options.spw 427 | resp = JSON.parse(response.body) 428 | resp['results'].each do |r| 429 | # node names provided are returned as columns 430 | names = [] 431 | r['columns'].each do |c| 432 | names.push(c) 433 | end 434 | r['stats'].each do |s| 435 | # check stats to see if a relationship was created 436 | if s.first == "relationships_created" and s.last == 0 437 | # if there are records in data, then relationship already exists 438 | if r['data'].any? 439 | r['data'].each do |d| 440 | puts blue("[*]") + " Relationship already exists for #{names.first} and #{names.last}" 441 | end 442 | else 443 | puts red("[-]") + " Relationship not created for #{names.first} and #{names.last} (check spelling)" 444 | end 445 | elsif s.first == "relationships_created" and s.last == 2 446 | puts green("[+]") + " Created SharesPasswordWith relationship between #{names.first} and #{names.last}" 447 | elsif s.first == "relationships_created" and (s.last != 0 or s.last != 2) 448 | puts "Something went wrong when creating SharesPasswordWith relationship" 449 | end 450 | end if r['stats'].any? 451 | end if resp['results'].any? 452 | end 453 | # uncomment line below to debug 454 | #puts JSON.pretty_generate(JSON.parse(response.body)) 455 | end 456 | 457 | def main() 458 | options = OpenStruct.new 459 | ARGV << '-h' if ARGV.empty? 460 | OptionParser.new do |opt| 461 | opt.banner = "Usage: ruby bh-owned.rb [options]" 462 | opt.on('Server Details:') 463 | opt.on('-u', '--username ', 'Neo4j database username (default: \'neo4j\')') { |o| options.username = o } 464 | opt.on('-p', '--password ', 'Neo4j database password (default: \'BloodHound\')') { |o| options.password = o } 465 | opt.on('-U', '--url ', 'URL of Neo4j RESTful host (default: \'http://127.0.0.1:7474/\')') { |o| options.url = o } 466 | opt.on('Owned/Wave/SPW:') 467 | opt.on('-a', '--add ', 'add \'owned\' and \'wave\' property to nodes in ') { |o| options.add = o } 468 | opt.on('-A', '--add-no-wave ', 'add \'owned\' property to nodes in (skip \'wave\' property)') { |o| options.addnowave = o } 469 | opt.on('-w', '--wave ', Integer, 'value to set \'wave\' property (override default behavior)') { |o| options.wave = o } 470 | opt.on('-s', '--spw ', 'add \'SharesPasswordWith\' relationship between all nodes in ') { |o| options.spw = o } 471 | opt.on('Blacklisting:') 472 | opt.on('-b', '--bl-node ', 'add \'blacklist\' property to nodes in ') { |o| options.blacklistn = o } 473 | opt.on('-B', '--bl-rel ', 'add \'blacklist\' property to relationships in ') { |o| options.blacklistr = o } 474 | opt.on('-r', '--remove-bl-node ', 'remove \'blacklist\' property from nodes in ') { |o| options.rblacklistn = o } 475 | opt.on('-R', '--remove-bl-rel ', 'remove \'blacklist\' property from relationships in ') { |o| options.rblacklistr = o } 476 | opt.on('Connections:') 477 | opt.on('-c', '--connections ', 'add connection info from netstat ') { |o| options.connection = o } 478 | opt.on('-d', '--dns ', 'contains DNS mapping of IP to computer name (10.2.3.4,srv1.int.local)') { |o| options.dns = o } 479 | opt.on('Misc Queries:') 480 | opt.on('-n', '--nodes', 'get all node names') { |o| options.nodes = o } 481 | opt.on('-e', '--examples', 'reference doc of custom Cypher queries for BloodHound') { |o| options.examples = o } 482 | opt.on('--reset', 'remove all custom properties and SharesPasswordWith relationships') { |o| options.reset = o } 483 | end.parse! 484 | 485 | if options.examples 486 | examples() 487 | end 488 | 489 | if options.username.nil? 490 | options.username = 'neo4j' 491 | puts blue("[*]") + " Using default username: neo4j" 492 | end 493 | 494 | if options.password.nil? 495 | options.password = 'BloodHound' 496 | puts blue("[*]") + " Using default password: BloodHound" 497 | end 498 | 499 | if options.url.nil? 500 | options.url = 'http://127.0.0.1:7474/db/data/transaction/commit' 501 | puts blue("[*]") + " Using default URL: http://127.0.0.1:7474/" 502 | else 503 | options.url = options.url.gsub(/\/+$/, '') + '/db/data/transaction/commit' 504 | puts blue("[*]") + " URL set: #{options.url}" 505 | end 506 | 507 | if options.add 508 | if File.exist?(options.add) == false 509 | puts red("#{options.add} does not exist! Exiting.") 510 | exit 1 511 | elsif options.addnowave.nil? 512 | if options.wave.nil? 513 | # -1 means we don't know current max "n.wave" value 514 | options.wave = -1 515 | sendrequest(options) 516 | options.forceWave = true 517 | end 518 | else 519 | # -2 means we're skipping wave/spread 520 | options.wave = -2 521 | end 522 | end 523 | 524 | if options.spw 525 | if File.exist?(options.spw) == false 526 | puts red("#{options.spw} does not exist! Exiting.") 527 | exit 1 528 | end 529 | end 530 | 531 | if options.blacklistn 532 | if File.exist?(options.blacklistn) == false 533 | puts red("#{options.blacklistn} does not exist! Exiting.") 534 | exit 1 535 | end 536 | end 537 | 538 | if options.blacklistr 539 | if File.exist?(options.blacklistr) == false 540 | puts red("#{options.blacklistr} does not exist! Exiting.") 541 | exit 1 542 | end 543 | end 544 | 545 | if options.rblacklistn 546 | if File.exist?(options.rblacklistn) == false 547 | puts red("#{options.rblacklistn} does not exist! Exiting.") 548 | exit 1 549 | end 550 | end 551 | 552 | if options.rblacklistr 553 | if File.exist?(options.rblacklistr) == false 554 | puts red("#{options.rblacklistr} does not exist! Exiting.") 555 | exit 1 556 | end 557 | end 558 | 559 | if options.connection 560 | if File.exist?(options.connection) == false 561 | puts red("#{options.connection} does not exist! Exiting.") 562 | exit 1 563 | end 564 | if options.dns 565 | if File.exist?(options.dns) == false 566 | puts red("#{options.dns} does not exist! Exiting.") 567 | exit 1 568 | end 569 | else 570 | puts "No DNS file set (-d), using IP addresses as Computer node names." 571 | end 572 | end 573 | 574 | options.spread = false 575 | 576 | sendrequest(options) 577 | end 578 | 579 | main() 580 | -------------------------------------------------------------------------------- /customqueries.json: -------------------------------------------------------------------------------- 1 | { 2 | "queries": [ 3 | { 4 | "name": "Find all owned Domain Admins", 5 | "requireNodeSelect": false, 6 | "query": "MATCH (n:Group) WHERE n.name =~ {name} WITH n MATCH p=(n)<-[r:MemberOf*1..]-(m) WHERE exists(m.owned) AND NONE (x IN nodes(p) WHERE exists(x.blacklist)) AND NONE (x in relationships(p) WHERE exists(x.blacklist)) RETURN nodes(p),relationships(p)", 7 | "allowCollapse": false, 8 | "props": {"name": "(?i).*DOMAIN ADMINS.*"} 9 | }, 10 | { 11 | "name": "Find Shortest Paths from owned node to Domain Admins", 12 | "requireNodeSelect": true, 13 | "nodeSelectQuery": { 14 | "query":"MATCH (n:Group) WHERE n.name =~ {name} RETURN n.name", 15 | "queryProps": {"name":"(?i).*DOMAIN ADMINS.*"}, 16 | "onFinish": "MATCH (n),(m:Group {name:{result}}),p=shortestPath((n)-[*1..12]->(m)) WHERE exists(n.owned) AND NONE (x IN nodes(p) WHERE exists(x.blacklist)) AND NONE (x in relationships(p) WHERE exists(x.blacklist)) RETURN p", 17 | "start":"", 18 | "end": "{}", 19 | "allowCollapse": true, 20 | "boxTitle": "Select domain to map..." 21 | } 22 | }, 23 | { 24 | "name": "Show Wave", 25 | "requireNodeSelect": true, 26 | "nodeSelectQuery": { 27 | "query":"MATCH (n) WHERE exists(n.wave) WITH DISTINCT n.wave as d RETURN toString(d) ORDER BY d", 28 | "queryProps": {}, 29 | "onFinish": "OPTIONAL MATCH (n1:User {wave:toInt({result})}) WITH collect(distinct n1) as c1 OPTIONAL MATCH (n2:Computer {wave:toInt({result})}) WITH collect(distinct n2) + c1 as c2 OPTIONAL MATCH (n3:Group {wave:toInt({result})}) WITH c2, collect(distinct n3) + c2 as c3 UNWIND c2 as n UNWIND c3 as m MATCH (n)-[r]->(m) WHERE not(exists(n.blacklist)) AND not(exists(m.blacklist)) AND not(exists(r.blacklist)) RETURN n,r,m", 30 | "start": "", 31 | "end": "", 32 | "allowCollapse": true, 33 | "boxTitle": "Select wave..." 34 | } 35 | }, 36 | { 37 | "name": "Highlight Delta for Wave", 38 | "requireNodeSelect": true, 39 | "nodeSelectQuery": { 40 | "query":"MATCH (n) WHERE exists(n.wave) WITH DISTINCT n.wave as d RETURN toString(d) ORDER BY d", 41 | "queryProps": {}, 42 | "onFinish": "MATCH (n)-[r]->(m) WHERE n.wave<=toInt({result}) AND not(exists(n.blacklist)) AND not(exists(m.blacklist)) AND not(exists(r.blacklist)) RETURN n,r,m", 43 | "start": "", 44 | "end": "", 45 | "allowCollapse": true, 46 | "boxTitle": "Select wave to show deltas..." 47 | } 48 | }, 49 | { 50 | "name": "Find Clusters of Password Reuse", 51 | "requireNodeSelect": false, 52 | "query": "MATCH p=(n)-[r:SharesPasswordWith]->(m) WHERE not(exists(n.blacklist)) AND not(exists(m.blacklist)) RETURN p", 53 | "allowCollapse": true, 54 | "props": {} 55 | }, 56 | { 57 | "name": "Show Blacklisted Nodes", 58 | "requireNodeSelect": false, 59 | "query": "MATCH (n) WHERE exists(n.blacklist) RETURN n", 60 | "allowCollapse": true, 61 | "props": {} 62 | }, 63 | { 64 | "name": "Show Blacklisted Relationships", 65 | "requireNodeSelect": false, 66 | "query": "MATCH (n)-[r]->(m) WHERE exists(r.blacklist) RETURN n,r,m", 67 | "allowCollapse": true, 68 | "props": {} 69 | }, 70 | { 71 | "name": "Show Blacklist", 72 | "requireNodeSelect": false, 73 | "query": "OPTIONAL MATCH (n {blacklist:true}) WITH n OPTIONAL MATCH p=(()-[{blacklist:true}]->()) RETURN n,p", 74 | "allowCollapse": true, 75 | "props": {} 76 | }, 77 | { 78 | "name": "Show owned Nodes", 79 | "requireNodeSelect": false, 80 | "query": "MATCH (n) WHERE exists(n.owned) RETURN n", 81 | "allowCollapse": true, 82 | "props": {} 83 | }, 84 | { 85 | "name": "Find Shortest Paths to DA Equivalency", 86 | "requireNodeSelect": true, 87 | "nodeSelectQuery": { 88 | "query":"MATCH (n:Group) WHERE n.name =~ {name} RETURN n.name", 89 | "queryProps": {"name":"(?i).*DOMAIN CONTROLLERS.*"}, 90 | "onFinish": "MATCH (n:User),(m:Group {name:{result}}),p=shortestPath((n)-[*1..]->(m)) RETURN p", 91 | "start":"", 92 | "end": "{}", 93 | "allowCollapse": true, 94 | "boxTitle": "Select domain to map..." 95 | } 96 | }, 97 | { 98 | "name": "Find Shortest Paths to Domain Admins from Foreign User", 99 | "requireNodeSelect": true, 100 | "nodeSelectQuery": { 101 | "query": "MATCH (n:Domain) RETURN n.name", 102 | "queryProps":{}, 103 | "onFinish": "MATCH (n:User) WHERE NOT n.name ENDS WITH ('@' + {result}) WITH n MATCH (m:Group {name:('DOMAIN ADMINS@' + {result})}) WITH n,m MATCH p=shortestPath((n)-[*1..]->(m)) RETURN p", 104 | "start": "{}", 105 | "end": "", 106 | "allowCollapse": true, 107 | "boxTitle": "Select target domain..." 108 | } 109 | }, 110 | { 111 | "name": "Show Connections over 22/tcp", 112 | "requireNodeSelect": false, 113 | "query": "MATCH p=((s:Computer)-[:Connected_22]->(d:Computer)) RETURN p", 114 | "allowCollapse": true, 115 | "props": {} 116 | }, 117 | { 118 | "name": "Show Connections over 80/tcp", 119 | "requireNodeSelect": false, 120 | "query": "MATCH p=((s:Computer)-[:Connected_80]->(d:Computer)) RETURN p", 121 | "allowCollapse": true, 122 | "props": {} 123 | }, 124 | { 125 | "name": "Show Connections over 135/tcp", 126 | "requireNodeSelect": false, 127 | "query": "MATCH p=((s:Computer)-[:Connected_135]->(d:Computer)) RETURN p", 128 | "allowCollapse": true, 129 | "props": {} 130 | }, 131 | { 132 | "name": "Show Connections over 139/tcp", 133 | "requireNodeSelect": false, 134 | "query": "MATCH p=((s:Computer)-[:Connected_139]->(d:Computer)) RETURN p", 135 | "allowCollapse": true, 136 | "props": {} 137 | }, 138 | { 139 | "name": "Show Connections over 389/tcp", 140 | "requireNodeSelect": false, 141 | "query": "MATCH p=((s:Computer)-[:Connected_389]->(d:Computer)) RETURN p", 142 | "allowCollapse": true, 143 | "props": {} 144 | }, 145 | { 146 | "name": "Show Connections over 443/tcp", 147 | "requireNodeSelect": false, 148 | "query": "MATCH p=((s:Computer)-[:Connected_443]->(d:Computer)) RETURN p", 149 | "allowCollapse": true, 150 | "props": {} 151 | }, 152 | { 153 | "name": "Show Connections over 445/tcp", 154 | "requireNodeSelect": false, 155 | "query": "MATCH p=((s:Computer)-[:Connected_445]->(d:Computer)) RETURN p", 156 | "allowCollapse": true, 157 | "props": {} 158 | }, 159 | { 160 | "name": "Show Connections over 1433/tcp", 161 | "requireNodeSelect": false, 162 | "query": "MATCH p=((s:Computer)-[:Connected_1433]->(d:Computer)) RETURN p", 163 | "allowCollapse": true, 164 | "props": {} 165 | }, 166 | { 167 | "name": "Show Connections over 1521/tcp", 168 | "requireNodeSelect": false, 169 | "query": "MATCH p=((s:Computer)-[:Connected_1521]->(d:Computer)) RETURN p", 170 | "allowCollapse": true, 171 | "props": {} 172 | }, 173 | { 174 | "name": "Show Connections over 3306/tcp", 175 | "requireNodeSelect": false, 176 | "query": "MATCH p=((s:Computer)-[:Connected_3306]->(d:Computer)) RETURN p", 177 | "allowCollapse": true, 178 | "props": {} 179 | }, 180 | { 181 | "name": "Show Connections over 3389/tcp", 182 | "requireNodeSelect": false, 183 | "query": "MATCH p=((s:Computer)-[:Connected_3389]->(d:Computer)) RETURN p", 184 | "allowCollapse": true, 185 | "props": {} 186 | }, 187 | { 188 | "name": "Show Connections over 5432/tcp", 189 | "requireNodeSelect": false, 190 | "query": "MATCH p=((s:Computer)-[:Connected_5432]->(d:Computer)) RETURN p", 191 | "allowCollapse": true, 192 | "props": {} 193 | }, 194 | { 195 | "name": "Show Database Connections", 196 | "requireNodeSelect": false, 197 | "query": "MATCH p=((s:Computer)-[:Connected_1433|Connected_1521|Connected_3306|Connected_5432]->(d:Computer)) RETURN p", 198 | "allowCollapse": true, 199 | "props": {} 200 | }, 201 | { 202 | "name": "Show Web App Connections", 203 | "requireNodeSelect": false, 204 | "query": "MATCH p=((s:Computer)-[:Connected_80|Connected_443]->(d:Computer)) RETURN p", 205 | "allowCollapse": true, 206 | "props": {} 207 | }, 208 | { 209 | "name": "Find Top 10 RDP Servers", 210 | "requireNodeSelect": false, 211 | "query": "MATCH (n:Computer)-[r:Connected_3389]->(m:Computer) WHERE NOT m.name STARTS WITH 'ANONYMOUS LOGON' AND NOT m.name='' WITH m, count(r) as rel_count order by rel_count desc LIMIT 10 MATCH (n)-[r:Connected_3389]->(m) RETURN n,r,m", 212 | "allowCollapse": true, 213 | "props": {} 214 | }, 215 | { 216 | "name": "Find Top 10 SSH Servers", 217 | "requireNodeSelect": false, 218 | "query": "MATCH (n:Computer)-[r:Connected_22]->(m:Computer) WHERE NOT m.name STARTS WITH 'ANONYMOUS LOGON' AND NOT m.name='' WITH m, count(r) as rel_count order by rel_count desc LIMIT 10 MATCH (n)-[r:Connected_22]->(m) RETURN n,r,m", 219 | "allowCollapse": true, 220 | "props": {} 221 | }, 222 | { 223 | "name": "Find Top 10 Web Apps with most Connections", 224 | "requireNodeSelect": false, 225 | "query": "MATCH (n:Computer)-[r:Connected_80|Connected_443]->(m:Computer) WHERE NOT m.name STARTS WITH 'ANONYMOUS LOGON' AND NOT m.name='' WITH m, count(r) as rel_count order by rel_count desc LIMIT 10 MATCH (n)-[r:Connected_80|Connected_443]->(m) RETURN n,r,m", 226 | "allowCollapse": true, 227 | "props": {} 228 | } 229 | ] 230 | } 231 | -------------------------------------------------------------------------------- /example-files/1st-wave.txt: -------------------------------------------------------------------------------- 1 | BLOPER@INTERNAL.LOCAL,LLMNR wpad 2 | JCARNEAL@INTERNAL.LOCAL,NBNS wpad 3 | -------------------------------------------------------------------------------- /example-files/2nd-wave.txt: -------------------------------------------------------------------------------- 1 | ZDEVENS@INTERNAL.LOCAL,Password spray 2 | BPICKEREL@INTERNAL.LOCAL,Password spray 3 | -------------------------------------------------------------------------------- /example-files/3rd-wave.txt: -------------------------------------------------------------------------------- 1 | SMADDUX@INTERNAL.LOCAL,Creds in file on DATABASE5 2 | QBULLIS@EXTERNAL.LOCAL,Creds in file on DATABASE5 3 | -------------------------------------------------------------------------------- /example-files/4th-wave.txt: -------------------------------------------------------------------------------- 1 | BGRIFFIN@EXTERNAL.LOCAL,Mimikatz on MANAGEMENT3 2 | FILESERVER6.INTERNAL.LOCAL,Local Administrator password reuse (dumped from MANAGEMENT3) 3 | -------------------------------------------------------------------------------- /example-files/BREYES-password-reuse.txt: -------------------------------------------------------------------------------- 1 | BREYES@INTERNAL.LOCAL 2 | BREYES.ADMIN@INTERNAL.LOCAL 3 | -------------------------------------------------------------------------------- /example-files/blacklist-nodes.txt: -------------------------------------------------------------------------------- 1 | MANAGEMENT3.INTERNAL.LOCAL 2 | BGRIFFIN@EXTERNAL.LOCAL 3 | BPICKEREL@INTERNAL.LOCAL 4 | DESKTOP20.EXTERNAL.LOCAL 5 | -------------------------------------------------------------------------------- /example-files/blacklist-rels.txt: -------------------------------------------------------------------------------- 1 | ZDEVENS@INTERNAL.LOCAL,AdminTo,MANAGEMENT3.INTERNAL.LOCAL 2 | DESKTOP21.EXTERNAL.LOCAL,HasSession,JANTHONY@EXTERNAL.LOCAL 3 | -------------------------------------------------------------------------------- /example-files/common-local-admins.txt: -------------------------------------------------------------------------------- 1 | MANAGEMENT3.INTERNAL.LOCAL 2 | FILESERVER6.INTERNAL.LOCAL 3 | SYSTEM38.INTERNAL.LOCAL 4 | DESKTOP40.EXTERNAL.LOCAL 5 | -------------------------------------------------------------------------------- /example-files/owned-no-wave.txt: -------------------------------------------------------------------------------- 1 | AMURPHY@INTERNAL.LOCAL,Social engineered 2 | OPIERCE@INTERNAL.LOCAL 3 | TROBINSON@INTERNAL.LOCAL,Phish 4 | --------------------------------------------------------------------------------