├── .circleci └── config.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── cmd └── windapsearch │ └── main.go ├── go.mod ├── go.sum ├── magefile.go ├── pkg ├── adschema │ ├── attr_syntaxes.go │ ├── attributes.go │ ├── entry.go │ ├── enums │ │ └── enumerations.go │ ├── gen.go │ ├── json_marshal.go │ ├── objectreplicalinks │ │ ├── dnsrecord │ │ │ ├── convert.go │ │ │ └── dnsrecord.go │ │ └── objectreplicalinks.go │ ├── root_dse_syntaxes.go │ ├── syntax_enums.go │ ├── syntax_jsonenums.go │ ├── syntaxes.go │ └── utils.go ├── buildinfo │ └── version.go ├── dns │ └── dns.go ├── ldapsession │ ├── domaininfo.go │ ├── search.go │ └── session.go ├── modules │ ├── README.md │ ├── admin_objects.go │ ├── computers.go │ ├── custom_filter.go │ ├── dnsnames.go │ ├── dnszones.go │ ├── domainadmins.go │ ├── gpos.go │ ├── groups.go │ ├── members.go │ ├── metadata.go │ ├── modules.go │ ├── privileged_users.go │ ├── search.go │ ├── unconstrained.go │ ├── user-spns.go │ └── users.go ├── utils │ ├── ldap_filters.go │ └── prompts.go └── windapsearch │ ├── handleErrors.go │ ├── results.go │ └── windapsearch.go └── tools ├── ADattributes.json └── scrapeAttributesFromMS.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: cimg/go:1.14 6 | steps: 7 | - checkout 8 | - run: 9 | name: Build windapsearch 10 | command: go build github.com/ropnop/go-windapsearch/cmd/... 11 | - run: 12 | name: Install build deps 13 | command: | 14 | go get github.com/mitchellh/gox 15 | go get github.com/magefile/mage 16 | - run: 17 | name: Cross build versions 18 | command: VERSION=${CIRCLE_TAG} mage dist 19 | - store_artifacts: 20 | path: ./dist 21 | - persist_to_workspace: 22 | root: dist 23 | paths: 24 | - ./* 25 | 26 | github-release: 27 | docker: 28 | - image: cibuilds/github:0.13 29 | steps: 30 | - attach_workspace: 31 | at: ./artifacts 32 | - run: 33 | name: "Publish Binaries on Github" 34 | command: | 35 | ghr -t ${GITHUB_TOKEN} -u ${CIRCLE_PROJECT_USERNAME} -r ${CIRCLE_PROJECT_REPONAME} -c ${CIRCLE_SHA1} -delete ${CIRCLE_TAG} ./artifacts/ 36 | 37 | workflows: 38 | version: 2 39 | main: 40 | jobs: 41 | - build: 42 | filters: 43 | tags: 44 | only: /.*/ 45 | - github-release: 46 | filters: 47 | branches: 48 | ignore: /.*/ 49 | tags: 50 | only: /^v\d+\.\d+.*$/ 51 | context: "Github Token" 52 | requires: 53 | - build 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | bin/ 3 | dist/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Ronnie Flathers 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-windapsearch 2 | [![CircleCI](https://circleci.com/gh/ropnop/go-windapsearch.svg?style=svg)](https://circleci.com/gh/ropnop/go-windapsearch) 3 | 4 | `windapsearch` is a tool to assist in Active Directory Domain enumeration through LDAP queries. It contains several modules to enumerate users, groups, computers, as well as perform searching and unauthenticated information gathering. 5 | 6 | For usage examples of each of the modules, view the [modules README](pkg/modules/README.md) 7 | 8 | In addition to performing common LDAP searches, `windapsearch` now also has the option to convert LDAP results to JSON format for easy parsing. When performing JSON encoding, `windapsearch` will automatically convert certain LDAP attributes to a more human friendly format as well (e.g. timestamps, GUIDs, enumerations, etc) 9 | 10 | This is a complete re-write of my earlier [Python implementation](https://github.com/ropnop/windapsearch). For some more background/explanation on how I'm using Go, and more advanced usage examples see [this blog post](TODO). 11 | 12 | ## Installation 13 | It is recommended to download pre-compiled binaries for amd64 Linux/Mac/Windows from the [latest releases](https://github.com/ropnop/go-windapsearch/releases) 14 | 15 | ### Install from Source 16 | To build from source, I use [mage](https://github.com/magefile/mage), a Make like tool written in Go. Install `mage` then run the mage targets: 17 | 18 | ``` 19 | $ git clone https://github.com/ropnop/go-windapsearch.git && cd go-windapsearch 20 | $ go get github.com/magefile/mage 21 | $ mage 22 | Targets: 23 | build Compile windapsearch for current OS and ARCH 24 | clean Delete bin and dist dirs 25 | dist Cross-compile for Windows, Linux, Mac x64 and put in ./dist 26 | $ mage build 27 | $ ./windapsearch --version 28 | ``` 29 | 30 | # Usage 31 | `windapsearch` is a standalone binary with multiple modules for various common LDAP queries 32 | 33 | ``` 34 | $ ./windapsearch -h 35 | windapsearch: a tool to perform Windows domain enumeration through LDAP queries 36 | Version: dev (9f91330) | Built: 03/04/21 (go1.16) | Ronnie Flathers @ropnop 37 | 38 | Usage: ./windapsearch [options] -m [module] [module options] 39 | 40 | Options: 41 | -d, --domain string The FQDN of the domain (e.g. 'lab.example.com'). Only needed if dc not provided 42 | --dc string The Domain Controller to query against 43 | -u, --username string The full username with domain to bind with (e.g. 'ropnop@lab.example.com' or 'LAB\ropnop') 44 | If not specified, will attempt anonymous bind 45 | --bindDN string Full DN to use to bind (as opposed to -u for just username) 46 | e.g. cn=rflathers,ou=users,dc=example,dc=com 47 | -p, --password string Password to use. If not specified, will be prompted for 48 | --hash string NTLM Hash to use instead of password (i.e. pass-the-hash) 49 | --ntlm Use NTLM auth (automatic if hash is set) 50 | --port int Port to connect to (if non standard) 51 | --secure Use LDAPS. This will not verify TLS certs, however. (default: false) 52 | --proxy string SOCKS5 Proxy to use (e.g. 127.0.0.1:9050) 53 | --full Output all attributes from LDAP 54 | --ignore-display-filters Ignore any display filters set by the module and always output every entry 55 | -o, --output string Save results to file 56 | -j, --json Convert LDAP output to JSON 57 | --page-size int LDAP page size to use (default 1000) 58 | --version Show version info and exit 59 | -v, --verbose Show info logs 60 | --debug Show debug logs 61 | -h, --help Show this help 62 | -m, --module string Module to use 63 | 64 | Available modules: 65 | admin-objects Enumerate all objects with protected ACLs (i.e admins) 66 | computers Enumerate AD Computers 67 | custom Run a custom LDAP syntax filter 68 | dns-names List all DNS Names 69 | dns-zones List all DNS Zones 70 | domain-admins Recursively list all users objects in Domain Admins group 71 | gpos Enumerate Group Policy Objects 72 | groups List all AD groups 73 | members Query for members of a group 74 | metadata Print LDAP server metadata 75 | privileged-users Recursively list members of all highly privileged groups 76 | search Perform an ANR Search and return the results 77 | unconstrained Find objects that allow unconstrained delegation 78 | user-spns Enumerate all users objects with Service Principal Names (for kerberoasting) 79 | users List all user objects 80 | ``` 81 | 82 | ## Selecting a Module 83 | Select a module to use with the `-m` option. Some modules have additional options which can be seen by specifying a module when running `-h`: 84 | 85 | ``` 86 | $ ./windapsearch -m users -h 87 | <...> 88 | Options for "users" module: 89 | --attrs strings Comma separated custom atrributes to display (default [cn,sAMAccountName]) 90 | --filter string Extra LDAP syntax filter to use 91 | -s, --search string Search term to filter on 92 | ``` 93 | 94 | Each module defines a default set of attributes to return. These can always be overriden by the comma separated `--attrs` option, or by specifying `--full`, which will always return every attribute. 95 | 96 | 97 | ## Output Formats 98 | With no other options specified, `windapsearch` will display output to the terminal in the same text based format used by `ldapsearch`. Output can also be written to a file by specifying the `-o` option. 99 | 100 | When specifying the `-j` option, the tool will convert LDAP responses to JSON format and outut a JSON array of LDAP entries. During the marshalling, `windapsearch` will also convert binary values to human-readable formats, and perform some enumeration substitution with string values. 101 | 102 | For example, when looking a single user, these are the normal "text" attributes: 103 | ``` 104 | whenCreated: 20170806185838.0Z 105 | objectSid: AQUAAAAAAAUVAAAAoWuXYvBp2/Bf49rCUgQAAA== 106 | lastLogonTimestamp: 132340658159483754 107 | userAccountControl: 66048 108 | ``` 109 | 110 | But in JSON format, they are converted: 111 | ```json 112 | "whenCreated": "2017-08-06T18:58:38Z", 113 | "objectSid": "S-1-5-21-1654090657-4040911344-3269124959-1106", 114 | "lastLogonTimestamp": "2020-05-15T20:23:35.9483754-05:00", 115 | "userAccountControl": [ 116 | "DONT_EXPIRE_PASSWORD", 117 | "NORMAL_ACCOUNT" 118 | ], 119 | ``` 120 | 121 | *Note: I have not implemented full mapping/pretty printing of every LDAP attribute. If you see one that should be converted to something else and isn't, please open an Issue - or better yet a PR ;)* 122 | 123 | ## Logging 124 | To see more information, including the full LDAP queries that are being sent, use the `--verbose` option, which will display helpful information. 125 | 126 | If you are experiencing issues, please use the `--debug` option for much more detailed log information, including every entry being parsed 127 | 128 | # Credits 129 | - The authors of [go-ldap](https://github.com/go-ldap/ldap) for the LDAP client that powers all of this 130 | - [Michael Eder](https://twitter.com/michael_eder_) for his PR and contributions to the `dns-names` and `dns-zones` modules! 131 | - [audibleblink](https://twitter.com/4lex) for the [idea](https://twitter.com/4lex/status/1254037754842931200?s=20) and the [package](github.com/audibleblink/msldapuac) to parse UserAccountControl from LDAP 132 | - [dirkjanm](https://twitter.com/_dirkjan) for [adidnsdump](https://github.com/dirkjanm/adidnsdump) 133 | 134 | -------------------------------------------------------------------------------- /cmd/windapsearch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/windapsearch" 5 | ) 6 | 7 | func main() { 8 | w := windapsearch.NewSession() 9 | err := w.Run() 10 | if err != nil { 11 | w.Log.Fatalf(err.Error()) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ropnop/go-windapsearch 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/audibleblink/msldapuac v0.2.0 7 | github.com/bwmarrin/go-objectsid v0.0.0-20191126144531-5fee401a2f37 8 | github.com/go-ldap/ldap/v3 v3.2.1 9 | github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 10 | github.com/magefile/mage v1.9.0 11 | github.com/sirupsen/logrus v1.6.0 12 | github.com/spf13/pflag v1.0.5 13 | github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8 14 | golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 15 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 16 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28= 2 | github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= 3 | github.com/audibleblink/bamflags v0.2.0 h1:xLsR8OO2mmbhpQglTvSKNJMnmQMI9L884eJ15zbu4mI= 4 | github.com/audibleblink/bamflags v0.2.0/go.mod h1:zpuLMpykftgB88SHYGBa1urg0uHn01R2pjeBed+JhB8= 5 | github.com/audibleblink/msldapuac v0.2.0 h1:1KFPLKWNmPGiCbd7HD/PPb5UuNOTvKRz1XcGeP2TyuI= 6 | github.com/audibleblink/msldapuac v0.2.0/go.mod h1:dYy4kKJVkwsmvb+8AVyxf84YhW0FAD+RB+wxy3M/fXA= 7 | github.com/bwmarrin/go-objectsid v0.0.0-20191126144531-5fee401a2f37 h1:MuLKITJFJ3Q4zql+AMdvOWgL1c4sB9SIF3tIrbQ8Jw0= 8 | github.com/bwmarrin/go-objectsid v0.0.0-20191126144531-5fee401a2f37/go.mod h1:bh3gp4JMNaDCqhJfKGjttehBWFtgfQ/FUevRm5fr88E= 9 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/go-asn1-ber/asn1-ber v1.5.0 h1:/S4hO/AO6tLMlPX0oftGSOcdGJJN/MuYzfgWRMn199E= 14 | github.com/go-asn1-ber/asn1-ber v1.5.0/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= 15 | github.com/go-ldap/ldap/v3 v3.2.1 h1:mbP3BPfsULz5DuI3ejHuAypAbcg38Xv5T7eEHp3+XAE= 16 | github.com/go-ldap/ldap/v3 v3.2.1/go.mod h1:phWI+JSJ/eGvABjJxU7bT7CBv03KfS0e16+bQxLtjMw= 17 | github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= 18 | github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 19 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 20 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 21 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 22 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 23 | github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 h1:EnfXoSqDfSNJv0VBNqY/88RNnhSGYkrHaO0mmFGbVsc= 24 | github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40/go.mod h1:vy1vK6wD6j7xX6O6hXe621WabdtNkou2h7uRtTfRMyg= 25 | github.com/magefile/mage v1.9.0 h1:t3AU2wNwehMCW97vuqQLtw6puppWXHO+O2MHo5a50XE= 26 | github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 27 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 28 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= 32 | github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 33 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 34 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 35 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 36 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 37 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 38 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 39 | github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8 h1:RB0v+/pc8oMzPsN97aZYEwNuJ6ouRJ2uhjxemJ9zvrY= 40 | github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8/go.mod h1:IlWNj9v/13q7xFbaK4mbyzMNwrZLaWSHx/aibKIZuIg= 41 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 42 | golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= 43 | golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 44 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= 45 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 46 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 47 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 48 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 49 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 50 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= 51 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 52 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 55 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 56 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 57 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 58 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 59 | -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | // +build mage 2 | 3 | // magefile inspired/copied from Hugo's: https://github.com/gohugoio/hugo/blob/master/magefile.go 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "path" 12 | "time" 13 | 14 | "github.com/magefile/mage/mg" 15 | "github.com/magefile/mage/sh" 16 | ) 17 | 18 | const ( 19 | packageName = "github.com/ropnop/go-windapsearch" 20 | ) 21 | 22 | var ( 23 | curDir string 24 | binDir string 25 | distDir string 26 | hash string 27 | buildDate string 28 | buildNum string 29 | version string 30 | ) 31 | 32 | var ldflags = `-w -s` + 33 | ` -X $PKG/pkg/buildinfo.GitSHA=$GIT_SHA` + 34 | ` -X $PKG/pkg/buildinfo.BuildDate=$DATE` + 35 | ` -X $PKG/pkg/buildinfo.Version=$VERSION` + 36 | ` -X $PKG/pkg/buildinfo.BuildNumber=$BUILDNUM` 37 | 38 | var targets = "linux/amd64 darwin/amd64 windows/amd64" 39 | 40 | var goexe = "go" 41 | 42 | func init() { 43 | if exe := os.Getenv("GOEXE"); exe != "" { 44 | goexe = exe 45 | } 46 | 47 | // We want to use Go 1.11 modules even if the source lives inside GOPATH. 48 | // The default is "auto". 49 | os.Setenv("GO111MODULE", "on") 50 | 51 | curDir, err := os.Getwd() 52 | if err != nil { 53 | curDir = "." //hack 54 | } 55 | binDir = curDir 56 | distDir = path.Join(curDir, "dist") 57 | } 58 | 59 | // Build Compile windapsearch for current OS and ARCH 60 | func Build() error { 61 | err := sh.RunWith(flagEnv(), goexe, "install", "-ldflags", ldflags, "$PKG/cmd/...") 62 | if err != nil { 63 | return err 64 | } 65 | fmt.Printf("[+] Compiled binary to %s/\n", binDir) 66 | return nil 67 | } 68 | 69 | var gox = sh.RunCmd("gox") 70 | 71 | // Dist Cross-compile for Windows, Linux, Mac x64 and put in ./dist 72 | func Dist() error { 73 | ldflags += " -extldflags \"-static\"" 74 | mg.Deps(checkGox) 75 | fmt.Printf("[+] Cross compiling for: %q\n", targets) 76 | err := sh.RunWith( 77 | flagEnv(), 78 | "gox", 79 | "-parallel=3", 80 | "-output", 81 | "$DISTDIR/{{.Dir}}-{{.OS}}-{{.Arch}}", 82 | "--osarch=$TARGETS", 83 | "-ldflags", 84 | ldflags, 85 | "$PKG/cmd/...") 86 | if err != nil { 87 | return err 88 | } 89 | fmt.Printf("[+] Cross compiled binaries in: %s\n", distDir) 90 | return nil 91 | 92 | } 93 | 94 | func checkGox() error { 95 | _, err := exec.LookPath("gox") 96 | if err != nil { 97 | return sh.Run(goexe, "get", "-u", "github.com/mitchellh/gox") 98 | } 99 | return nil 100 | } 101 | 102 | // Clean Delete bin and dist dirs 103 | func Clean() { 104 | fmt.Println("[+] Removing bin and dist...") 105 | os.RemoveAll(binDir) 106 | os.RemoveAll(distDir) 107 | } 108 | 109 | // set up environment variables 110 | func flagEnv() map[string]string { 111 | hash, _ := sh.Output("git", "rev-parse", "--short", "HEAD") 112 | if version = os.Getenv("VERSION"); version == "" { 113 | version = "dev" 114 | } 115 | if buildNum = os.Getenv("BUILDNUM"); buildNum == "" { 116 | buildNum = "local" 117 | } 118 | 119 | return map[string]string{ 120 | "PKG": packageName, 121 | "GOBIN": binDir, 122 | "GIT_SHA": hash, 123 | "DATE": time.Now().Format("01/02/06"), 124 | "VERSION": version, 125 | "BUILDNUM": buildNum, 126 | "DISTDIR": distDir, 127 | "CGO_ENABLED": "1", //bug: when this is disabled, DNS gets wonky 128 | "TARGETS": targets, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pkg/adschema/attributes.go: -------------------------------------------------------------------------------- 1 | package adschema 2 | 3 | //go:generate go run gen.go 4 | //go:generate go fmt 5 | 6 | //var BooleanSyntax = AttributeSyntax{ 7 | // Name: "Boolean", 8 | // ConvertBytes: ConvertBool, 9 | //} 10 | 11 | //func (a *AttributeSyntax) UnmarshalJSON(data []byte) error { 12 | // var s string 13 | // if err := json.Unmarshal(data, &s); err != nil { 14 | // return err 15 | // } 16 | // 17 | // switch s { 18 | // case "Boolean": 19 | // a.Name = "Boolean" 20 | // a.ConvertBytes = ConvertBool 21 | // case "Interval": 22 | // a.Name = "Interval" 23 | // a.ConvertBytes = func(b []byte) interface{} { return nil } 24 | // default: 25 | // a.Name = "Unknown" 26 | // a.ConvertBytes = func(b []byte) interface{} { return nil } 27 | // } 28 | // return nil 29 | //} 30 | 31 | //var EnumerationSyntax = AttributeSyntax{ 32 | // Name: "Enumeration", 33 | // Convert: ParseEnumeration, 34 | //} 35 | // 36 | //var IntervalSyntax = AttributeSyntax{ 37 | // Name: "Interval", 38 | // Convert: ParseInterval, 39 | //} 40 | // 41 | //var ObjectAccessPoint = AttributeSyntax{ 42 | // Name: "Object(Access-Point)", 43 | // Convert: ParseObject, 44 | //} 45 | -------------------------------------------------------------------------------- /pkg/adschema/entry.go: -------------------------------------------------------------------------------- 1 | package adschema 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "github.com/go-ldap/ldap/v3" 7 | "strconv" 8 | "strings" 9 | "unicode/utf8" 10 | ) 11 | 12 | type ADEntry struct { 13 | *ldap.Entry 14 | } 15 | 16 | func (e *ADEntry) String() string { 17 | return e.DN 18 | } 19 | 20 | func (e *ADEntry) LDAPFormat() string { 21 | var sb strings.Builder 22 | if e.DN != "" { 23 | sb.WriteString(fmt.Sprintf("dn: %s\n", e.DN)) 24 | } 25 | for _, attribute := range e.Attributes { 26 | for _, value := range attribute.ByteValues { 27 | //valueString := HandleLDAPBytes(attribute.Name, value) 28 | sb.WriteString(fmt.Sprintf("%s: %v\n", attribute.Name, printable(value))) 29 | } 30 | } 31 | return sb.String() 32 | } 33 | 34 | // HandleLDAPBytes takes a byte slice from a raw attribute value and returns either a UTF8 string (if it's a string), 35 | // or GUID or timestamp 36 | func HandleLDAPBytes(name string, b []byte) interface{} { 37 | if name == "objectGUID" { 38 | g, err := WindowsGuidFromBytes(b) 39 | if err != nil { 40 | return b 41 | } 42 | return g 43 | } 44 | if name == "objectSid" { 45 | s, err := WindowsSIDFromBytes(b) 46 | if err != nil { 47 | return b 48 | } 49 | return s 50 | } 51 | 52 | if name == "domainFunctionality" { 53 | return FunctionalityLevelsMapping[string(b)] 54 | } 55 | if name == "forestFunctionality" { 56 | return FunctionalityLevelsMapping[string(b)] 57 | } 58 | if name == "domainControllerFunctionality" { 59 | return FunctionalityLevelsMapping[string(b)] 60 | } 61 | 62 | if utf8.Valid(b) { 63 | s := string(b) 64 | if s == "9223372036854775807" { //max int64 size 65 | return 0 //basically a no-value (e.g. never expires) 66 | } 67 | if NTFileTimeRegex.Match(b) { 68 | timeStamp, err := NTFileTimeToTimestamp(s) 69 | if err != nil { 70 | return s 71 | } 72 | return timeStamp 73 | } 74 | if ADLdapTimeRegex.Match(b) { 75 | timeStamp, err := ADLdapTimeToTimestamp(s) 76 | if err != nil { 77 | return s 78 | } 79 | return timeStamp 80 | } 81 | if i, err := strconv.Atoi(s); err == nil { 82 | return i 83 | } 84 | return s 85 | } 86 | return base64.StdEncoding.EncodeToString(b) 87 | } 88 | -------------------------------------------------------------------------------- /pkg/adschema/enums/enumerations.go: -------------------------------------------------------------------------------- 1 | package enums 2 | 3 | import ( 4 | uac "github.com/audibleblink/msldapuac" 5 | ) 6 | 7 | type ConvertEnum func(int64) interface{} 8 | 9 | var EnumFuncs = map[string]ConvertEnum{ 10 | "sAMAccountType": func(i int64) interface{} { 11 | val, ok := SamAccountTypeEnum[i] 12 | if !ok { 13 | return i 14 | } 15 | return val 16 | }, 17 | "userAccountControl": ConvertUAC, 18 | } 19 | 20 | // SAM-Account-Type 21 | // https://docs.microsoft.com/en-us/windows/win32/adschema/a-samaccounttype 22 | var SamAccountTypeEnum = map[int64]string{ 23 | 0x0: "SAM_DOMAIN_OBJECT", 24 | 0x10000000: "SAM_GROUP_OBJECT", 25 | 0x10000001: "SAM_NON_SECURITY_GROUP_OBJECT", 26 | 0x20000000: "SAM_ALIAS_OBJECT", 27 | 0x20000001: "SAM_NON_SECURITY_ALIAS_OBJECT", 28 | 0x30000000: "SAM_USER_OBJECT", 29 | 0x30000001: "SAM_MACHINE_ACCOUNT", 30 | 0x30000002: "SAM_TRUST_ACCOUNT", 31 | 0x40000000: "SAM_APP_BASIC_GROUP", 32 | 0x40000001: "SAM_APP_QUERY_GROUP", 33 | 0x7fffffff: "SAM_ACCOUNT_TYPE_MAX", 34 | } 35 | 36 | func ConvertUAC(i int64) interface{} { 37 | flags, err := uac.ParseUAC(i) 38 | if err != nil { 39 | return i 40 | } 41 | return flags 42 | } 43 | -------------------------------------------------------------------------------- /pkg/adschema/gen.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | // This code consumes the JSON created from scraping the MS documentation and generates the map of Attribute names 4 | // to Syntax 5 | // to update the JSON, run scrapeAttributesFromMS.py in the tools directory 6 | package main 7 | 8 | import ( 9 | "encoding/json" 10 | "io/ioutil" 11 | "log" 12 | "os" 13 | "text/template" 14 | "time" 15 | ) 16 | 17 | type ADAttributeJSON struct { 18 | CN string `json:"CN"` 19 | LdapDisplayName string `json:"Ldap-Display-Name"` 20 | AttributeId string `json:"Attribute-Id"` 21 | SystemIDGuid string `json:"System-Id-Guid"` 22 | Syntax string `json:"Syntax"` 23 | IsSingleValue bool `json:"Is-Single-Valued"` 24 | } 25 | 26 | type data struct { 27 | ADAttributes []ADAttributeJSON 28 | Syntaxes []string 29 | Timestamp string 30 | } 31 | 32 | func must(err error) { 33 | if err != nil { 34 | log.Fatal(err) 35 | } 36 | } 37 | 38 | func main() { 39 | var d data 40 | d.Timestamp = time.Now().Format(time.RFC3339) 41 | 42 | file, err := ioutil.ReadFile("../../tools/ADAttributes.json") 43 | must(err) 44 | 45 | err = json.Unmarshal([]byte(file), &d.ADAttributes) 46 | must(err) 47 | 48 | uniqueSyntaxes := make(map[string]bool) 49 | var multiValueAttributes []string 50 | 51 | for _, attr := range d.ADAttributes { 52 | if _, ok := uniqueSyntaxes[attr.Syntax]; !ok { 53 | uniqueSyntaxes[attr.Syntax] = true 54 | d.Syntaxes = append(d.Syntaxes, attr.Syntax) 55 | } 56 | if !attr.IsSingleValue { 57 | multiValueAttributes = append(multiValueAttributes, attr.LdapDisplayName) 58 | } 59 | } 60 | 61 | tmpl, err := template.New("attributeSyntax").Parse(attributeSyntaxTemplate) 62 | must(err) 63 | 64 | f, err := os.Create("attr_syntaxes.go") 65 | must(err) 66 | defer f.Close() 67 | 68 | tmpl.Execute(f, d) 69 | 70 | } 71 | 72 | var attributeSyntaxTemplate = ` 73 | // This file was automatically generated at 74 | // {{ .Timestamp }} 75 | // from AD Schema documentation here https://docs.microsoft.com/en-us/windows/win32/adschema/ 76 | // length of unique attributes: {{len .ADAttributes}} 77 | // length of unique syntaxes: {{len .Syntaxes}} 78 | package adschema 79 | 80 | type ADAttributeInfo struct { 81 | Syntax string 82 | IsSingleValue bool 83 | } 84 | 85 | var AttributeMap = map[string]*ADAttributeInfo{ 86 | {{range $attr := .ADAttributes}} 87 | "{{$attr.LdapDisplayName}}": &ADAttributeInfo{Syntax: "{{$attr.Syntax}}", IsSingleValue: {{$attr.IsSingleValue}}},{{end}} 88 | } 89 | ` 90 | -------------------------------------------------------------------------------- /pkg/adschema/json_marshal.go: -------------------------------------------------------------------------------- 1 | package adschema 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "github.com/go-ldap/ldap/v3" 7 | "unicode/utf8" 8 | ) 9 | 10 | type LDAPAttribute ldap.EntryAttribute 11 | type LDAPEntryJSON map[string]interface{} 12 | 13 | type ADAttribute struct { 14 | *ldap.EntryAttribute 15 | } 16 | 17 | //func (e *ADEntry) MarshalJSON() ([]byte, error) { 18 | // jEntry := make(LDAPEntryJSON) 19 | // for _, attribute := range e.Attributes { 20 | // if len(attribute.Values) == 1 { 21 | // jEntry[attribute.Name] = HandleLDAPBytes(attribute.Name, attribute.ByteValues[0]) 22 | // } else { 23 | // var vals []interface{} 24 | // for _, val := range attribute.ByteValues { 25 | // vals = append(vals, HandleLDAPBytes(attribute.Name, val)) 26 | // } 27 | // jEntry[attribute.Name] = vals 28 | // } 29 | // } 30 | // return json.Marshal(jEntry) 31 | //} 32 | 33 | func (e *ADEntry) MarshalJSON() ([]byte, error) { 34 | //jEntry := make(map[string]*ADAttribute) 35 | jEntry := make(map[string]interface{}) 36 | if e.DN != "" { 37 | jEntry["dn"] = e.DN 38 | } 39 | for _, attribute := range e.Attributes { 40 | jEntry[attribute.Name] = &ADAttribute{attribute} 41 | } 42 | return json.Marshal(jEntry) 43 | } 44 | 45 | func (e *ADAttribute) MarshalJSON() ([]byte, error) { 46 | // Look up syntax for attribute name 47 | info, ok := AttributeMap[e.Name] 48 | if !ok { 49 | // check if its a root DSE attribute 50 | _, ok := RootDSEAttributeMap[e.Name] 51 | if ok { 52 | return marshalRootDSEAttribute(e) 53 | } else { 54 | return marshalUnknownAttribute(e) 55 | } 56 | } 57 | convert, ok := SyntaxFunctions[info.Syntax] 58 | if !ok { 59 | convert = DefaultPrint 60 | } 61 | var vals []interface{} 62 | for _, v := range e.ByteValues { 63 | i, err := convert(e.Name, v) 64 | if err != nil { 65 | return nil, err 66 | } 67 | vals = append(vals, i) 68 | } 69 | if info.IsSingleValue && len(vals) == 1 { 70 | return json.Marshal(vals[0]) 71 | } 72 | return json.Marshal(vals) 73 | 74 | } 75 | 76 | func marshalUnknownAttribute(e *ADAttribute) ([]byte, error) { 77 | var vals []string 78 | for _, val := range e.ByteValues { 79 | vals = append(vals, printable(val)) 80 | } 81 | info, ok := AttributeMap[e.Name] 82 | if ok { 83 | if info.IsSingleValue && len(vals) == 1 { 84 | return json.Marshal(vals[0]) 85 | } 86 | } 87 | 88 | return json.Marshal(vals) 89 | } 90 | 91 | func printable(b []byte) string { 92 | if utf8.Valid(b) { 93 | return string(b) 94 | } 95 | return base64.StdEncoding.EncodeToString(b) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/adschema/objectreplicalinks/dnsrecord/convert.go: -------------------------------------------------------------------------------- 1 | package dnsrecord 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/binary" 7 | "fmt" 8 | "github.com/lunixbochs/struc" 9 | "log" 10 | ) 11 | 12 | type DnsRecordInfo struct { 13 | Type string `json:"type"` 14 | Data string `json:"data"` 15 | } 16 | 17 | func ConvertDnsRecord(b []byte) interface{} { 18 | var record DnsRecord 19 | buf := bytes.NewReader(b) 20 | 21 | err := struc.UnpackWithOptions(buf, &record, &struc.Options{Order: binary.LittleEndian}) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | data, err := DNSRecordDataToString(record.Type, record.Data) 26 | if err != nil { 27 | data = base64.StdEncoding.EncodeToString(record.Data) 28 | } 29 | return &DnsRecordInfo{ 30 | Type: DnsTypes[record.Type], 31 | Data: data, 32 | } 33 | } 34 | 35 | func DNSRecordDataToString(dnsType uint16, data []byte) (string, error) { 36 | buf := bytes.NewReader(data) 37 | switch dnsType { 38 | case DNS_TYPE_A: 39 | var rpcRecord DNS_RPC_RECORD_A 40 | if err := struc.Unpack(buf, &rpcRecord); err != nil { 41 | return "", err 42 | } 43 | return rpcRecord.Ipv4Address.String(), nil 44 | case DNS_TYPE_AAAA: 45 | var rpcRecord DNS_RPC_RECORD_AAAA 46 | if err := struc.Unpack(buf, &rpcRecord); err != nil { 47 | log.Fatal(err) 48 | return "", err 49 | } 50 | return rpcRecord.Ipv6Address.String(), nil 51 | case DNS_TYPE_PTR, DNS_TYPE_NS, DNS_TYPE_CNAME, DNS_TYPE_DNAME, DNS_TYPE_MB, DNS_TYPE_MR, DNS_TYPE_MG, DNS_TYPE_MD, DNS_TYPE_MF: 52 | var nameRecord DNS_COUNT_NAME 53 | if err := struc.Unpack(buf, &nameRecord); err != nil { 54 | return "", err 55 | } 56 | return nameRecord.String(), nil 57 | 58 | case DNS_TYPE_SRV: 59 | var srvRecord DNS_RPC_RECORD_SRV 60 | if err := struc.Unpack(buf, &srvRecord); err != nil { 61 | return "", err 62 | } 63 | return srvRecord.String(), nil 64 | case DNS_TYPE_HINFO, DNS_TYPE_ISDN, DNS_TYPE_TXT, DNS_TYPE_X25, DNS_TYPE_LOC: 65 | var nameRecord DNS_RPC_NAME 66 | if err := struc.Unpack(buf, &nameRecord); err != nil { 67 | return "", err 68 | } 69 | return nameRecord.DnsName, nil 70 | case DNS_TYPE_SOA: 71 | var soaRecord DNS_RPC_RECORD_SOA 72 | if err := struc.Unpack(buf, &soaRecord); err != nil { 73 | return "", err 74 | } 75 | return soaRecord.String(), nil 76 | 77 | default: 78 | return "", fmt.Errorf("unimplemented type: %s", DnsTypes[dnsType]) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pkg/adschema/objectreplicalinks/dnsrecord/dnsrecord.go: -------------------------------------------------------------------------------- 1 | package dnsrecord 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | ) 8 | 9 | // See https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-dnsp/6912b338-5472-4f59-b912-0edb536b6ed8 10 | // for structure of dnsRecord 11 | // thanks to @dirkjanm for https://github.com/dirkjanm/adidnsdump/blob/master/adidnsdump/dnsdump.py for inspiration 12 | 13 | type DnsRecord struct { 14 | DataLength uint16 `struc:"uint16,sizeof=Data"` 15 | Type uint16 `struc:"uint16"` 16 | Version uint8 `struc:"uint8"` 17 | Rank uint8 `struc:"uint8"` 18 | Flags uint16 `struc:"uint16"` 19 | Serial uint32 `struc:"uint32"` 20 | TtlSeconds uint32 `struc:"uint32"` 21 | Reserved []byte `struc:"pad,[4]byte"` 22 | TimeStamp uint32 `struc:"uint32"` 23 | Data []byte `struc:"[]byte"` 24 | } 25 | 26 | const ( 27 | DNS_TYPE_ZERO uint16 = 0x0 28 | DNS_TYPE_A uint16 = 0x1 29 | DNS_TYPE_NS uint16 = 0x2 30 | DNS_TYPE_MD uint16 = 0x3 31 | DNS_TYPE_MF uint16 = 0x4 32 | DNS_TYPE_CNAME uint16 = 0x5 33 | DNS_TYPE_SOA uint16 = 0x6 34 | DNS_TYPE_MB uint16 = 0x7 35 | DNS_TYPE_MG uint16 = 0x8 36 | DNS_TYPE_MR uint16 = 0x9 37 | DNS_TYPE_NULL uint16 = 0xA 38 | DNS_TYPE_WKS uint16 = 0xB 39 | DNS_TYPE_PTR uint16 = 0xC 40 | DNS_TYPE_HINFO uint16 = 0xD 41 | DNS_TYPE_MINFO uint16 = 0xE 42 | DNS_TYPE_MX uint16 = 0xF 43 | DNS_TYPE_TXT uint16 = 0x10 44 | DNS_TYPE_RP uint16 = 0x11 45 | DNS_TYPE_AFSDB uint16 = 0x12 46 | DNS_TYPE_X25 uint16 = 0x13 47 | DNS_TYPE_ISDN uint16 = 0x14 48 | DNS_TYPE_RT uint16 = 0x15 49 | DNS_TYPE_SIG uint16 = 0x18 50 | DNS_TYPE_KEY uint16 = 0x19 51 | DNS_TYPE_AAAA uint16 = 0x1C 52 | DNS_TYPE_LOC uint16 = 0x1D 53 | DNS_TYPE_NXT uint16 = 0x1E 54 | DNS_TYPE_SRV uint16 = 0x21 55 | DNS_TYPE_ATMA uint16 = 0x22 56 | DNS_TYPE_NAPTR uint16 = 0x23 57 | DNS_TYPE_DNAME uint16 = 0x27 58 | DNS_TYPE_DS uint16 = 0x2B 59 | DNS_TYPE_RRSIG uint16 = 0x2E 60 | DNS_TYPE_NSEC uint16 = 0x2F 61 | DNS_TYPE_DNSKEY uint16 = 0x30 62 | DNS_TYPE_DHCID uint16 = 0x31 63 | DNS_TYPE_NSEC3 uint16 = 0x32 64 | DNS_TYPE_NSEC3PARAM uint16 = 0x33 65 | DNS_TYPE_TLSA uint16 = 0x34 66 | DNS_TYPE_ALL uint16 = 0xFF 67 | DNS_TYPE_WINS uint16 = 0xFF01 68 | DNS_TYPE_WINSR uint16 = 0xFF02 69 | ) 70 | 71 | var DnsTypes = map[uint16]string{ 72 | DNS_TYPE_ZERO: "DNS_TYPE_ZERO", 73 | DNS_TYPE_A: "A", 74 | DNS_TYPE_NS: "NS", 75 | DNS_TYPE_MD: "MD", 76 | DNS_TYPE_MF: "MF", 77 | DNS_TYPE_CNAME: "CNAME", 78 | DNS_TYPE_SOA: "SOA", 79 | DNS_TYPE_MB: "MB", 80 | DNS_TYPE_MG: "MG", 81 | DNS_TYPE_MR: "MR", 82 | DNS_TYPE_NULL: "NULL", 83 | DNS_TYPE_WKS: "WKS", 84 | DNS_TYPE_PTR: "PTR", 85 | DNS_TYPE_HINFO: "HINFO", 86 | DNS_TYPE_MINFO: "MINFO", 87 | DNS_TYPE_MX: "MX", 88 | DNS_TYPE_TXT: "TXT", 89 | DNS_TYPE_RP: "RP", 90 | DNS_TYPE_AFSDB: "AFSDB", 91 | DNS_TYPE_X25: "X25", 92 | DNS_TYPE_ISDN: "ISDN", 93 | DNS_TYPE_RT: "RT", 94 | DNS_TYPE_SIG: "SIG", 95 | DNS_TYPE_KEY: "KEY", 96 | DNS_TYPE_AAAA: "AAAA", 97 | DNS_TYPE_LOC: "LOC", 98 | DNS_TYPE_NXT: "NXT", 99 | DNS_TYPE_SRV: "SRV", 100 | DNS_TYPE_ATMA: "ATMA", 101 | DNS_TYPE_NAPTR: "NAPTR", 102 | DNS_TYPE_DNAME: "DNAME", 103 | DNS_TYPE_DS: "DS", 104 | DNS_TYPE_RRSIG: "RRSIG", 105 | DNS_TYPE_NSEC: "NSEC", 106 | DNS_TYPE_DNSKEY: "DNSKEY", 107 | DNS_TYPE_DHCID: "DHCID", 108 | DNS_TYPE_NSEC3: "NSEC3", 109 | DNS_TYPE_NSEC3PARAM: "NSEC3PARAM", 110 | DNS_TYPE_TLSA: "TLSA", 111 | DNS_TYPE_ALL: "ALL", 112 | DNS_TYPE_WINS: "WINS", 113 | DNS_TYPE_WINSR: "WINSR", 114 | } 115 | 116 | type DNS_RPC_RECORD_A struct { 117 | Ipv4Address net.IP `struc:"[4]byte,little"` 118 | } 119 | 120 | type DNS_RPC_RECORD_AAAA struct { 121 | Ipv6Address net.IP `struc:"[16]byte,little"` 122 | } 123 | 124 | type DNS_RPC_RECORD_NODE_NAME struct { 125 | NameNode DNS_RPC_NAME 126 | } 127 | 128 | type DNS_RPC_NAME struct { 129 | NameLength uint8 `struc:"uint8,sizeof=DnsName"` 130 | DnsName string `struc:"[]byte"` 131 | } 132 | 133 | type DNS_COUNT_NAME struct { 134 | Length uint8 `struc:"uint8,little,sizeof=RawName"` 135 | LabelCount uint8 `struc:"uint8,little"` 136 | RawName []byte 137 | } 138 | 139 | func (d DNS_COUNT_NAME) String() string { 140 | // <3 you dirkjanm https://github.com/dirkjanm/adidnsdump/blob/master/adidnsdump/dnsdump.py#L107 141 | var ind uint8 = 0 142 | var labels []string 143 | for i := uint8(0); i <= d.LabelCount; i++ { 144 | nextlen := uint8(d.RawName[ind : ind+1][0]) 145 | labels = append(labels, string(d.RawName[ind+1:ind+1+nextlen])) 146 | ind += nextlen + 1 147 | } 148 | return strings.Join(labels, ".") 149 | } 150 | 151 | type DNS_RPC_RECORD_SOA struct { 152 | DwSerialNo int `struc:"uint32"` 153 | DwRefresh int `struc:"uint32"` 154 | DwRetry int `struc:"uint32"` 155 | DwExpire int `struc:"uint32"` 156 | DwMinimumTtl int `struc:"uint32"` 157 | NamePrimaryServer DNS_COUNT_NAME 158 | ZoneAdministratorEmail DNS_COUNT_NAME 159 | } 160 | 161 | func (d *DNS_RPC_RECORD_SOA) String() string { 162 | return fmt.Sprintf("%s %s %d %d %d %d %d", d.NamePrimaryServer, d.ZoneAdministratorEmail, d.DwSerialNo, d.DwRefresh, d.DwRetry, d.DwExpire, d.DwMinimumTtl) 163 | } 164 | 165 | // can't get bitmask to work? 166 | //type DNS_RPC_RECORD_WKS struct { 167 | // IpAddress net.IP `struc:"[4]byte"` 168 | // ChProtocol int `struc:"uint8"` 169 | // BBitMask DNS_COUNT_NAME 170 | //} 171 | // 172 | //func (d *DNS_RPC_RECORD_WKS) String() string { 173 | // return fmt.Sprintf("%s %d %d", d.IpAddress, d.ChProtocol, d.BBitMask) 174 | //} 175 | 176 | type DNS_RPC_RECORD_SRV struct { 177 | Priority int `struc:"uint16"` 178 | Weight int `struc:"uint16"` 179 | Port int `struc:"uint16"` 180 | NameTarget DNS_COUNT_NAME 181 | } 182 | 183 | func (d *DNS_RPC_RECORD_SRV) String() string { 184 | return fmt.Sprintf("%d %d %d %s", d.Priority, d.Weight, d.Port, d.NameTarget) 185 | } 186 | 187 | -------------------------------------------------------------------------------- /pkg/adschema/objectreplicalinks/objectreplicalinks.go: -------------------------------------------------------------------------------- 1 | package objectreplicalinks 2 | 3 | import "github.com/ropnop/go-windapsearch/pkg/adschema/objectreplicalinks/dnsrecord" 4 | 5 | type ConvertObjectReplicaLink func([]byte) interface{} 6 | 7 | var ObjectReplicaLinkFuncs = map[string]ConvertObjectReplicaLink{ 8 | "dnsRecord": dnsrecord.ConvertDnsRecord, 9 | } 10 | -------------------------------------------------------------------------------- /pkg/adschema/root_dse_syntaxes.go: -------------------------------------------------------------------------------- 1 | package adschema 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | // Manually creating mappings for Root DSE attributes, which are documented here: 8 | // https://docs.microsoft.com/en-us/windows/win32/adschema/rootdse 9 | 10 | // I'm only implementing ones I care about at this point 11 | var RootDSEAttributeMap = map[string]bool{ 12 | "defaultNamingContext": true, 13 | "dnsHostName": true, 14 | "domainFunctionality": true, 15 | "forestFunctionality": true, 16 | "domainControllerFunctionality": true, 17 | "rootDomainNamingContext": true, 18 | "currentTime": true, 19 | } 20 | 21 | func marshalRootDSEAttribute(e *ADAttribute) ([]byte, error) { 22 | switch e.Name { 23 | case "defaultNamingContext", "dnsHostName", "rootDomainNamingContext": 24 | return json.Marshal(string(e.ByteValues[0])) 25 | case "domainFunctionality", "forestFunctionality", "domainControllerFunctionality": 26 | level, ok := FunctionalityLevelsMapping[string(e.ByteValues[0])] 27 | if ok { 28 | return json.Marshal(level) 29 | } else { 30 | return json.Marshal(printable(e.ByteValues[0])) 31 | } 32 | case "currentTime": 33 | b, err := ConvertGeneralizedTime(e.Name, e.ByteValues[0]) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return json.Marshal(b) 38 | } 39 | return marshalUnknownAttribute(e) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/adschema/syntax_enums.go: -------------------------------------------------------------------------------- 1 | package adschema 2 | 3 | type syntax int 4 | 5 | const ( 6 | Boolean syntax = iota 7 | Enumeration 8 | Interval 9 | Object_Access_Point 10 | Object_DN_Binary 11 | Object_DS_DN 12 | Object_Presentation_Address 13 | Object_Replica_Link 14 | String_Generalized_Time 15 | String_IA5 16 | String_NT_Sec_Desc 17 | String_Numeric 18 | String_Object_Identifier 19 | String_Sid 20 | String_Teletex 21 | String_Unicode 22 | ) 23 | 24 | var ( 25 | _syntaxNameToValue = map[string]syntax{ 26 | "Boolean": Boolean, 27 | "Enumeration": Enumeration, 28 | "Interval": Interval, 29 | "Object(Access-Point)": Object_Access_Point, 30 | "Object(DN-Binary)": Object_DN_Binary, 31 | "Object(DS-DN)": Object_DS_DN, 32 | "Object(Presentation-Address)": Object_Presentation_Address, 33 | "Object(Replica-Link)": Object_Replica_Link, 34 | "String(Generalized-Time)": String_Generalized_Time, 35 | "String(IA5)": String_IA5, 36 | "String(NT-Sec-Desc)": String_NT_Sec_Desc, 37 | "String(Numeric)": String_Numeric, 38 | "String(Object-Identifier)": String_Object_Identifier, 39 | "String(Sid)": String_Sid, 40 | "String(Teletex)": String_Teletex, 41 | "String(Unicode)": String_Unicode, 42 | } 43 | 44 | _syntaxValueToName = map[syntax]string{ 45 | Boolean: "Boolean", 46 | Enumeration: "Enumeration", 47 | Interval: "Interval", 48 | Object_Access_Point: "Object_Access_Point", 49 | Object_DN_Binary: "Object_DN_Binary", 50 | Object_DS_DN: "Object_DS_DN", 51 | Object_Presentation_Address: "Object_Presentation_Address", 52 | Object_Replica_Link: "Object_Replica_Link", 53 | String_Generalized_Time: "String_Generalized_Time", 54 | String_IA5: "String_IA5", 55 | String_NT_Sec_Desc: "String_NT_Sec_Desc", 56 | String_Numeric: "String_Numeric", 57 | String_Object_Identifier: "String_Object_Identifier", 58 | String_Sid: "String_Sid", 59 | String_Teletex: "String_Teletex", 60 | String_Unicode: "String_Unicode", 61 | } 62 | ) 63 | -------------------------------------------------------------------------------- /pkg/adschema/syntax_jsonenums.go: -------------------------------------------------------------------------------- 1 | // generated by jsonenums -type=syntax; DO NOT EDIT 2 | 3 | package adschema 4 | 5 | import ( 6 | "encoding/json" 7 | "fmt" 8 | ) 9 | 10 | func init() { 11 | var v syntax 12 | if _, ok := interface{}(v).(fmt.Stringer); ok { 13 | _syntaxNameToValue = map[string]syntax{ 14 | interface{}(Boolean).(fmt.Stringer).String(): Boolean, 15 | interface{}(Enumeration).(fmt.Stringer).String(): Enumeration, 16 | interface{}(Interval).(fmt.Stringer).String(): Interval, 17 | interface{}(Object_Access_Point).(fmt.Stringer).String(): Object_Access_Point, 18 | interface{}(Object_DN_Binary).(fmt.Stringer).String(): Object_DN_Binary, 19 | interface{}(Object_DS_DN).(fmt.Stringer).String(): Object_DS_DN, 20 | interface{}(Object_Presentation_Address).(fmt.Stringer).String(): Object_Presentation_Address, 21 | interface{}(Object_Replica_Link).(fmt.Stringer).String(): Object_Replica_Link, 22 | interface{}(String_Generalized_Time).(fmt.Stringer).String(): String_Generalized_Time, 23 | interface{}(String_IA5).(fmt.Stringer).String(): String_IA5, 24 | interface{}(String_NT_Sec_Desc).(fmt.Stringer).String(): String_NT_Sec_Desc, 25 | interface{}(String_Numeric).(fmt.Stringer).String(): String_Numeric, 26 | interface{}(String_Object_Identifier).(fmt.Stringer).String(): String_Object_Identifier, 27 | interface{}(String_Sid).(fmt.Stringer).String(): String_Sid, 28 | interface{}(String_Teletex).(fmt.Stringer).String(): String_Teletex, 29 | interface{}(String_Unicode).(fmt.Stringer).String(): String_Unicode, 30 | } 31 | } 32 | } 33 | 34 | // MarshalJSON is generated so syntax satisfies json.Marshaler. 35 | func (r syntax) MarshalJSON() ([]byte, error) { 36 | if s, ok := interface{}(r).(fmt.Stringer); ok { 37 | return json.Marshal(s.String()) 38 | } 39 | s, ok := _syntaxValueToName[r] 40 | if !ok { 41 | return nil, fmt.Errorf("invalid syntax: %d", r) 42 | } 43 | return json.Marshal(s) 44 | } 45 | 46 | // UnmarshalJSON is generated so syntax satisfies json.Unmarshaler. 47 | func (r *syntax) UnmarshalJSON(data []byte) error { 48 | var s string 49 | if err := json.Unmarshal(data, &s); err != nil { 50 | return fmt.Errorf("syntax should be a string, got %s", data) 51 | } 52 | v, ok := _syntaxNameToValue[s] 53 | if !ok { 54 | return fmt.Errorf("invalid syntax %q", s) 55 | } 56 | *r = v 57 | return nil 58 | } 59 | -------------------------------------------------------------------------------- /pkg/adschema/syntaxes.go: -------------------------------------------------------------------------------- 1 | package adschema 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "github.com/bwmarrin/go-objectsid" 7 | "github.com/ropnop/go-windapsearch/pkg/adschema/enums" 8 | "github.com/ropnop/go-windapsearch/pkg/adschema/objectreplicalinks" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | // Unique syntaxes 14 | //"Boolean", 15 | //"Enumeration", 16 | //"Interval", 17 | //"Object(Access-Point)", 18 | //"Object(DN-Binary)", 19 | //"Object(DS-DN)", 20 | //"Object(Presentation-Address)", 21 | //"Object(Replica-Link)", 22 | //"String(Generalized-Time)", 23 | //"String(IA5)", 24 | //"String(NT-Sec-Desc)", 25 | //"String(Numeric)", 26 | //"String(Object-Identifier)", 27 | //"String(Sid)", 28 | //"String(Teletex)", 29 | //"String(Unicode)" 30 | 31 | type ConvertBytes = func(string, []byte) (interface{}, error) 32 | 33 | // these are custon functions for converting LDAP bytes to a more readable form 34 | // I don't account for all of them - if the syntax isn't listed they just default to a printable string 35 | var SyntaxFunctions = map[string]ConvertBytes{ 36 | "Boolean": ConvertBool, 37 | "String(Generalized-Time)": ConvertGeneralizedTime, 38 | "Interval": ConvertInterval, 39 | "String(Sid)": ConvertSid, 40 | "Object(Replica-Link)": ConvertObjectReplicaLink, 41 | "Enumeration": ConvertEnumeration, 42 | } 43 | 44 | func DefaultPrint(name string, b []byte) (interface{}, error) { 45 | return printable(b), nil 46 | } 47 | 48 | func ConvertBool(name string, b []byte) (interface{}, error) { 49 | return strconv.ParseBool(string(b)) 50 | } 51 | 52 | func ConvertGeneralizedTime(name string, b []byte) (interface{}, error) { 53 | // https://docs.microsoft.com/en-us/windows/win32/adschema/s-string-generalized-time 54 | timestamp := string(b) 55 | return time.Parse("20060102150405.0Z0700", timestamp) 56 | } 57 | 58 | // these attrbitures are longs which represent "number of 100 nanosecond intervals since January 1, 1601 (UTC)" 59 | var NTFiletimeAttributes = map[string]bool{ 60 | "accountExpires": true, 61 | "badPasswordTime": true, 62 | "lastLogoff": true, 63 | "lastLogon": true, 64 | "lastLogonTimestamp": true, 65 | "lastSetTime": true, 66 | "lockoutTime": true, 67 | "pwdLastSet": true, 68 | } 69 | 70 | func ConvertInterval(name string, b []byte) (interface{}, error) { 71 | // https://docs.microsoft.com/en-us/windows/win32/adschema/s-interval 72 | timestamp := string(b) 73 | if timestamp == "9223372036854775807" || timestamp == "0" { // indicates a "never", I chose to represent this as a 0 74 | return "0", nil 75 | } 76 | 77 | if _, ok := NTFiletimeAttributes[name]; ok { 78 | return NTFileTimeToTimestamp(timestamp) 79 | } 80 | return timestamp, nil 81 | } 82 | 83 | func ConvertSid(name string, b []byte) (interface{}, error) { 84 | if len(b) < 12 { 85 | return "", fmt.Errorf("windows SID seems too short") 86 | } 87 | sid := objectsid.Decode(b) 88 | return sid.String(), nil 89 | } 90 | 91 | func ConvertObjectReplicaLink(name string, b []byte) (interface{}, error) { 92 | if _, ok := objectreplicalinks.ObjectReplicaLinkFuncs[name]; ok { 93 | return objectreplicalinks.ObjectReplicaLinkFuncs[name](b), nil 94 | } 95 | if len(b) != 16 { 96 | return printable(b), nil 97 | } 98 | return fmt.Sprintf( 99 | "%08x-%04x-%04x-%04x-%012x", 100 | binary.LittleEndian.Uint32(b[:4]), 101 | binary.LittleEndian.Uint16(b[4:6]), 102 | binary.LittleEndian.Uint16(b[6:8]), 103 | b[8:10], 104 | b[10:]), nil 105 | } 106 | 107 | func ConvertEnumeration(name string, b []byte) (interface{}, error) { 108 | // https://docs.microsoft.com/en-us/windows/win32/adschema/s-enumeration 109 | // Active Directory treats this as an integer. 110 | val, err := strconv.ParseInt(string(b), 10, 64) 111 | if err != nil { 112 | return 0, err 113 | } 114 | if _, ok := enums.EnumFuncs[name]; ok { 115 | return enums.EnumFuncs[name](val), nil 116 | } 117 | return val, nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/adschema/utils.go: -------------------------------------------------------------------------------- 1 | package adschema 2 | 3 | import ( 4 | "encoding/binary" 5 | "fmt" 6 | "github.com/bwmarrin/go-objectsid" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | "unicode/utf16" 12 | ) 13 | 14 | var FunctionalityLevelsMapping = map[string]string{ 15 | "0": "2000", 16 | "1": "2003 Interim", 17 | "2": "2003", 18 | "3": "2008", 19 | "4": "2008 R2", 20 | "5": "2012", 21 | "6": "2012 R2", 22 | "7": "2016", 23 | "": "Unknown", 24 | } 25 | 26 | var NTFileTimeRegex *regexp.Regexp 27 | var ADLdapTimeRegex *regexp.Regexp 28 | 29 | func init() { 30 | NTFileTimeRegex = regexp.MustCompile(`^[0-9]{18}$`) 31 | ADLdapTimeRegex = regexp.MustCompile(`^[0-9]{14}\.[0-9]Z$`) 32 | } 33 | 34 | func WindowsGuidFromBytes(b []byte) (string, error) { 35 | if len(b) != 16 { 36 | return "", fmt.Errorf("GUID must be 16 bytes") 37 | } 38 | return fmt.Sprintf( 39 | "%08x-%04x-%04x-%04x-%012x", 40 | binary.LittleEndian.Uint32(b[:4]), 41 | binary.LittleEndian.Uint16(b[4:6]), 42 | binary.LittleEndian.Uint16(b[6:8]), 43 | b[8:10], 44 | b[10:]), nil 45 | } 46 | 47 | func WindowsSIDFromBytes(b []byte) (string, error) { 48 | if len(b) < 12 { 49 | return "", fmt.Errorf("windows SID seems too short") 50 | } 51 | sid := objectsid.Decode(b) 52 | return sid.String(), nil 53 | } 54 | 55 | func NTFileTimeToTimestamp(s string) (timestamp time.Time, err error) { 56 | ticks, err := strconv.ParseInt(s, 10, 64) 57 | if err != nil { 58 | return 59 | } 60 | 61 | secs := (int64)((ticks / 10000000) - 11644473600) 62 | nsecs := (int64)((ticks % 10000000) * 100) 63 | 64 | return time.Unix(secs, nsecs), nil 65 | } 66 | 67 | func ADLdapTimeToTimestamp(s string) (timestamp time.Time, err error) { 68 | s = strings.TrimSuffix(s, ".0Z") 69 | return time.Parse("20060102150405", s) 70 | } 71 | 72 | // credit: https://golang.org/src/syscall/syscall_windows.go 73 | func UTF16ToString(s []uint16) string { 74 | for i, v := range s { 75 | if v == 0 { 76 | s = s[0:i] 77 | break 78 | } 79 | } 80 | return string(utf16.Decode(s)) 81 | } 82 | -------------------------------------------------------------------------------- /pkg/buildinfo/version.go: -------------------------------------------------------------------------------- 1 | package buildinfo 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | var ( 9 | Version = "dev" 10 | GitSHA = "HEAD" 11 | BuildDate = "N/A" 12 | BuildNumber = "" 13 | GoVersion string 14 | ) 15 | 16 | func FormatVersionString() string { 17 | GoVersion = runtime.Version() 18 | return fmt.Sprintf("Version: %s (%s) | Built: %s (%s) | Ronnie Flathers @ropnop\n", Version, GitSHA, BuildDate, GoVersion) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/dns/dns.go: -------------------------------------------------------------------------------- 1 | package dns 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "strings" 7 | ) 8 | 9 | // FindLDAPServers attempts to find LDAP servers in a domain via DNS. First it attempts looking up LDAP via SRV records, 10 | // if that fails, it will just resolve the domain to an IP and return that. 11 | func FindLDAPServers(domain string) (servers []string, err error) { 12 | _, srvs, err := net.LookupSRV("ldap", "tcp", domain) 13 | if err != nil { 14 | if strings.Contains(err.Error(), "No records found") { 15 | return net.LookupHost(domain) 16 | } 17 | } 18 | 19 | for _, s := range srvs { 20 | servers = append(servers, s.Target) 21 | } 22 | // also resolve the domain itself and return that IP 23 | domain_ips, _ := net.LookupHost(domain) 24 | servers = append(servers, domain_ips...) 25 | 26 | if len(servers) == 0 { 27 | err = fmt.Errorf("no LDAP servers found for domain: %s", domain) 28 | return 29 | } 30 | return servers, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/ldapsession/domaininfo.go: -------------------------------------------------------------------------------- 1 | package ldapsession 2 | -------------------------------------------------------------------------------- /pkg/ldapsession/search.go: -------------------------------------------------------------------------------- 1 | package ldapsession 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/go-ldap/ldap/v3" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | func (w *LDAPSession) MakeSimpleSearchRequest(filter string, attrs []string) *ldap.SearchRequest { 12 | return ldap.NewSearchRequest( 13 | w.BaseDN, 14 | ldap.ScopeWholeSubtree, 15 | ldap.NeverDerefAliases, 16 | 0, 0, false, 17 | filter, 18 | attrs, 19 | nil) 20 | } 21 | 22 | func (w *LDAPSession) MakeSearchRequestWithDN(baseDN, filter string, attrs []string) *ldap.SearchRequest { 23 | return ldap.NewSearchRequest( 24 | baseDN, 25 | ldap.ScopeWholeSubtree, 26 | ldap.NeverDerefAliases, 27 | 0, 0, false, 28 | filter, 29 | attrs, 30 | nil) 31 | } 32 | 33 | // GetPagedSearchResults is a synchronous operation that will populate and return an ldap.SearchResult object 34 | func (w *LDAPSession) GetPagedSearchResults(request *ldap.SearchRequest) (result *ldap.SearchResult, err error) { 35 | w.Log.WithFields(logrus.Fields{"baseDN": request.BaseDN, "filter": request.Filter, "attributes": request.Attributes}).Infof("sending LDAP search request") 36 | return w.LConn.SearchWithPaging(request, 1000) 37 | } 38 | 39 | func (w *LDAPSession) GetSearchResults(request *ldap.SearchRequest) (result *ldap.SearchResult, err error) { 40 | w.Log.WithFields(logrus.Fields{"baseDN": request.BaseDN, "filter": request.Filter, "attributes": request.Attributes}).Infof("sending LDAP search request") 41 | return w.LConn.Search(request) 42 | } 43 | 44 | func (w *LDAPSession) ManualWriteSearchResultsToChan(results *ldap.SearchResult) { 45 | w.Log.Debugf("received search results, writing %d entries to channel", len(results.Entries)) 46 | 47 | if !w.Channels.keepOpen { 48 | defer w.CloseChannels() 49 | } 50 | 51 | for _, entry := range results.Entries { 52 | w.Channels.Entries <- entry 53 | } 54 | for _, referral := range results.Referrals { 55 | w.Channels.Referrals <- referral 56 | } 57 | for _, control := range results.Controls { 58 | w.Channels.Controls <- control 59 | } 60 | } 61 | 62 | func (w *LDAPSession) ManualWriteMultipleSearchResultsToChan(multipleResults []*ldap.SearchResult) { 63 | defer w.CloseChannels() 64 | 65 | for _, results := range multipleResults { 66 | w.Log.Debugf("received search results, writing %d entries to channel", len(results.Entries)) 67 | 68 | for _, entry := range results.Entries { 69 | w.Channels.Entries <- entry 70 | } 71 | for _, referral := range results.Referrals { 72 | w.Channels.Referrals <- referral 73 | } 74 | for _, control := range results.Controls { 75 | w.Channels.Controls <- control 76 | } 77 | } 78 | } 79 | 80 | // ExecuteSearchRequest performs a paged search and writes results to the LDAPsession's defined results channel. 81 | // it only returns an err 82 | func (w *LDAPSession) ExecuteSearchRequest(searchRequest *ldap.SearchRequest) error { 83 | w.Log.WithFields(logrus.Fields{"baseDN": searchRequest.BaseDN, "filter": searchRequest.Filter, "attributes": searchRequest.Attributes}).Infof("sending LDAP search request") 84 | 85 | if w.Channels == nil { 86 | return fmt.Errorf("no channels defined. Call SetChannels first, or use GetPagedSearchResults instead") 87 | } 88 | 89 | defer func() { 90 | if !w.Channels.keepOpen { 91 | w.Log.Debugf("search finished. closing channels...") 92 | w.CloseChannels() 93 | } 94 | }() 95 | 96 | // basically a re-implementation of the standard function: https://github.com/go-ldap/ldap/blob/master/v3/search.go#L253 97 | // but writes entries to a channel as it gets them instead of waiting for all pages to complete 98 | 99 | var pagingControl *ldap.ControlPaging 100 | control := ldap.FindControl(searchRequest.Controls, ldap.ControlTypePaging) 101 | if control == nil { 102 | pagingControl = ldap.NewControlPaging(w.PageSize) 103 | searchRequest.Controls = append(searchRequest.Controls, pagingControl) 104 | } else { 105 | castControl, ok := control.(*ldap.ControlPaging) 106 | if !ok { 107 | return fmt.Errorf("expected paging control to be of type *ControlPaging, got %v", control) 108 | } 109 | if castControl.PagingSize != w.PageSize { 110 | return fmt.Errorf("paging size given in search request (%d) conflicts with size given in search call (%d)", castControl.PagingSize, w.PageSize) 111 | } 112 | pagingControl = castControl 113 | } 114 | pageNumber := 0 115 | 116 | PagedSearch: 117 | for { 118 | select { 119 | case <-w.ctx.Done(): 120 | w.Log.Warn("cancel received. aborting remaining pages") 121 | return nil 122 | default: 123 | w.Log.Debugf("making paged request...\n") 124 | result, err := w.LConn.Search(searchRequest) 125 | w.Log.Debugf("Looking for Paging Control...\n") 126 | pageNumber++ 127 | if err != nil { 128 | return err 129 | } 130 | if result == nil { 131 | return ldap.NewError(ldap.ErrorNetwork, errors.New("ldap: packet not received")) 132 | } 133 | 134 | for _, entry := range result.Entries { 135 | w.Channels.Entries <- entry 136 | } 137 | 138 | w.Log.Infof("Received page %d with %d LDAP entries...", pageNumber, len(result.Entries)) 139 | 140 | for _, referral := range result.Referrals { 141 | w.Channels.Referrals <- referral 142 | } 143 | 144 | for _, control := range result.Controls { 145 | w.Channels.Controls <- control 146 | } 147 | 148 | w.Log.Debugf("Looking for Paging Control...") 149 | pagingResult := ldap.FindControl(result.Controls, ldap.ControlTypePaging) 150 | if pagingResult == nil { 151 | pagingControl = nil 152 | w.Log.Debugf("Could not find paging control. Breaking...") 153 | break PagedSearch 154 | } 155 | 156 | cookie := pagingResult.(*ldap.ControlPaging).Cookie 157 | if len(cookie) == 0 { 158 | pagingControl = nil 159 | w.Log.Debugf("Could not find cookie. Breaking...") 160 | break PagedSearch 161 | } 162 | pagingControl.SetCookie(cookie) 163 | } 164 | } 165 | 166 | if pagingControl != nil { 167 | w.Log.Debugf("Abandoning Paging...") 168 | pagingControl.PagingSize = 0 169 | w.LConn.Search(searchRequest) 170 | } 171 | return nil 172 | } 173 | 174 | // ExecuteBulkSearchRequest will take a slice of ldap.SearchRequest and execute each one sequentially, 175 | // keeping the results channels open until the end of the last one 176 | func (w *LDAPSession) ExecuteBulkSearchRequest(searchRequests []*ldap.SearchRequest) error { 177 | w.keepChannelsOpen() 178 | defer w.CloseChannels() 179 | for _, request := range searchRequests { 180 | err := w.ExecuteSearchRequest(request) 181 | if err != nil { 182 | return err 183 | } 184 | } 185 | return nil 186 | } 187 | -------------------------------------------------------------------------------- /pkg/ldapsession/session.go: -------------------------------------------------------------------------------- 1 | package ldapsession 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "fmt" 7 | "net" 8 | "strings" 9 | 10 | "golang.org/x/net/proxy" 11 | 12 | "github.com/go-ldap/ldap/v3" 13 | "github.com/ropnop/go-windapsearch/pkg/dns" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | type LDAPSessionOptions struct { 18 | Domain string 19 | DomainController string 20 | Username string 21 | Password string 22 | Hash string 23 | UseNTLM bool 24 | Port int 25 | Secure bool 26 | Proxy string 27 | PageSize int 28 | Logger *logrus.Logger 29 | } 30 | 31 | type LDAPSession struct { 32 | LConn *ldap.Conn 33 | PageSize uint32 34 | BaseDN string 35 | DomainInfo DomainInfo 36 | Log *logrus.Entry 37 | resultsChan chan *ldap.Entry 38 | ctx context.Context 39 | Channels *ResultChannels 40 | } 41 | 42 | type ResultChannels struct { 43 | Entries chan *ldap.Entry 44 | Referrals chan string 45 | Controls chan ldap.Control 46 | keepOpen bool 47 | } 48 | 49 | type DomainInfo struct { 50 | Metadata *ldap.SearchResult 51 | DomainFunctionalityLevel string 52 | ForestFunctionalityLevel string 53 | DomainControllerFunctionalityLevel string 54 | ServerDNSName string 55 | } 56 | 57 | func NewLDAPSession(options *LDAPSessionOptions, ctx context.Context) (sess *LDAPSession, err error) { 58 | logger := logrus.New() 59 | if options.Logger != nil { 60 | logger = options.Logger 61 | } 62 | sess = &LDAPSession{Log: logger.WithFields(logrus.Fields{"package": "ldapsession"})} 63 | 64 | port := options.Port 65 | dc := options.DomainController 66 | if port == 0 { 67 | if options.Secure { 68 | port = 636 69 | } else { 70 | port = 389 71 | } 72 | } 73 | if dc == "" { 74 | dcs, err := dns.FindLDAPServers(options.Domain) 75 | if err != nil { 76 | return sess, err 77 | } 78 | dc = dcs[0] 79 | sess.Log.Infof("Found LDAP server via DNS: %s", dc) 80 | } 81 | var url string 82 | 83 | if options.Secure { 84 | url = fmt.Sprintf("ldaps://%s:%d", dc, port) 85 | } else { 86 | url = fmt.Sprintf("ldap://%s:%d", dc, port) 87 | } 88 | 89 | var conn net.Conn 90 | defaultDailer := &net.Dialer{Timeout: ldap.DefaultTimeout} 91 | 92 | // Use socks proxy if specified 93 | if options.Proxy != "" { 94 | pDialer, err := proxy.SOCKS5("tcp", options.Proxy, nil, defaultDailer) 95 | if err != nil { 96 | return nil, err 97 | } 98 | conn, err = pDialer.Dial("tcp", fmt.Sprintf("%s:%d", dc, port)) 99 | if err != nil { 100 | return nil, err 101 | } 102 | sess.Log.Debugf("establishing connection through socks proxy at %s", options.Proxy) 103 | } else { 104 | conn, err = defaultDailer.Dial("tcp", fmt.Sprintf("%s:%d", dc, port)) 105 | if err != nil { 106 | return 107 | } 108 | } 109 | sess.Log.Debugf("tcp connection established to %s:%d", dc, port) 110 | 111 | var lConn *ldap.Conn 112 | if options.Secure { 113 | tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true}) 114 | lConn = ldap.NewConn(tlsConn, options.Secure) 115 | sess.Log.Debug("TLS connection established") 116 | } else { 117 | lConn = ldap.NewConn(conn, options.Secure) 118 | } 119 | 120 | lConn.Start() 121 | 122 | sess.LConn = lConn 123 | sess.PageSize = uint32(options.PageSize) 124 | 125 | if options.UseNTLM || options.Hash != "" { 126 | err = sess.NTLMBind(options.Username, options.Password, options.Hash) 127 | } else { 128 | err = sess.SimpleBind(options.Username, options.Password) 129 | } 130 | 131 | if err != nil { 132 | return 133 | } 134 | sess.Log.Infof("successful bind to %q as %q", url, options.Username) 135 | _, err = sess.GetDefaultNamingContext() 136 | if err != nil { 137 | return 138 | } 139 | sess.Log.Infof("retrieved default naming context: %q", sess.BaseDN) 140 | 141 | sess.NewChannels(ctx) 142 | return sess, nil 143 | } 144 | 145 | func (w *LDAPSession) SetChannels(chs *ResultChannels, ctx context.Context) { 146 | w.Channels = chs 147 | w.ctx = ctx 148 | } 149 | 150 | func (w *LDAPSession) NewChannels(ctx context.Context) { 151 | w.Log.Debugf("creating new ldapsession channels") 152 | w.Channels = &ResultChannels{ 153 | Entries: make(chan *ldap.Entry), 154 | Referrals: make(chan string), 155 | Controls: make(chan ldap.Control), 156 | keepOpen: false, 157 | } 158 | w.ctx = ctx 159 | } 160 | 161 | // If you call this, the results channels will not automatically close when a search is finished and 162 | // will need to be manually closed with CloseChannels(). Be careful here - this can 163 | // cause all sorts of concurrency race conditions 164 | func (w *LDAPSession) keepChannelsOpen() { 165 | w.Channels.keepOpen = true 166 | } 167 | 168 | func (w *LDAPSession) CloseChannels() { 169 | if w.Channels.Entries != nil { 170 | close(w.Channels.Entries) 171 | } 172 | if w.Channels.Controls != nil { 173 | close(w.Channels.Controls) 174 | } 175 | if w.Channels.Referrals != nil { 176 | close(w.Channels.Referrals) 177 | } 178 | w.Log.Debugf("closing ldapsession channels") 179 | 180 | } 181 | 182 | //func (w *LDAPSession) SetResultsChannel(ch chan *ldap.Entry, ctx context.Context) { 183 | // w.resultsChan = ch 184 | // w.ctx = ctx 185 | //} 186 | 187 | func (w *LDAPSession) SimpleBind(username, password string) (err error) { 188 | if username == "" { 189 | err = w.LConn.UnauthenticatedBind("") 190 | } else { 191 | err = w.LConn.Bind(username, password) 192 | } 193 | if err != nil { 194 | return 195 | } 196 | return 197 | } 198 | 199 | func (w *LDAPSession) NTLMBind(username, password, hash string) (err error) { 200 | userParts := strings.Split(username, "@") 201 | user := userParts[0] 202 | domain := strings.Join(userParts[1:], "") 203 | 204 | if hash != "" { 205 | w.Log.Infof("attempting PtH NTLM bind for %q", user) 206 | return w.LConn.NTLMBindWithHash(domain, user, hash) 207 | } 208 | w.Log.Infof("attempting NTLM bind for %q", user) 209 | return w.LConn.NTLMBind(domain, user, password) 210 | } 211 | 212 | func (w *LDAPSession) Close() { 213 | w.LConn.Close() 214 | } 215 | 216 | func (w *LDAPSession) GetDefaultNamingContext() (string, error) { 217 | if w.BaseDN != "" { 218 | return w.BaseDN, nil 219 | } 220 | sr := ldap.NewSearchRequest( 221 | "", 222 | ldap.ScopeBaseObject, 223 | ldap.NeverDerefAliases, 224 | 0, 0, false, 225 | "(objectClass=*)", 226 | []string{"defaultNamingContext"}, 227 | nil) 228 | res, err := w.LConn.Search(sr) 229 | if err != nil { 230 | return "", err 231 | } 232 | if len(res.Entries) == 0 { 233 | return "", fmt.Errorf("error getting metadata: No LDAP responses from server") 234 | } 235 | defaultNamingContext := res.Entries[0].GetAttributeValue("defaultNamingContext") 236 | if defaultNamingContext == "" { 237 | return "", fmt.Errorf("error getting metadata: attribute defaultNamingContext missing") 238 | } 239 | w.BaseDN = defaultNamingContext 240 | return w.BaseDN, nil 241 | 242 | } 243 | 244 | func (w *LDAPSession) ReturnMetadataResults() error { 245 | for _, entry := range w.DomainInfo.Metadata.Entries { 246 | w.resultsChan <- entry 247 | } 248 | return nil 249 | } 250 | -------------------------------------------------------------------------------- /pkg/modules/README.md: -------------------------------------------------------------------------------- 1 | # Windapsearch Modules 2 | `windapsearch` has adopted a modular structure for extending and adding common LDAP enumeration techniques. 3 | The core functionality of setting up the connection and processing results is handled by the `windapsearch` and `ldapsession` 4 | packages, so modules can be fairly standalone. 5 | 6 | The following modules have been implemented, with functionality copied from the existing Python `windapsearch` script: 7 | 8 | * [admin-objects](#admin-objects) 9 | * [computers](#computers) 10 | * [custom](#custom) 11 | * [dns-names](#dns-names) 12 | * [dns-zones](#dns-zones) 13 | * [domain-admins](#domain-admins) 14 | * [gpos](#gpos) 15 | * [groups](#groups) 16 | * [members](#members) 17 | * [metadata](#metadata) 18 | * [privileged-users](#privileged-users) 19 | * [search](#search) 20 | * [unconstrained](#unconstrained) 21 | * [user-spns](#user-spns) 22 | * [users](#users) 23 | 24 | **Common Options** 25 | Every module inherits/hones the following command line switches: 26 | `--attrs`: custom comma separated attributes to display. Overrides per-module defaults 27 | `--full`: display all attributes (`*`). Overrides defaults and `--attrs` 28 | `--json/-j`: Convert entries to JSON and convert availble fields to friendly formats 29 | 30 | Also, `dn` will always be included as an attribute by default since it is always returned in responses. 31 | 32 | ## admin-objects 33 | **Description**: `Enumerate all objects with protected ACLs (i.e admins)` 34 | 35 | **Default Attrs**: `cn` 36 | 37 | **Base Filter**: `(adminCount=1)` 38 | 39 | **Additional Options**: `` 40 | 41 | This module searches for any and all LDAP entries that have `adminCount=1`, indicating that they have protected ACLs, which means they are highly privileged objects (e.g. Domain Admins, priveleged groups, etc) 42 | 43 | **Example Usage**: 44 | ``` 45 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m admin-objects -j | jq '.[0]' 46 | { 47 | "cn": "Backup Operators", 48 | "dn": "CN=Backup Operators,CN=Builtin,DC=lab,DC=ropnop,DC=com" 49 | } 50 | ``` 51 | 52 | ## computers 53 | **Description**: `Enumerate AD Computers` 54 | 55 | **Default Attrs**: `cn, dNSHostName, operatingSystem, operatingSystemVersion,operatingSystemServicePack` 56 | 57 | **Base Filter**: `(objectClass=Computer)` 58 | 59 | **Additional Options**: `` 60 | 61 | This module searches for all AD joined computers, and displays LDAP information about the computers, including DNS name and OS version. 62 | 63 | **Example Usage**: 64 | ``` 65 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m computers -j | jq '.[0]' 66 | { 67 | "cn": "WS03WIN10", 68 | "dNSHostName": "ws03win10.lab.ropnop.com", 69 | "dn": "CN=WS03WIN10,OU=computers,OU=LAB,DC=lab,DC=ropnop,DC=com", 70 | "operatingSystem": "Windows 10 Pro", 71 | "operatingSystemVersion": "10.0 (17134)" 72 | } 73 | ``` 74 | 75 | ## custom 76 | **Description**: `Run a custom LDAP syntax filter` 77 | 78 | **Default Attrs**: `*` 79 | 80 | **Base Filter**: `custom` 81 | 82 | **Additional Options**: `--filter` 83 | 84 | The module lets you specify a custom LDAP syntax filter to run, and returns all attributes by default. *Note: your filter must be valid LDAP filter syntax and wrapped in parantheses* 85 | 86 | **Example Usage**: 87 | ``` 88 | $ ./bin/windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m custom --filter "(sAMAccountName=thoffman)" --attrs pwdLastSet -j | jq . 89 | [ 90 | { 91 | "dn": "CN=Trevor Hoffman,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 92 | "pwdLastSet": "2019-05-16T18:39:48.1597266-05:00" 93 | } 94 | ] 95 | ``` 96 | 97 | ## dns-names 98 | **Description**: `Query AD integrated DNS for domain names` 99 | 100 | **Default Attrs**: `name, dnsTombstoned, dnsRecord` 101 | 102 | **Base Filter**: `(objectClass=*)` 103 | 104 | **Additional Options**: `` 105 | 106 | The module queries the Active Directory integrated DNS and returns all objects. 107 | When using JSON output, `windapsearch` will attempt to unmarshal the binary `dnsRecord` field and display the DNS contents *Note: this implementation is not complete for all record types. Fallback will always just be the base64 encode blob* 108 | 109 | **Example Usage**: 110 | ``` 111 | $ ./bin/windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m dns-names -j | jq . 112 | [ 113 | { 114 | "dn": "DC=sharepoint,DC=lab.ropnop.com,CN=MicrosoftDNS,DC=DomainDnsZones,DC=lab,DC=ropnop,DC=com" 115 | }, 116 | { 117 | "dn": "DC=dc,DC=lab.ropnop.com,CN=MicrosoftDNS,DC=DomainDnsZones,DC=lab,DC=ropnop,DC=com" 118 | }, 119 | { 120 | "dn": "DC=app01,DC=lab.ropnop.com,CN=MicrosoftDNS,DC=DomainDnsZones,DC=lab,DC=ropnop,DC=com" 121 | }, 122 | ... 123 | ] 124 | 125 | ``` 126 | 127 | ## dns-zones 128 | **Description**: `Query AD integrated DNS for registered zones` 129 | 130 | **Default Attrs**: `dn, name` 131 | 132 | **Base Filter**: `(&(objectClass=dnsZone)(!name=RootDNSServers)(!name=*.in-addr.arpa)(!name=_msdcs.*)(!name=..TrustAnchors))` 133 | 134 | **Additional Options**: `` 135 | 136 | The module queries the Active Directory integrated DNS and returns all DNS zones. Unfortunately, there is no attribute for the complete FQDN, therefore the dn is returned, containing sufficient information to recover the actual FQDN. 137 | 138 | **Example Usage**: 139 | ``` 140 | $ ./bin/windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m dns-zones -j | jq . 141 | [ 142 | { 143 | "dn": "DC=lab.ropnop.com,CN=MicrosoftDNS,DC=DomainDnsZones,DC=lab,DC=ropnop,DC=com", 144 | "name": "lab.ropnop.com" 145 | }, 146 | { 147 | "dn": "DC=dev.ropnop.net,CN=MicrosoftDNS,DC=DomainDnsZones,DC=dev,DC=ropnop,DC=net", 148 | "name": "dev.ropnop.net" 149 | } 150 | ] 151 | ``` 152 | 153 | ## domain-admins 154 | **Description**: `Recursively list all users objects in Domain Admins group` 155 | 156 | **Default Attrs**: `cn, sAMAccountName` 157 | 158 | **Base Filter**: `(&(objectClass=user)(|(memberof:1.2.840.113556.1.4.1941:=CN=Domain Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain-Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain Administrators,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain-Administrators,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domänen Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domänen-Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain-Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domänen Administratoren,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domänen-Administratoren,CN=Users,DC=lab,DC=ropnop,DC=com)))` 159 | 160 | **Additional Options**: `` 161 | 162 | This module lists every user object that is a member of the Domain Admins group. It performs recursive lookups using the OID `LDAP_MATCHING_RULE_IN_CHAIN`, so it will also display any user that is transitively part of the Domain Admins group as well. The filter includes language variations of the `Domain Admins` group too. 163 | 164 | **Example Usage**: 165 | ``` 166 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m domain-admins -j | jq '.[0]' 167 | { 168 | "cn": "Edna Dominguez", 169 | "dn": "CN=Edna Dominguez,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 170 | "sAMAccountName": "edominguez" 171 | } 172 | ``` 173 | 174 | ## gpos 175 | **Description**: `Enumerate Group Policy Objects` 176 | 177 | **Default Attrs**: `displayName, gPCFileSysPath` 178 | 179 | **Base Filter**: `(objectClass=groupPolicyContainer)` 180 | 181 | **Additional Options**: `` 182 | 183 | This module lists Group Policy Objects found in LDAP. It will display the display name and SYSVOL path by default: 184 | 185 | **Example Usage**: 186 | ``` 187 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m gpos -j | jq '.[0]' 188 | { 189 | "displayName": "firewall_rules", 190 | "dn": "CN={24722667-432E-4508-A58C-15D3D42FEFF4},CN=Policies,CN=System,DC=lab,DC=ropnop,DC=com", 191 | "gPCFileSysPath": "\\\\lab.ropnop.com\\SysVol\\lab.ropnop.com\\Policies\\{24722667-432E-4508-A58C-15D3D42FEFF4}" 192 | } 193 | ``` 194 | 195 | ## groups 196 | **Description**: `List all AD groups` 197 | 198 | **Default Attrs**: `cn` 199 | 200 | **Base Filter**: `(objectcategory=group)` 201 | 202 | **Additional Options**: `-s / --search` 203 | 204 | This module lists all group objects. By default it only displays the CN. Optionally, it takes a `search` option to narrow down groups. 205 | 206 | ``` 207 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m groups --search IT --attrs cn,member -j | jq '.[0]' 208 | { 209 | "cn": "IT Admins", 210 | "dn": "CN=IT Admins,OU=groups,OU=LAB,DC=lab,DC=ropnop,DC=com", 211 | "member": [ 212 | "CN=vulnscanner,OU=service-accounts,OU=LAB,DC=lab,DC=ropnop,DC=com", 213 | "CN=Desktop Support,OU=groups,OU=LAB,DC=lab,DC=ropnop,DC=com", 214 | "CN=Mark Murdock,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 215 | "CN=Susan Hendrickson,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 216 | "CN=Michael Timpson,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 217 | "CN=Herbert Smith,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 218 | "CN=Paul Rivera,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com" 219 | ] 220 | } 221 | ``` 222 | 223 | ## members 224 | **Description**: `Query for members of a group` 225 | 226 | **Default Attrs**: `cn, sAMAccountName` 227 | 228 | **Base Filter**: `(memberOf=)` 229 | 230 | **Additional Options**: `-g / --group, -r / --recursive, -s / --search, --users` 231 | 232 | This module lists members of a group. You must specify a group with `-g` by its full distinguished name, or perform a search with `-s`. If more than one match is found, the module will prompt you for which group you meant. 233 | 234 | Optionally, you can perform a `--recursive` lookup to list transitive members as well, or limit the results to only user objects with `--users` 235 | 236 | **Example Usage**: 237 | ``` 238 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m members -s remote -j |jq '.[0]' 239 | What DN do you want to use? 240 | 241 | 1. CN=Remote Desktop Users,CN=Builtin,DC=lab,DC=ropnop,DC=com 242 | 2. CN=Remote Management Users,CN=Builtin,DC=lab,DC=ropnop,DC=com 243 | 244 | Enter a number: 1 245 | 246 | [+] Using group: CN=Remote Desktop Users,CN=Builtin,DC=lab,DC=ropnop,DC=com 247 | 248 | { 249 | "cn": "Peter Harris", 250 | "dn": "CN=Peter Harris,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 251 | "sAMAccountName": "pharris" 252 | } 253 | ``` 254 | 255 | ## metadata 256 | **Description**: `Print LDAP server metadata` 257 | 258 | **Default Attrs**: `defaultNamingContext, domainFunctionality, forestFunctionality, domainControllerFunctionality, dnsHostName` 259 | 260 | **Base Filter**: `(objectClass=*)` 261 | 262 | **Additional Options**: `` 263 | 264 | This module queries the LDAP server for metadata. It does not require an authenticated bind. By default it returns functionality levels, base DN, and DNS info 265 | 266 | **Example Usage**: 267 | ``` 268 | $ ./windapsearch -d lab.ropnop.com -m metadata -j | jq . 269 | [ 270 | { 271 | "defaultNamingContext": "DC=lab,DC=ropnop,DC=com", 272 | "dnsHostName": "pdc01.lab.ropnop.com", 273 | "domainControllerFunctionality": "2012 R2", 274 | "domainFunctionality": "2012 R2", 275 | "forestFunctionality": "2012 R2" 276 | } 277 | ] 278 | ``` 279 | 280 | ## privileged-users 281 | **Description**: `Recursively list members of all highly privileged groups` 282 | 283 | **Default Attrs**: `cn, sAMAccountName` 284 | 285 | **Base Filter**: `(&(objectClass=user)(|(memberof:1.2.840.113556.1.4.1941:=CN=Administrators,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Enterprise Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Schema Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Account Operators,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Backup Operators,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Server Management,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Konten-Operatoren,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Sicherungs-Operatoren,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Server-Operatoren,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Schema-Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain-Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain Administrators,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain-Administrators,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domänen Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domänen-Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domain-Admins,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domänen Administratoren,CN=Users,DC=lab,DC=ropnop,DC=com)(memberof:1.2.840.113556.1.4.1941:=CN=Domänen-Administratoren,CN=Users,DC=lab,DC=ropnop,DC=com)))` 286 | 287 | **Additional Options**: `` 288 | 289 | This module recursively lists all members of Domain Admins and every other highly privileged group (e.g. `Schema Admins`, `Backup Operators`, etc) 290 | 291 | **Example Usage**: 292 | ``` 293 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m privileged-users -j | jq '.[0]' 294 | { 295 | "cn": "Paul Rivera", 296 | "dn": "CN=Paul Rivera,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 297 | "sAMAccountName": "privera" 298 | } 299 | ``` 300 | 301 | ## search 302 | **Description**: `Perform an ANR Search and return the results` 303 | 304 | **Default Attrs**: `*` 305 | 306 | **Base Filter**: `(anr=)` 307 | 308 | **Additional Options**: `--all, -s / --search` 309 | 310 | This module performs an [Ambiguous Name Resolution](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/1a9177f4-0272-4ab8-aa22-3c3eafd39e4b) search. If more than one match is found, it will prompt you for the entry you wish to retrieve. If `--all` is specified, it will not prompt and instead dump every matching entry. 311 | 312 | **Example Usage**: 313 | ``` 314 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m search -s ronnie --attrs objectSid -j | jq '.[0]' 315 | What DN do you want to use? 316 | 317 | 1. CN=Ronnie Weinberg,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com 318 | 2. CN=Ronnie Cooper,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com 319 | 320 | Enter a number: 1 321 | 322 | { 323 | "dn": "CN=Ronnie Weinberg,OU=US,OU=users,OU=LAB,DC=lab,DC=ropnop,DC=com", 324 | "objectSid": "S-1-5-21-1654090657-4040911344-3269124959-1715" 325 | } 326 | ``` 327 | 328 | ## unconstrained 329 | **Description**: `Find objects that allow unconstrained delegation` 330 | 331 | **Default Attrs**: `cn, sAMAccountName` 332 | 333 | **Base Filter**: `(userAccountControl:1.2.840.113556.1.4.803:=524288)` 334 | 335 | **Additional Options**: `--computers, --users` 336 | 337 | This module will search for LDAP objects that allow for unconstrained delegation. By default it will list all objects, though you can limit it either computers or users by using `--computers` or `--users`, respectively. 338 | 339 | **Example Usage**: 340 | ``` 341 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m unconstrained -j | jq '.[0]' 342 | { 343 | "cn": "PDC01", 344 | "dn": "CN=PDC01,OU=Domain Controllers,DC=lab,DC=ropnop,DC=com", 345 | "sAMAccountName": "PDC01$" 346 | } 347 | ``` 348 | 349 | ## user-spns 350 | **Description**: `Enumerate all users objects with Service Principal Names (for kerberoasting)` 351 | 352 | **Default Attrs**: `cn, sAMAccountName, servicePrincipalName` 353 | 354 | **Base Filter**: `(&(&(servicePrincipalName=*)(UserAccountControl:1.2.840.113556.1.4.803:=512))(!(UserAccountControl:1.2.840.113556.1.4.803:=2)))` 355 | 356 | **Additional Options**: `` 357 | 358 | This module will identify user objects with servicePrincipalNames defined, which can be used for kerberoasting. 359 | 360 | **Example Usage**: 361 | ``` 362 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m user-spns -j | jq '.[0]' 363 | { 364 | "cn": "vulnscanner", 365 | "dn": "CN=vulnscanner,OU=service-accounts,OU=LAB,DC=lab,DC=ropnop,DC=com", 366 | "sAMAccountName": "vulnscanner", 367 | "servicePrincipalName": [ 368 | "HTTP/webdev.lab.ropnop.com" 369 | ] 370 | } 371 | ``` 372 | 373 | ## users 374 | **Description**: `List all user objects` 375 | 376 | **Default Attrs**: `cn, sAMAccountName, userPrincipalName` 377 | 378 | **Base Filter**: `(objectcategory=user)` 379 | 380 | **Additional Options**: `--filter, -s / --search` 381 | 382 | This module lists every LDAP user object. Depending on the size of the domain, this can get very big. You can limit results by adding an additional LDAP syntax filter with `--filter`, or an ANR search term with `--search`. 383 | 384 | **Example Usage**: 385 | ``` 386 | $ ./windapsearch -d lab.ropnop.com -u agreen@lab.ropnop.com -p $PASS -m users --full -j -o full_user_dump.json 387 | [+] full_user_dump.json written 388 | 389 | $ jq '.|length' full_user_dump.json 390 | 2758 391 | 392 | $ jq '.[] | select(.badPwdCount > 4)|.userPrincipalName' full_user_dump.json 393 | "baguirre@lab.ropnop.com" 394 | "avenezia@lab.ropnop.com" 395 | "aturner@lab.ropnop.com" 396 | "avelasquez@lab.ropnop.com" 397 | "awoodell@lab.ropnop.com" 398 | "ayoho@lab.ropnop.com" 399 | "awilliams@lab.ropnop.com" 400 | "atressler@lab.ropnop.com" 401 | "awhite@lab.ropnop.com" 402 | "awoods@lab.ropnop.com" 403 | "ayim@lab.ropnop.com" 404 | "aweiss@lab.ropnop.com" 405 | "ayunker@lab.ropnop.com" 406 | "barndt@lab.ropnop.com" 407 | ``` 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | -------------------------------------------------------------------------------- /pkg/modules/admin_objects.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 5 | "github.com/spf13/pflag" 6 | ) 7 | 8 | type AdminObjects struct{} 9 | 10 | func init() { 11 | AllModules = append(AllModules, new(AdminObjects)) 12 | } 13 | 14 | func (AdminObjects) Name() string { 15 | return "admin-objects" 16 | } 17 | 18 | func (AdminObjects) Description() string { 19 | return "Enumerate all objects with protected ACLs (i.e admins)" 20 | } 21 | 22 | func (AdminObjects) FlagSet() *pflag.FlagSet { 23 | return pflag.NewFlagSet("adminobjects", pflag.ExitOnError) 24 | } 25 | 26 | func (AdminObjects) DefaultAttrs() []string { 27 | return []string{"cn"} 28 | } 29 | 30 | func (AdminObjects) Run(session *ldapsession.LDAPSession, attrs []string) error { 31 | filter := "(adminCount=1)" 32 | sr := session.MakeSimpleSearchRequest(filter, attrs) 33 | return session.ExecuteSearchRequest(sr) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/modules/computers.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 5 | "github.com/spf13/pflag" 6 | ) 7 | 8 | type ComputersModule struct{} 9 | 10 | func init() { 11 | AllModules = append(AllModules, new(ComputersModule)) 12 | } 13 | 14 | func (c ComputersModule) Name() string { 15 | return "computers" 16 | } 17 | 18 | func (c ComputersModule) Description() string { 19 | return "Enumerate AD Computers" 20 | } 21 | 22 | func (c ComputersModule) FlagSet() *pflag.FlagSet { 23 | flags := pflag.NewFlagSet("computers-module", pflag.ExitOnError) 24 | return flags 25 | } 26 | 27 | func (c ComputersModule) DefaultAttrs() []string { 28 | return []string{"cn", "dNSHostName", "operatingSystem", "operatingSystemVersion", "operatingSystemServicePack"} 29 | } 30 | 31 | func (c ComputersModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 32 | filter := "(objectClass=Computer)" 33 | searchReq := session.MakeSimpleSearchRequest(filter, attrs) 34 | return session.ExecuteSearchRequest(searchReq) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/modules/custom_filter.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-ldap/ldap/v3" 6 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 7 | "github.com/spf13/pflag" 8 | ) 9 | 10 | type CustomSearch struct { 11 | CustomFilter string 12 | CustomBaseDN string 13 | } 14 | 15 | func init() { 16 | AllModules = append(AllModules, new(CustomSearch)) 17 | } 18 | 19 | func (c *CustomSearch) Name() string { 20 | return "custom" 21 | } 22 | 23 | func (c *CustomSearch) Description() string { 24 | return "Run a custom LDAP syntax filter" 25 | } 26 | 27 | func (c *CustomSearch) Filter() string { 28 | return c.CustomFilter 29 | } 30 | 31 | func (c *CustomSearch) FlagSet() *pflag.FlagSet { 32 | flags := pflag.NewFlagSet("custom", pflag.ExitOnError) 33 | flags.StringVar(&c.CustomFilter, "filter", "", "LDAP syntax filter") 34 | flags.StringVar(&c.CustomBaseDN, "base", "", "Custom base DN to search from") 35 | return flags 36 | } 37 | 38 | func (c *CustomSearch) DefaultAttrs() []string { 39 | return []string{"*"} 40 | } 41 | 42 | func (c *CustomSearch) Run(lSession *ldapsession.LDAPSession, attrs []string) error { 43 | if c.Filter() == "" { 44 | return fmt.Errorf("must provide a filter to run") 45 | } 46 | var searchReq *ldap.SearchRequest 47 | if c.CustomBaseDN != "" { 48 | searchReq = lSession.MakeSearchRequestWithDN(c.CustomBaseDN, c.Filter(), attrs) 49 | } else { 50 | searchReq = lSession.MakeSimpleSearchRequest(c.Filter(), attrs) 51 | } 52 | return lSession.ExecuteSearchRequest(searchReq) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/modules/dnsnames.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-ldap/ldap/v3" 6 | "github.com/ropnop/go-windapsearch/pkg/adschema" 7 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 8 | "github.com/spf13/pflag" 9 | "strings" 10 | ) 11 | 12 | type DnsNamesModule struct{} 13 | 14 | func init() { 15 | AllModules = append(AllModules, new(DnsNamesModule)) 16 | } 17 | 18 | func (d DnsNamesModule) Name() string { 19 | return "dns-names" 20 | } 21 | 22 | func (d DnsNamesModule) Description() string { 23 | return "List all DNS Names" 24 | } 25 | 26 | func (d DnsNamesModule) FlagSet() *pflag.FlagSet { 27 | flags := pflag.NewFlagSet(d.Name(), pflag.ExitOnError) 28 | return flags 29 | } 30 | 31 | func (d DnsNamesModule) DefaultAttrs() []string { 32 | return []string{"name", "dnsRecord", "dnsTombstoned"} 33 | } 34 | 35 | // Optional function for the module interface that will be called by searchResultWorker 36 | // This will hide by default a lot of extraneous entries we usually don't care about 37 | // use '--ignore-display-filters' to bypass this and display everything 38 | func (d DnsNamesModule) DisplayFilter(entry *adschema.ADEntry) bool { 39 | locations := []string{"CN=MicrosoftDNS,DC=DomainDnsZones,%s", "CN=MicrosoftDNS,DC=ForestDnsZones,%s", "CN=MicrosoftDNS,CN=System,%s"} 40 | dnContainsFilter := []string{"DC=RootDNSServers", "in-addr.arpa,", "DC=_msdcs", "..TrustAnchors"} 41 | dnBeginsFilter := []string{"DC=DomainDnsZones,", "DC=ForestDnsZones,", "DC=_kerberos.", "DC=_ldap.", "DC=_kpasswd.", "DC=_gc.", "DC=@", "DC=_autodiscover."} 42 | for _, filterEntry := range dnContainsFilter { // Filter entries like rDNS zones 43 | if strings.Contains(entry.DN, filterEntry) { 44 | return false 45 | } 46 | } 47 | for _, filterEntry := range dnBeginsFilter { // filter entries used for discovery, e.g. _ldap 48 | if strings.HasPrefix(entry.DN, filterEntry) { 49 | return false 50 | } 51 | } 52 | for _, filterEntry := range locations { // filter the zone entries themselves 53 | if strings.HasPrefix(entry.DN, fmt.Sprintf(filterEntry, "")) { 54 | return false 55 | } 56 | } 57 | return true 58 | 59 | } 60 | 61 | func (d DnsNamesModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 62 | locations := []string{"CN=MicrosoftDNS,DC=DomainDnsZones,%s", "CN=MicrosoftDNS,DC=ForestDnsZones,%s", "CN=MicrosoftDNS,CN=System,%s"} 63 | var searchRequests []*ldap.SearchRequest 64 | for _, location := range locations { 65 | dn := fmt.Sprintf(location, session.BaseDN) 66 | 67 | searchReq := session.MakeSearchRequestWithDN(dn, "(objectClass=*)", attrs) 68 | searchRequests = append(searchRequests, searchReq) 69 | } 70 | 71 | return session.ExecuteBulkSearchRequest(searchRequests) 72 | } 73 | -------------------------------------------------------------------------------- /pkg/modules/dnszones.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/go-ldap/ldap/v3" 7 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 8 | "github.com/spf13/pflag" 9 | ) 10 | 11 | type DnsZonesModule struct{} 12 | 13 | func init() { 14 | AllModules = append(AllModules, new(DnsZonesModule)) 15 | } 16 | 17 | func (d DnsZonesModule) Name() string { 18 | return "dns-zones" 19 | } 20 | 21 | func (d DnsZonesModule) Description() string { 22 | return "List all DNS Zones" 23 | } 24 | 25 | func (d DnsZonesModule) FlagSet() *pflag.FlagSet { 26 | flags := pflag.NewFlagSet(d.Name(), pflag.ExitOnError) 27 | return flags 28 | } 29 | 30 | func (d DnsZonesModule) DefaultAttrs() []string { 31 | return []string{"name"} 32 | } 33 | 34 | func (d DnsZonesModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 35 | locations := []string{"CN=MicrosoftDNS,DC=DomainDnsZones,%s", "CN=MicrosoftDNS,DC=ForestDnsZones,%s", "CN=MicrosoftDNS,CN=System,%s"} 36 | baseDN := session.BaseDN 37 | var requests []*ldap.SearchRequest 38 | for _, location := range locations { 39 | dn := fmt.Sprintf(location, baseDN) 40 | 41 | searchReq := session.MakeSearchRequestWithDN(dn, "(&(objectClass=dnsZone)(!name=RootDNSServers)(!name=*.in-addr.arpa)(!name=_msdcs.*)(!name=..TrustAnchors))", attrs) 42 | requests = append(requests, searchReq) 43 | } 44 | return session.ExecuteBulkSearchRequest(requests) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/modules/domainadmins.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 6 | "github.com/spf13/pflag" 7 | "strings" 8 | ) 9 | 10 | var DomainAdminGroups = []string{ 11 | "Domain Admins", 12 | "Domain-Admins", 13 | "Domain Administrators", 14 | "Domain-Administrators", 15 | "Domänen Admins", 16 | "Domänen-Admins", 17 | "Domain Admins", 18 | "Domain-Admins", 19 | "Domänen Administratoren", 20 | "Domänen-Administratoren", 21 | } 22 | 23 | type DAModule struct{} 24 | 25 | func init() { 26 | AllModules = append(AllModules, new(DAModule)) 27 | } 28 | 29 | func (DAModule) Name() string { 30 | return "domain-admins" 31 | } 32 | 33 | func (DAModule) Description() string { 34 | return "Recursively list all users objects in Domain Admins group" 35 | } 36 | 37 | func (DAModule) Filter(baseDN string) string { 38 | var sb strings.Builder 39 | sb.WriteString("(&(objectClass=user)(|") 40 | for _, group := range DomainAdminGroups { 41 | filter := fmt.Sprintf("(memberof:1.2.840.113556.1.4.1941:=CN=%s,CN=Users,%s)", group, baseDN) 42 | sb.WriteString(filter) 43 | } 44 | sb.WriteString("))") 45 | return sb.String() 46 | } 47 | 48 | func (DAModule) FlagSet() *pflag.FlagSet { 49 | flags := pflag.NewFlagSet("domain-admins", pflag.ExitOnError) 50 | return flags 51 | } 52 | 53 | func (DAModule) DefaultAttrs() []string { 54 | return []string{"cn", "sAMAccountName"} 55 | } 56 | 57 | func (d DAModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 58 | filter := d.Filter(session.BaseDN) 59 | searchReq := session.MakeSimpleSearchRequest(filter, attrs) 60 | return session.ExecuteSearchRequest(searchReq) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/modules/gpos.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 5 | "github.com/spf13/pflag" 6 | ) 7 | 8 | type GPOsModule struct{} 9 | 10 | func init() { 11 | AllModules = append(AllModules, new(GPOsModule)) 12 | } 13 | 14 | func (g GPOsModule) Name() string { 15 | return "gpos" 16 | } 17 | 18 | func (g GPOsModule) Description() string { 19 | return "Enumerate Group Policy Objects" 20 | } 21 | 22 | func (g *GPOsModule) FlagSet() *pflag.FlagSet { 23 | flags := pflag.NewFlagSet("gpos", pflag.ExitOnError) 24 | return flags 25 | } 26 | 27 | func (g GPOsModule) DefaultAttrs() []string { 28 | return []string{"displayName", "gPCFileSysPath"} 29 | } 30 | 31 | func (g GPOsModule) Filter() string { 32 | return "(objectClass=groupPolicyContainer)" 33 | } 34 | 35 | func (g *GPOsModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 36 | sr := session.MakeSimpleSearchRequest(g.Filter(), attrs) 37 | return session.ExecuteSearchRequest(sr) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/modules/groups.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 5 | "github.com/ropnop/go-windapsearch/pkg/utils" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type GroupsModule struct { 10 | SearchTerm string 11 | } 12 | 13 | func init() { 14 | AllModules = append(AllModules, new(GroupsModule)) 15 | } 16 | 17 | func (g *GroupsModule) Name() string { 18 | return "groups" 19 | } 20 | 21 | func (g *GroupsModule) Description() string { 22 | return "List all AD groups" 23 | } 24 | 25 | func (g *GroupsModule) Filter() string { 26 | filter := "(objectcategory=group)" 27 | if g.SearchTerm != "" { 28 | filter = utils.AddAndFilter(filter, utils.CreateANRSearch(g.SearchTerm)) 29 | } 30 | return filter 31 | } 32 | 33 | func (g *GroupsModule) FlagSet() *pflag.FlagSet { 34 | flags := pflag.NewFlagSet(g.Name(), pflag.ExitOnError) 35 | flags.StringVarP(&g.SearchTerm, "search", "s", "", "Search term to filter on") 36 | return flags 37 | } 38 | 39 | func (g *GroupsModule) DefaultAttrs() []string { 40 | return []string{"cn"} 41 | } 42 | 43 | func (g *GroupsModule) Run(lSession *ldapsession.LDAPSession, attrs []string) error { 44 | searchReq := lSession.MakeSimpleSearchRequest(g.Filter(), attrs) 45 | return lSession.ExecuteSearchRequest(searchReq) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/modules/members.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 6 | "github.com/ropnop/go-windapsearch/pkg/utils" 7 | "github.com/spf13/pflag" 8 | "os" 9 | ) 10 | 11 | type MembersModule struct { 12 | Recursive bool 13 | Search string 14 | DN string 15 | OnlyUsers bool 16 | } 17 | 18 | func init() { 19 | AllModules = append(AllModules, new(MembersModule)) 20 | } 21 | 22 | func (m MembersModule) Name() string { 23 | return "members" 24 | } 25 | 26 | func (m MembersModule) Description() string { 27 | return "Query for members of a group" 28 | } 29 | 30 | func (m *MembersModule) FlagSet() *pflag.FlagSet { 31 | flags := pflag.NewFlagSet("members-module", pflag.ExitOnError) 32 | flags.BoolVarP(&m.Recursive, "recursive", "r", false, "Perform recursive lookup") 33 | flags.StringVarP(&m.Search, "search", "s", "", "Search for group name") 34 | flags.StringVarP(&m.DN, "group", "g", "", "Full DN of group to enumerate") 35 | flags.BoolVar(&m.OnlyUsers, "users", false, "Only return user objects") 36 | return flags 37 | } 38 | 39 | func (m MembersModule) DefaultAttrs() []string { 40 | return []string{"cn", "sAMAccountName"} 41 | } 42 | 43 | func (m *MembersModule) ChooseGroup(session *ldapsession.LDAPSession) (dn string, err error) { 44 | filter := "(objectcategory=group)" 45 | filter = utils.AddAndFilter(filter, utils.CreateANRSearch(m.Search)) 46 | sr := session.MakeSimpleSearchRequest(filter, []string{}) 47 | matchResults, err := session.GetPagedSearchResults(sr) 48 | if err != nil { 49 | return 50 | } 51 | return utils.ChooseDN(matchResults) 52 | } 53 | 54 | func (m MembersModule) Filter() string { 55 | var filter string 56 | if m.Recursive { 57 | filter = fmt.Sprintf("(memberof:1.2.840.113556.1.4.1941:=%s)", m.DN) 58 | } else { 59 | filter = fmt.Sprintf("(memberOf=%s)", m.DN) 60 | } 61 | if m.OnlyUsers { 62 | filter = utils.AddAndFilter(filter, "(objectcategory=user)") 63 | } 64 | return filter 65 | } 66 | 67 | func (m *MembersModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 68 | if m.DN == "" && m.Search == "" { 69 | return fmt.Errorf("must provide a group or a search term") 70 | } 71 | if m.DN == "" { 72 | dn, err := m.ChooseGroup(session) 73 | if err != nil { 74 | return err 75 | } 76 | m.DN = dn 77 | fmt.Fprintf(os.Stderr, "[+] Using group: %s\n\n", m.DN) 78 | } 79 | sr := session.MakeSimpleSearchRequest(m.Filter(), attrs) 80 | return session.ExecuteSearchRequest(sr) 81 | 82 | } 83 | -------------------------------------------------------------------------------- /pkg/modules/metadata.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/go-ldap/ldap/v3" 5 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type FunctionalityModule struct{} 10 | 11 | func init() { 12 | AllModules = append(AllModules, new(FunctionalityModule)) 13 | } 14 | 15 | func (FunctionalityModule) Name() string { 16 | return "metadata" 17 | } 18 | 19 | func (FunctionalityModule) Description() string { 20 | return "Print LDAP server metadata" 21 | } 22 | 23 | func (FunctionalityModule) FlagSet() *pflag.FlagSet { 24 | return pflag.NewFlagSet("metadata", pflag.ExitOnError) 25 | } 26 | 27 | func (FunctionalityModule) DefaultAttrs() []string { 28 | return []string{ 29 | "defaultNamingContext", 30 | "domainFunctionality", 31 | "forestFunctionality", 32 | "domainControllerFunctionality", 33 | "dnsHostName", 34 | } 35 | } 36 | 37 | func (FunctionalityModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 38 | sr := ldap.NewSearchRequest( 39 | "", 40 | ldap.ScopeBaseObject, 41 | ldap.NeverDerefAliases, 42 | 0, 0, false, 43 | "(objectClass=*)", 44 | attrs, 45 | nil) 46 | //res, err := session.LConn.Search(sr) 47 | res, err := session.GetSearchResults(sr) 48 | if err != nil { 49 | return err 50 | } 51 | session.ManualWriteSearchResultsToChan(res) 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/modules/modules.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/adschema" 5 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type Module interface { 10 | Name() string 11 | Description() string 12 | FlagSet() *pflag.FlagSet 13 | DefaultAttrs() []string 14 | Run(session *ldapsession.LDAPSession, attrs []string) error 15 | } 16 | 17 | type ModuleWithDisplayFilter interface { 18 | //DisplayFilter is an optional function for a module used to filter what results get written to the output 19 | //it's best to try and filter the request, but when that's not possible, a custom function can be provided 20 | //that takes an LDAP entry and returns true/false (whether to display or not) 21 | DisplayFilter(e *adschema.ADEntry) bool 22 | } 23 | 24 | // Hacky way to have "DisplayFilter" be optional for the Module interface - if it exists, call it, if not, just return true 25 | func DisplayFilter(m Module, e *adschema.ADEntry) bool { 26 | if moduleWithDisplayFilter, ok := m.(ModuleWithDisplayFilter); ok { 27 | return moduleWithDisplayFilter.DisplayFilter(e) 28 | } else { 29 | return true 30 | } 31 | } 32 | 33 | var AllModules []Module 34 | -------------------------------------------------------------------------------- /pkg/modules/privileged_users.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 6 | "github.com/spf13/pflag" 7 | "strings" 8 | ) 9 | 10 | var PrivilegedGroups = append([]string{ 11 | "Administrators", // Builtin administrators group for the domain 12 | "Enterprise Admins", 13 | "Schema Admins", // Highly privileged builtin group 14 | "Account Operators", 15 | "Backup Operators", 16 | "Server Management", 17 | "Konten-Operatoren", 18 | "Sicherungs-Operatoren", 19 | "Server-Operatoren", 20 | "Schema-Admins", 21 | }, DomainAdminGroups...) 22 | 23 | type PrivilegedObjectsModule struct{} 24 | 25 | func init() { 26 | AllModules = append(AllModules, new(PrivilegedObjectsModule)) 27 | } 28 | 29 | func (p PrivilegedObjectsModule) Name() string { 30 | return "privileged-users" 31 | } 32 | 33 | func (p PrivilegedObjectsModule) Description() string { 34 | return "Recursively list members of all highly privileged groups" 35 | } 36 | 37 | func (p PrivilegedObjectsModule) FlagSet() *pflag.FlagSet { 38 | return pflag.NewFlagSet("privileged-objects", pflag.ExitOnError) 39 | } 40 | 41 | func (p PrivilegedObjectsModule) DefaultAttrs() []string { 42 | return []string{"cn", "sAMAccountName"} 43 | } 44 | 45 | func (PrivilegedObjectsModule) Filter(baseDN string) string { 46 | var sb strings.Builder 47 | sb.WriteString("(&(objectClass=user)(|") 48 | for _, group := range PrivilegedGroups { 49 | filter := fmt.Sprintf("(memberof:1.2.840.113556.1.4.1941:=CN=%s,CN=Users,%s)", group, baseDN) 50 | sb.WriteString(filter) 51 | } 52 | sb.WriteString("))") 53 | return sb.String() 54 | 55 | } 56 | 57 | func (p PrivilegedObjectsModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 58 | filter := p.Filter(session.BaseDN) 59 | searchReq := session.MakeSimpleSearchRequest(filter, attrs) 60 | return session.ExecuteSearchRequest(searchReq) 61 | } 62 | -------------------------------------------------------------------------------- /pkg/modules/search.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-ldap/ldap/v3" 6 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 7 | "github.com/ropnop/go-windapsearch/pkg/utils" 8 | "github.com/spf13/pflag" 9 | ) 10 | 11 | type SearchModule struct { 12 | SearchTerm string 13 | AllResults bool 14 | } 15 | 16 | func init() { 17 | AllModules = append(AllModules, new(SearchModule)) 18 | } 19 | 20 | func (s SearchModule) Name() string { 21 | return "search" 22 | } 23 | 24 | func (s SearchModule) Description() string { 25 | return "Perform an ANR Search and return the results" 26 | } 27 | 28 | func (s *SearchModule) FlagSet() *pflag.FlagSet { 29 | flags := pflag.NewFlagSet("search-mdoule", pflag.ExitOnError) 30 | flags.StringVarP(&s.SearchTerm, "search", "s", "", "Search term") 31 | flags.BoolVar(&s.AllResults, "all", false, "Output attrs for all matching search results") 32 | return flags 33 | } 34 | 35 | func (s SearchModule) DefaultAttrs() []string { 36 | return []string{"*"} 37 | } 38 | 39 | func (s *SearchModule) SearchFilter() string { 40 | return fmt.Sprintf("(%s)", utils.CreateANRSearch(s.SearchTerm)) 41 | } 42 | 43 | func (s *SearchModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 44 | if s.SearchTerm == "" { 45 | return fmt.Errorf("must include a search term") 46 | } 47 | if s.AllResults { 48 | sr := session.MakeSimpleSearchRequest(s.SearchFilter(), attrs) 49 | return session.ExecuteSearchRequest(sr) 50 | } 51 | searchRequest := session.MakeSimpleSearchRequest(s.SearchFilter(), []string{"distinguishedName"}) 52 | searchResults, err := session.GetPagedSearchResults(searchRequest) 53 | if err != nil { 54 | return err 55 | } 56 | dn, err := utils.ChooseDN(searchResults) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | sr := ldap.NewSearchRequest( 62 | dn, 63 | ldap.ScopeBaseObject, 64 | ldap.NeverDerefAliases, 65 | 0, 0, false, 66 | "(cn=*)", 67 | attrs, 68 | nil) 69 | return session.ExecuteSearchRequest(sr) 70 | 71 | } 72 | -------------------------------------------------------------------------------- /pkg/modules/unconstrained.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 5 | "github.com/ropnop/go-windapsearch/pkg/utils" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type UnconstrainedModule struct { 10 | Users bool 11 | Computers bool 12 | } 13 | 14 | func init() { 15 | AllModules = append(AllModules, new(UnconstrainedModule)) 16 | } 17 | 18 | func (u UnconstrainedModule) Name() string { 19 | return "unconstrained" 20 | } 21 | 22 | func (u UnconstrainedModule) Description() string { 23 | return "Find objects that allow unconstrained delegation" 24 | } 25 | 26 | func (u *UnconstrainedModule) FlagSet() *pflag.FlagSet { 27 | flags := pflag.NewFlagSet("unconstrained-module", pflag.ExitOnError) 28 | flags.BoolVar(&u.Users, "users", false, "Only show users") 29 | flags.BoolVar(&u.Computers, "computers", false, "Only show computers") 30 | return flags 31 | } 32 | 33 | func (u UnconstrainedModule) DefaultAttrs() []string { 34 | return []string{"cn", "sAMAccountName"} 35 | } 36 | 37 | func (u *UnconstrainedModule) Filter() string { 38 | filter := "(userAccountControl:1.2.840.113556.1.4.803:=524288)" 39 | if u.Users { 40 | usersFilter := utils.AddAndFilter("(objectClass=user)", "(objectCategory=user)") 41 | filter = utils.AddAndFilter(filter, usersFilter) 42 | } 43 | if u.Computers { 44 | compFilter := utils.AddAndFilter("(objectCategory=computer)", "(objectClass=computer)") 45 | filter = utils.AddAndFilter(filter, compFilter) 46 | } 47 | return filter 48 | } 49 | 50 | func (u *UnconstrainedModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 51 | sr := session.MakeSimpleSearchRequest(u.Filter(), attrs) 52 | return session.ExecuteSearchRequest(sr) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/modules/user-spns.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 5 | "github.com/spf13/pflag" 6 | ) 7 | 8 | type UserSPNsModule struct{} 9 | 10 | func init() { 11 | AllModules = append(AllModules, new(UserSPNsModule)) 12 | } 13 | 14 | func (u UserSPNsModule) Name() string { 15 | return "user-spns" 16 | } 17 | 18 | func (u UserSPNsModule) Description() string { 19 | return "Enumerate all users objects with Service Principal Names (for kerberoasting)" 20 | } 21 | 22 | func (u UserSPNsModule) FlagSet() *pflag.FlagSet { 23 | return pflag.NewFlagSet("user-spns", pflag.ExitOnError) 24 | } 25 | 26 | func (u UserSPNsModule) DefaultAttrs() []string { 27 | return []string{"cn", "sAMAccountName", "servicePrincipalName"} 28 | } 29 | 30 | func (u UserSPNsModule) Filter() string { 31 | return "(&(&(servicePrincipalName=*)(UserAccountControl:1.2.840.113556.1.4.803:=512))(!(UserAccountControl:1.2.840.113556.1.4.803:=2)))" 32 | 33 | } 34 | 35 | func (u UserSPNsModule) Run(session *ldapsession.LDAPSession, attrs []string) error { 36 | sr := session.MakeSimpleSearchRequest(u.Filter(), attrs) 37 | return session.ExecuteSearchRequest(sr) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/modules/users.go: -------------------------------------------------------------------------------- 1 | package modules 2 | 3 | import ( 4 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 5 | "github.com/ropnop/go-windapsearch/pkg/utils" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type UsersModule struct { 10 | ExtraFilter string 11 | SearchTerm string 12 | } 13 | 14 | func init() { 15 | AllModules = append(AllModules, new(UsersModule)) 16 | } 17 | 18 | func (u *UsersModule) Name() string { 19 | return "users" 20 | } 21 | 22 | func (u *UsersModule) Description() string { 23 | return "List all user objects" 24 | } 25 | 26 | func (u *UsersModule) Filter() string { 27 | filter := "(objectcategory=user)" 28 | if u.ExtraFilter != "" { 29 | //return fmt.Sprintf("(&%s(%s))", filter, u.ExtraFilter) 30 | filter = utils.AddAndFilter(filter, u.ExtraFilter) 31 | } 32 | if u.SearchTerm != "" { 33 | filter = utils.AddAndFilter(filter, utils.CreateANRSearch(u.SearchTerm)) 34 | } 35 | return filter 36 | 37 | } 38 | 39 | func (u *UsersModule) FlagSet() *pflag.FlagSet { 40 | flags := pflag.NewFlagSet(u.Name(), pflag.ExitOnError) 41 | flags.StringVar(&u.ExtraFilter, "filter", "", "Extra LDAP syntax filter to use") 42 | flags.StringVarP(&u.SearchTerm, "search", "s", "", "Search term to filter on") 43 | return flags 44 | } 45 | 46 | func (u *UsersModule) DefaultAttrs() []string { 47 | return []string{"cn", "sAMAccountName", "userPrincipalName"} 48 | } 49 | 50 | func (u *UsersModule) Run(lSession *ldapsession.LDAPSession, attrs []string) error { 51 | searchReq := lSession.MakeSimpleSearchRequest(u.Filter(), attrs) 52 | return lSession.ExecuteSearchRequest(searchReq) 53 | 54 | } 55 | -------------------------------------------------------------------------------- /pkg/utils/ldap_filters.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "fmt" 4 | 5 | func AddAndFilter(filter, extra string) string { 6 | return fmt.Sprintf("(&(%s)(%s))", filter, extra) 7 | } 8 | 9 | func AddOrFilter(filter, extra string) string { 10 | return fmt.Sprintf("(|(%s)(%s)", filter, extra) 11 | } 12 | 13 | func CreateANRSearch(search string) string { 14 | return fmt.Sprintf("anr=%s", search) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/utils/prompts.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "github.com/go-ldap/ldap/v3" 6 | "github.com/tcnksm/go-input" 7 | "golang.org/x/crypto/ssh/terminal" 8 | "os" 9 | "syscall" 10 | ) 11 | 12 | func SecurePrompt(message string) (response string, err error) { 13 | fmt.Fprintf(os.Stderr, "%s: ", message) 14 | securebytes, err := terminal.ReadPassword(int(syscall.Stdin)) 15 | if err != nil { 16 | return 17 | } 18 | fmt.Fprint(os.Stderr, "\n") 19 | return string(securebytes), nil 20 | } 21 | 22 | func ChooseDN(results *ldap.SearchResult) (dn string, err error) { 23 | var options []string 24 | for _, result := range results.Entries { 25 | options = append(options, result.DN) 26 | } 27 | if len(options) == 0 { 28 | return "", fmt.Errorf("no results") 29 | } 30 | if len(options) == 1 { 31 | return options[0], nil 32 | } 33 | ui := &input.UI{ 34 | Writer: os.Stderr, 35 | Reader: os.Stdin, 36 | } 37 | query := "What DN do you want to use?" 38 | return ui.Select(query, options, &input.Options{}) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/windapsearch/handleErrors.go: -------------------------------------------------------------------------------- 1 | package windapsearch 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func wrap(err error) error { 9 | if err == nil { 10 | return nil 11 | } 12 | if strings.Contains(err.Error(), "Invalid Credentials") { 13 | return fmt.Errorf("invalid Credentials") 14 | } 15 | if strings.Contains(err.Error(), "to perform this operation a successful bind must be completed") { 16 | return fmt.Errorf("a successful bind is required for this operation. Please provide valid credentials") 17 | } 18 | return err 19 | } 20 | -------------------------------------------------------------------------------- /pkg/windapsearch/results.go: -------------------------------------------------------------------------------- 1 | package windapsearch 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/ropnop/go-windapsearch/pkg/adschema" 6 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 7 | "github.com/ropnop/go-windapsearch/pkg/modules" 8 | "io" 9 | "sync" 10 | ) 11 | 12 | func (w *WindapSearchSession) outputWorker(input chan []byte, done chan struct{}) { 13 | w.Log.Debugf("outputWorker started") 14 | defer func() { 15 | if w.Options.JSON { 16 | io.WriteString(w.OutputWriter, "]") 17 | } 18 | // notify we're done writing by closing channel 19 | close(done) 20 | w.Log.Debugf("outputWorker closing, finished writing") 21 | }() 22 | 23 | entryDelimiter := "\n" 24 | if w.Options.JSON { 25 | entryDelimiter = "," 26 | io.WriteString(w.OutputWriter, "[") 27 | } 28 | firstEntry, ok := <-input 29 | if !ok { 30 | return 31 | } 32 | w.OutputWriter.Write(firstEntry) 33 | for b := range input { 34 | io.WriteString(w.OutputWriter, entryDelimiter) 35 | w.OutputWriter.Write(b) 36 | } 37 | } 38 | 39 | func (w *WindapSearchSession) searchResultWorker(chans *ldapsession.ResultChannels, out chan []byte, wg *sync.WaitGroup) { 40 | w.Log.Debugf("searchResultsWorker started") 41 | defer func() { 42 | w.Log.Debugf("searchResultsWorker closing") 43 | wg.Done() 44 | }() 45 | for { 46 | select { 47 | case entry, ok := <-chans.Entries: 48 | if !ok { 49 | return 50 | } 51 | w.Log.WithField("DN", entry.DN).Debug("parsing entry") 52 | e := &adschema.ADEntry{entry} 53 | if !w.Options.IgnoreDisplayFilters && !modules.DisplayFilter(w.Module, e) { 54 | w.Log.WithField("DN", e.DN).Debug("skipping entry due to module display filter") 55 | continue 56 | } 57 | if !w.Options.JSON { 58 | out <- []byte(e.LDAPFormat()) 59 | } else { 60 | b, err := json.Marshal(e) 61 | if err != nil { 62 | w.Log.WithField("DN", e.DN).Warn("error marshaling entry") 63 | } 64 | out <- b 65 | } 66 | // these do nothing, but we need have something receiving these channels, or else the program will freeze 67 | case <-chans.Referrals: 68 | case <-chans.Controls: 69 | continue 70 | } 71 | } 72 | } 73 | 74 | func (w *WindapSearchSession) runModule() error { 75 | var attrs []string 76 | if w.Options.FullAttributes { 77 | attrs = []string{"*"} 78 | } else { 79 | attrs = w.Options.Attributes 80 | } 81 | 82 | // Set up our write worker, used to write stuff to stdout or file 83 | // doneChan is used to indicate the module is completely done and results are written 84 | doneWriting := make(chan struct{}) 85 | outputChan := make(chan []byte) 86 | 87 | go w.outputWorker(outputChan, doneWriting) 88 | 89 | // set up our result workers, used to translate/marshal entries 90 | var wg sync.WaitGroup 91 | for i := 0; i < w.workers; i++ { 92 | wg.Add(1) 93 | go w.searchResultWorker(w.LDAPSession.Channels, outputChan, &wg) 94 | } 95 | 96 | err := w.Module.Run(w.LDAPSession, attrs) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | // wait for the search to be done and workers to finish 102 | wg.Wait() 103 | w.Log.Debug("waitgroup finished, all entry workers done") 104 | 105 | // when workers are done, nothing left to write 106 | close(outputChan) 107 | w.Log.Debug("output channel closed. waiting for writer to finish") 108 | 109 | <-doneWriting 110 | 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/windapsearch/windapsearch.go: -------------------------------------------------------------------------------- 1 | package windapsearch 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/signal" 10 | "strings" 11 | "text/tabwriter" 12 | 13 | "github.com/ropnop/go-windapsearch/pkg/buildinfo" 14 | "github.com/ropnop/go-windapsearch/pkg/ldapsession" 15 | "github.com/ropnop/go-windapsearch/pkg/modules" 16 | "github.com/ropnop/go-windapsearch/pkg/utils" 17 | "github.com/sirupsen/logrus" 18 | "github.com/spf13/pflag" 19 | ) 20 | 21 | type WindapSearchSession struct { 22 | Options CommandLineOptions 23 | LDAPSession *ldapsession.LDAPSession 24 | Module modules.Module 25 | AllModules []modules.Module 26 | Log *logrus.Entry 27 | OutputWriter io.Writer 28 | workers int 29 | ctx context.Context 30 | cancel context.CancelFunc 31 | } 32 | 33 | type CommandLineOptions struct { 34 | FlagSet *pflag.FlagSet 35 | Help bool 36 | Domain string 37 | DomainController string 38 | Username string 39 | BindDN string 40 | Password string 41 | NTLMHash string 42 | UseNTLM bool 43 | Port int 44 | Proxy string 45 | Secure bool 46 | ResolveHosts bool 47 | Attributes []string 48 | FullAttributes bool 49 | IgnoreDisplayFilters bool 50 | Output string 51 | JSON bool 52 | Module string 53 | Interactive bool 54 | Version bool 55 | Verbose bool 56 | Debug bool 57 | PageSize int 58 | ModuleFlags *pflag.FlagSet 59 | } 60 | 61 | func NewSession() *WindapSearchSession { 62 | var w WindapSearchSession 63 | 64 | wFlags := pflag.NewFlagSet("WindapSearch", pflag.ContinueOnError) 65 | wFlags.SortFlags = false 66 | wFlags.StringVarP(&w.Options.Domain, "domain", "d", "", "The FQDN of the domain (e.g. 'lab.example.com'). Only needed if dc not provided") 67 | wFlags.StringVar(&w.Options.DomainController, "dc", "", "The Domain Controller to query against") 68 | wFlags.StringVarP(&w.Options.Username, "username", "u", "", "The full username with domain to bind with (e.g. 'ropnop@lab.example.com' or 'LAB\\ropnop')\n If not specified, will attempt anonymous bind") 69 | wFlags.StringVar(&w.Options.BindDN, "bindDN", "", "Full DN to use to bind (as opposed to -u for just username)\n e.g. cn=rflathers,ou=users,dc=example,dc=com") 70 | wFlags.StringVarP(&w.Options.Password, "password", "p", "", "Password to use. If not specified, will be prompted for") 71 | wFlags.StringVar(&w.Options.NTLMHash, "hash", "", "NTLM Hash to use instead of password (i.e. pass-the-hash)") 72 | wFlags.BoolVar(&w.Options.UseNTLM, "ntlm", false, "Use NTLM auth (automatic if hash is set)") 73 | wFlags.IntVar(&w.Options.Port, "port", 0, "Port to connect to (if non standard)") 74 | wFlags.BoolVar(&w.Options.Secure, "secure", false, "Use LDAPS. This will not verify TLS certs, however. (default: false)") 75 | wFlags.StringVar(&w.Options.Proxy, "proxy", "", "SOCKS5 Proxy to use (e.g. 127.0.0.1:9050)") 76 | wFlags.BoolVar(&w.Options.FullAttributes, "full", false, "Output all attributes from LDAP") 77 | wFlags.BoolVar(&w.Options.IgnoreDisplayFilters, "ignore-display-filters", false, "Ignore any display filters set by the module and always output every entry") 78 | wFlags.StringVarP(&w.Options.Output, "output", "o", "", "Save results to file") 79 | wFlags.BoolVarP(&w.Options.JSON, "json", "j", false, "Convert LDAP output to JSON") 80 | wFlags.IntVar(&w.Options.PageSize, "page-size", 1000, "LDAP page size to use") 81 | //wFlags.BoolVarP(&w.Options.Interactive, "interactive", "i", false, "Start in interactive mode") //TODO 82 | wFlags.BoolVar(&w.Options.Version, "version", false, "Show version info and exit") 83 | wFlags.BoolVarP(&w.Options.Verbose, "verbose", "v", false, "Show info logs") 84 | wFlags.BoolVar(&w.Options.Debug, "debug", false, "Show debug logs") 85 | wFlags.BoolVarP(&w.Options.Help, "help", "h", false, "Show this help") 86 | 87 | pflag.ErrHelp = errors.New("") 88 | wFlags.Usage = w.ShowUsage 89 | 90 | for _, m := range modules.AllModules { 91 | w.RegisterModule(m) 92 | } 93 | 94 | wFlags.StringVarP(&w.Options.Module, "module", "m", "", "Module to use") 95 | 96 | w.Options.FlagSet = wFlags 97 | 98 | w.OutputWriter = os.Stdout //default to stdout 99 | w.workers = 5 //concurrent workers for marshaling entries. 5 seems reasonable 100 | 101 | logger := logrus.New() 102 | 103 | logger.Out = os.Stderr // default log to stderr 104 | logger.SetLevel(logrus.ErrorLevel) 105 | logger.SetFormatter(&logrus.TextFormatter{ 106 | FullTimestamp: true, 107 | DisableLevelTruncation: true, 108 | }) 109 | w.Log = logger.WithFields(logrus.Fields{"package": "windapsearch"}) 110 | 111 | return &w 112 | } 113 | 114 | func (w *WindapSearchSession) handleInterrupt() { 115 | // set up cancelling, catch SIGINT 116 | w.ctx, w.cancel = context.WithCancel(context.Background()) 117 | c := make(chan os.Signal, 1) 118 | signal.Notify(c, os.Interrupt) 119 | go func() { 120 | <-c 121 | w.cancel() 122 | }() 123 | } 124 | 125 | func (w *WindapSearchSession) RegisterModule(mod modules.Module) { 126 | w.AllModules = append(w.AllModules, mod) 127 | } 128 | 129 | func (w *WindapSearchSession) LoadModule() { 130 | mod := w.GetModuleByName(w.Options.Module) 131 | if mod != nil { 132 | w.Module = mod 133 | w.Options.ModuleFlags = mod.FlagSet() 134 | w.Options.ModuleFlags.StringSliceVar(&w.Options.Attributes, "attrs", mod.DefaultAttrs(), "Comma separated custom atrributes to display") 135 | } 136 | } 137 | 138 | func (w *WindapSearchSession) ModuleListString() string { 139 | var sb strings.Builder 140 | for _, mod := range w.AllModules { 141 | sb.WriteString(mod.Name()) 142 | sb.WriteString(", ") 143 | } 144 | listString := sb.String() 145 | return strings.TrimSuffix(listString, ", ") 146 | } 147 | 148 | func (w *WindapSearchSession) ModuleDescriptionString() string { 149 | sb := &strings.Builder{} 150 | tw := tabwriter.NewWriter(sb, 0, 0, 4, ' ', 0) 151 | for _, mod := range w.AllModules { 152 | fmt.Fprintf(tw, "\t%s\t%s\n", mod.Name(), mod.Description()) 153 | } 154 | tw.Flush() 155 | return sb.String() 156 | 157 | } 158 | 159 | func (w *WindapSearchSession) GetModuleByName(name string) modules.Module { 160 | for _, m := range w.AllModules { 161 | if m.Name() == name { 162 | return m 163 | } 164 | } 165 | return nil 166 | } 167 | 168 | func (w *WindapSearchSession) ShowUsage() { 169 | fmt.Fprintf(os.Stderr, "windapsearch: a tool to perform Windows domain enumeration through LDAP queries\n%s\nUsage: %s [options] -m [module] [module options]\n\nOptions:\n", buildinfo.FormatVersionString(), os.Args[0]) 170 | w.Options.FlagSet.PrintDefaults() 171 | if w.Module == nil { 172 | fmt.Fprintf(os.Stderr, "\nAvailable modules:\n%s", w.ModuleDescriptionString()) 173 | } else { 174 | fmt.Fprintf(os.Stderr, "\nOptions for %q module:\n", w.Module.Name()) 175 | w.Options.ModuleFlags.PrintDefaults() 176 | } 177 | } 178 | 179 | func (w *WindapSearchSession) Run() (err error) { 180 | defer func() { 181 | err = wrap(err) 182 | }() 183 | 184 | w.Options.FlagSet.Parse(os.Args[:]) 185 | 186 | w.LoadModule() 187 | 188 | //w.Options.ModuleFlags.AddFlagSet(w.Options.FlagSet) 189 | w.Options.FlagSet.AddFlagSet(w.Options.ModuleFlags) 190 | w.Options.FlagSet.Parse(os.Args[:]) 191 | 192 | if w.Options.Help { 193 | w.ShowUsage() 194 | return 195 | } 196 | 197 | if w.Options.Version { 198 | fmt.Println(buildinfo.FormatVersionString()) 199 | return 200 | } 201 | 202 | if w.Options.Verbose { 203 | w.Log.Logger.SetLevel(logrus.InfoLevel) 204 | } 205 | if w.Options.Debug { 206 | w.Log.Logger.SetLevel(logrus.DebugLevel) 207 | } 208 | 209 | if w.Options.Output != "" { 210 | fp, err2 := os.Create(w.Options.Output) 211 | if err2 != nil { 212 | err = err2 213 | return 214 | } 215 | w.OutputWriter = fp 216 | defer fp.Close() 217 | w.Log.Infof("Saving output to %q", fp.Name()) 218 | } else { 219 | w.Log.Infof("Saving output to STDOUT") 220 | } 221 | 222 | if w.Options.Domain == "" && w.Options.DomainController == "" { 223 | w.ShowUsage() 224 | fmt.Fprintf(os.Stderr, "\n[!] You must specify either a domain or an IP address of a domain controller\n") 225 | return 226 | } 227 | password := w.Options.Password 228 | var username string 229 | if w.Options.BindDN != "" { 230 | username = w.Options.BindDN 231 | } else { 232 | username = w.Options.Username 233 | } 234 | 235 | if w.Options.UseNTLM && username == "" { 236 | return fmt.Errorf("must provide username for NTLM authentication") 237 | } 238 | 239 | if username != "" { // only prompt for password if username is provided 240 | if len(strings.Split(w.Options.Username, "@")) == 1 && w.Options.BindDN == "" { 241 | username = fmt.Sprintf("%s@%s", w.Options.Username, w.Options.Domain) 242 | } 243 | if username != "" && password == "" && w.Options.NTLMHash == "" { 244 | password, err = utils.SecurePrompt(fmt.Sprintf("Password for [%s]", username)) 245 | if err != nil { 246 | return err 247 | } 248 | } 249 | } 250 | 251 | // now that ldap connections are opened, handle interrupts gracefully 252 | w.handleInterrupt() 253 | 254 | ldapOptions := ldapsession.LDAPSessionOptions{ 255 | Domain: w.Options.Domain, 256 | DomainController: w.Options.DomainController, 257 | Username: username, 258 | Password: password, 259 | Hash: w.Options.NTLMHash, 260 | UseNTLM: w.Options.UseNTLM, 261 | Port: w.Options.Port, 262 | Proxy: w.Options.Proxy, 263 | Secure: w.Options.Secure, 264 | PageSize: w.Options.PageSize, 265 | Logger: w.Log.Logger, 266 | } 267 | 268 | w.LDAPSession, err = ldapsession.NewLDAPSession(&ldapOptions, w.ctx) 269 | if err != nil { 270 | return 271 | } 272 | defer w.LDAPSession.Close() 273 | 274 | if w.Options.Interactive { 275 | return w.StartTUI() 276 | } else { 277 | return w.StartCLI() 278 | } 279 | } 280 | 281 | func (w *WindapSearchSession) StartCLI() error { 282 | if w.Module == nil { 283 | fmt.Fprintf(os.Stderr, "[!] You must specify a valid module to use\n") 284 | fmt.Fprintf(os.Stderr, " Available modules: \n%s", w.ModuleDescriptionString()) 285 | return nil 286 | } 287 | err := w.runModule() 288 | if err != nil { 289 | return err 290 | } 291 | if w.Options.Output != "" { 292 | fmt.Printf("[+] %s written\n", w.Options.Output) 293 | } 294 | return nil 295 | } 296 | 297 | func (w *WindapSearchSession) StartTUI() error { 298 | return nil 299 | } 300 | -------------------------------------------------------------------------------- /tools/scrapeAttributesFromMS.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import requests 4 | import sys 5 | import json 6 | from bs4 import BeautifulSoup 7 | 8 | WIN32_ATTRIBUTES_PAGE = "https://docs.microsoft.com/en-us/windows/win32/adschema/" 9 | 10 | ATTRIBUTES = [] 11 | TOTAL = 0 12 | COUNTER = 0 13 | 14 | 15 | def run(filename): 16 | global TOTAL 17 | global COUNTER 18 | w32page = requests.get(WIN32_ATTRIBUTES_PAGE+"/attributes-all") 19 | soup = BeautifulSoup(w32page.content, 'html.parser') 20 | datalinks = soup.find_all('dl')[0] 21 | hrefs = datalinks.find_all('a') 22 | TOTAL = len(hrefs) 23 | print("[+] Found {} attributes...".format(TOTAL)) 24 | 25 | for href in hrefs: 26 | attributelink = WIN32_ATTRIBUTES_PAGE + "/" + href.attrs['href'] 27 | getAttributeInfo(attributelink) 28 | 29 | with open(filename, "w") as fp: 30 | fp.write(json.dumps(ATTRIBUTES)) 31 | print("{} written".format(filename)) 32 | 33 | 34 | 35 | def getAttributeInfo(link): 36 | global TOTAL 37 | global COUNTER 38 | COUNTER += 1 39 | attr = dict() 40 | attrPage = requests.get(link) 41 | if attrPage.status_code != 200: 42 | print("\t[!] Error fetching page: {}".format(link)) 43 | return 44 | soup = BeautifulSoup(attrPage.content, 'html.parser') 45 | 46 | tag = soup.find("td", string="Ldap-Display-Name") 47 | if tag is None: 48 | print("\t[!] Didn't find 'Ldap-Display-Name' on {}".format(link)) 49 | return 50 | 51 | infoTable = tag.find_parents('table') 52 | if len(infoTable) != 1: 53 | print("\t[!] Didn't find table on {}".format(link)) 54 | return 55 | 56 | for row in infoTable[0].find_all('tr'): 57 | tds = row.find_all('td') 58 | if len(tds) != 2: 59 | continue 60 | name = tds[0].getText() 61 | value = tds[1].getText() 62 | attr[name] = value 63 | isSingleTags = soup.find_all("td", string="Is-Single-Valued") 64 | 65 | if len(isSingleTags) != 0: 66 | latest = isSingleTags[-1] 67 | row = latest.parent 68 | tds = row.find_all('td') 69 | if len(tds) == 2: 70 | attr["Is-Single-Valued"] = tds[1].getText() == 'True' 71 | import ipdb; ipdb.set_trace() 72 | ATTRIBUTES.append(attr) 73 | print("[+] ({}/{}) Fetched {}".format(COUNTER, TOTAL, attr.get('Ldap-Display-Name', "Unknown"))) 74 | 75 | 76 | 77 | if __name__ == "__main__": 78 | if len(sys.argv) != 2: 79 | print("[+] Usage: {} output.json".format(sys.argv[0])) 80 | sys.exit(1) 81 | filename = sys.argv[1] 82 | run(filename) --------------------------------------------------------------------------------