Skip to content

SQL Browser detection for MSSQL protocol#733

Closed
mrsheepsheep wants to merge 12 commits into
Pennyw0rth:mainfrom
mrsheepsheep:main
Closed

SQL Browser detection for MSSQL protocol#733
mrsheepsheep wants to merge 12 commits into
Pennyw0rth:mainfrom
mrsheepsheep:main

Conversation

@mrsheepsheep
Copy link
Copy Markdown

Description

Adds support for SQL Browser detection and automatic fallback to the dynamic SQL server port if only one instance is running.

This slows down the scan a bit but can detect MSSQL servers without a detailed port scan on each

I chose to add SQL Browser detection by default and added a blacklit flag --no-sqlbrowser, but it can easily be changed to --sqlbrowser if it's more appropriate.

When using --port with a non-default port, the fallback will be ignored. When multiple instances are detected, no fallback happens.

We could also remove the port fallback part and simply report it and let the user rerun with --port. Let me know what's best.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update
  • This requires a third party update (such as Impacket, Dploot, lsassy, etc)

Setup guide for the review

OS : Windows Server 2022 with SQL Server Express installed

Expose the SQL server to the network : Open SQL Server Network Configuration > Protocols for INSTANCENAME

  • Click TCP/IP protocol
  • Set Enabled to true and ensure Listen All is set to Yes

Enable the SQL Browser service : Open SQL Server Services

  • Right-click SQL Server Browser > Properties > Service : Start-Mode = Automatic
  • Start the service

Disable the Windows Firewall if you're lazy (SQL Server rules are not added by default, this is for testing purposes only).

Restart the SQL Server service.

Note down the dynamic port used by SQL server :

image

You can now run a set of tests targeted at a single SQL server instance.

To add more instances, run SQL Server 2022 Installation Center, Installation, click New SQL Server Standalone installation or..., select This PC > C: > SQL2022 > Express_ENU and follow instructions. Once the instance is installed, repeat the steps above for the new instance.

Screenshots

Running on a /21 without --no-sqlbrowser (slower)

image

Running on a /21 with --no-sqlbrowser (faster) yields no results

image

The fallback works as expected when running other modules like command execution :

image

--port has priority and works as expected

image

image

Multiple instances setup

image

Checklist:

  • I have ran Ruff against my changes (via poetry: poetry run python -m ruff check . --preview, use --fix to automatically fix what it can)
  • I have added or updated the tests/e2e_commands.txt file if necessary
  • New and existing e2e tests pass locally with my changes
  • All tests related to MSSQL pass, except --rid-brute because test server was not domain-joined
  • If reliant on changes of third party dependencies, such as Impacket, dploot, lsassy, etc, I have linked the relevant PRs in those projects
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (PR here: https://github.com/Pennyw0rth/NetExec-Wiki)
    • Waiting for approval on the args / automatic fallback before sending PR

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Jun 12, 2025

Man that is a fantastic update! I wasn't aware of this MSSQL Browser thing!!

@mrsheepsheep
Copy link
Copy Markdown
Author

mrsheepsheep commented Jun 12, 2025

Man that is a fantastic update! I wasn't aware of this MSSQL Browser thing!!

Thanks ! SQL Browser is not enabled by default and requires a bit of setup, and I haven't been able to test it outside of the lab yet to assess the efficiency of scanning an additional UDP port by default.

I usually blind spray mssql for initial access, and I'm sure I missed some instances because I didn't know about the dynamic port until yesterday...

The SQL server can still run on a random port even if the SQL browser is disabled, so it's not a 100% guaranteed hit, but it will certainly improve automatic discovery.

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Jun 12, 2025

Yeah don't worry about the testing, I'm actually finishing integrating encryption and channel binding on Impacket, and then on NXC, myself so I have got a lab on which I can test your PR as well! Will look ASAP :)

@NeffIsBack
Copy link
Copy Markdown
Member

Very interesting, thanks for the PR! Do you have any resources about that topic that you could share?

@NeffIsBack NeffIsBack added the enhancement New feature or request label Jun 12, 2025
Comment thread nxc/protocols/mssql.py Outdated
@mrsheepsheep
Copy link
Copy Markdown
Author

Very interesting, thanks for the PR! Do you have any resources about that topic that you could share?

Not so much, I just learned about it 😅

SQL Browser uses the TDS protocol on UDP 1434 : https://learn.microsoft.com/en-us/sql/relational-databases/security/networking/tds-8?view=sql-server-ver17

So in reality, the protocol is TDS. Yet MS states it uses SQL Server Resolution Protocol (SSRP)... Confusing.

Impacket already implements TDS behind the scenes. I'm unsure if there's more setup required for encrypted connections or if it works out of the box.

Instances can be hidden from SQL Browser, meaning the service can run but not list any instances. There's probably a way to try catch getInstances to detect whether the service actually runs THEN enumerating instances, rather than only relying on the array size. But I don't think that makes a huge operational difference.

MSDOCS for SQL Browser : https://learn.microsoft.com/en-us/sql/tools/configuration-manager/sql-server-browser-service?view=sql-server-ver16

I'll see what I can do to move the detection to enum_host_info !

@mrsheepsheep
Copy link
Copy Markdown
Author

Oh wait there's something about DAC that we could definitely look into : https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/diagnostic-connection-for-database-administrators?view=sql-server-ver16

It runs on the same 1434 port and potentially gives access to the database even when it's not directly exposed.

I'll try to look into it later today.

@mrsheepsheep
Copy link
Copy Markdown
Author

It's getting more complicated than I thought !

SQL browser also lists named-pipe SQL instances ! (which is great)

For now I'll only fallback to TCP until I understand how we can fallback to named pipes without breaking other modules.

@mrsheepsheep
Copy link
Copy Markdown
Author

image

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Jun 30, 2025

Hey man! I think I missed something in the SQL browser configuration cause I can't seem to have a dynamic port here:

image

Althought I have followed you setup instructions. Is there something else you might have configured ?

@mrsheepsheep
Copy link
Copy Markdown
Author

Hey man! I think I missed something in the SQL browser configuration cause I can't seem to have a dynamic port here:

image

Althought I have followed you setup instructions. Is there something else you might have configured ?

Hey ;)
Remove the static port value from IPAll and restart the service, that should be enough.

Leaving this here just in case :
https://learn.microsoft.com/en-us/sql/tools/configuration-manager/tcp-ip-properties-ip-addresses-tab?view=sql-server-ver17#static-vs-dynamic-ports

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Sep 28, 2025

I'll be looking into this one that week mate! Sorry for the long delay but I had to implement the CBT thing before ahah!

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Sep 30, 2025

There is something I don't get:

image

Which is produced by that code:

# Get number of mssql instance
self.mssql_instances = self.conn.getInstances()
print(self.mssql_instances)

You mentionned there is another way of finding the correct value of instances ?

@mrsheepsheep
Copy link
Copy Markdown
Author

There is something I don't get:

image Which is produced by that code:
# Get number of mssql instance
self.mssql_instances = self.conn.getInstances()
print(self.mssql_instances)

You mentionned there is another way of finding the correct value of instances ?

Sorry for the delay, I haven't checked my GitHub inbox in a while.

There's no output when you don't specify any --port, that's expected when the SQL browser service is not detected. The service is not mandatory to use dynamic ports, it's just here to advertise.

Is the SQL browser service actually running ?

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Dec 24, 2025

Hey man! So I'm back at this PR which I will work on. Thing is there are a lot of stuff I gotta catch up because I have got limited knowledges about MSSQL instances. I'll dig that and let you know about my progress ;)!

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Dec 26, 2025

I have been playing with that PR and realised something quite important: the sql browser UDP port is reachable via an anonymous authentication:

image

https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/sql-server-browser-service-database-engine-and-ssas?view=sql-server-ver17

That means that we can modify that PR so that it:

  • Tries to connect to the UDP port (with a low timeout) ;
  • Prints whether or not the SQL browser is up or not.

From these information, we can implement a --list-instances option that allows browsing the SQL browser endpoint, retrieving the entire intances as well as their configurations and then build whatever is necessary so that NXC/impacket can talk to both a TCP port and a named pipe.

So far I have got the following banner:

image

That I computed based on the enum_host_info function to which I added the following code:

if len(self.conn.getInstances(2)) > 0:
    self.sqlbrowser_enabled = True

EDIT: I have added the function absed on your PR:

def list_instances(self):
        if self.sqlbrowser_enabled is False:
            self.logger.fail("MSSQL browser is not enabled, cannot enumerate...")
            return
        
        self.logger.display("Enumerating MSSQL browser")
        if len(self.mssql_instances) > 0:

            # Get information about instances
            for index, instance in enumerate(self.mssql_instances):
                instance_name = instance.get("InstanceName")
                instance_port = instance.get("tcp", None)
                instance_np = instance.get("np", None)
                instance_version = instance.get("Version", None)
                self.logger.success(f"#{index} {instance_name} (port:{instance_port}) (np:{instance_np}) (version:{instance_version})")
              
        else:
            self.logger.fail("No instance to enumerate")

And here is the output:

image

So the final one would be something like this:

image

Finally I hacked the create_conn_obj so that even if the default MSSQL instance is not listening, it will try to enumerate the SQL browser if found:

def create_conn_obj(self):
        try:
            # Connects to default port or --port 
            self.conn = tds.MSSQL(self.host, self.port, self.remoteName)            
            self.conn.connect(self.args.mssql_timeout)

        except Exception as e:
            self.logger.debug(f"Error connecting to MSSQL service on host: {self.host}, reason: {e}")
            self.logger.display("Trying to enumerate SQL browser")
            
            self.mssql_instances = self.conn.getInstances(2)
            if len(self.mssql_instances) > 0:
                self.sqlbrowser_enabled = True
                self.list_instances()

            with contextlib.suppress(Exception):
                self.conn.disconnect()
            return False
        else:
            self.is_mssql = True
            return True
image

Kinda strange output but that could be really useful!

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Dec 29, 2025

@mrsheepsheep any thoughts on that ?

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Mar 26, 2026

Hey @mrsheepsheep any news ? :P

@mrsheepsheep
Copy link
Copy Markdown
Author

mrsheepsheep commented Mar 28, 2026 via email

@mrsheepsheep
Copy link
Copy Markdown
Author

The SqlBrowser: true banner looks nice, and I like the --list-instances flag as well !

@Dfte I've sent you an invite so that you can commit the PR as well (I think ?)

My thought right now is whether we should do SQL browser enumeration by default (especially when no SQL server was found), or shift this ability to the --list-instances flag and ignore SQL Browser by default... We should try to stick with the usual output style, that is, no ouput if not found.

So to recap, I imagine it like this :

  • If MSSQL is running, scan sqlbrowser and report in banner
  • If --list-instances
    • Always scan sqlbrowser in this situation and list instances
    • Output "no sqlbrowser" if server had MSSQL running on 1433
    • No output if neither MSSQL or SQL Browser is running, keeping the output clean.

Then once we find a way to interact with the named pipes instances, we can just add a --instance to intercat directly with it, and a --np to also manually specify the namedpipe.

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Apr 2, 2026

Looks good to me! I'll try to merge that on your branch ASAP :)!

@mrsheepsheep
Copy link
Copy Markdown
Author

mrsheepsheep commented Apr 2, 2026

After a bit of testing, I ended up with a single --browser argument that handles both enumeration and instance selection.

The default enumeration behavior is unchanged :

image

Adding --browser on targets where SQL browser is not running yields no result at all. The philosophy is that adding --browser should only target enumerated instances, not those on port 1433 (unless it's listed in the enumeration, of course).

I decided not to put the SQL browser information within enum_host_info, as obtaining this information requires an additional port scan and it adds up to 2 seconds of latency against hosts where it's not enabled (because of UDP), as you can see :

image

If it is running, it connects to all instances by default as if they were individual servers :

image

It's also possible to specify an instance index :

image

Named pipes are not supported by impacket yet, but we should be able to add a --np argument to manually connect to a named pipe instance, and enforce np connection if the instance supports it and both --browser and --np were specified.

image

Other actions such as logging in should behave as expected on all instances specified by --browser :

image

(Ignore the incoherent logging on the first screenshots, I added the custom logger later)


Regarding the actual implementation, it's still a bit of hack because we need to deal with a completely different protocol and we don't need a complete connection to proceed.

  1. Modified __init__ to identify whether we're using --browser and if so, trigger a dummy connection on port 0 to properly run connection.__init__ and populate the object.
  2. Create a new dedicated NXCAdapter logger
  3. After that, run self.discover_sqlbrowser which uses a dedicated connection instance with its own decorator to handle reconnections (I'm not entirely sure we need this actually).
    def __init__(self, args, db, host):
        # ...
        self.is_mssql = False
        self.sqlbrowser_enabled = False

         # --browser
        if args.browser:
            args.port = 0 # Override port number with dummy one
            connection.__init__(self, args, db, host)
            self.discover_sqlbrowser()

            if args.browser == "all":
                for instance in self.mssql_instances:
                    self.instance_connect(instance)
            else:
                try:
                    index = int(args.browser)
                    instance = self.mssql_instances[index]
                    self.instance_connect(instance)
                except ValueError:
                    self.logger.fail("Instance argument must be an integer index or 'all'")
                    return
        else:
            connection.__init__(self, args, db, host)
    def discover_sqlbrowser(self):
        self.sqlbrowser_conn = tds.MSSQL(self.host, 0, self.remoteName)
        # No need to start the connection, we just need the tds object
        
        # Ignore broadcast targets (UDP)
        if self.host.endswith(".255"):
            self.sqlbrowser_logger.debug("Target is a broadcast address, skipping SQL browser enumeration")
            self.sqlbrowser_enabled = False
        else:
            self.sqlbrowser_logger.debug('Listing SQL browser instances')
            self.mssql_instances = self.sqlbrowser_conn.getInstances(2)
            if len(self.mssql_instances) > 0:
                self.sqlbrowser_enabled = True
                self.sqlbrowser_logger.success("SQL browser is enabled.")
                for index, instance in enumerate(self.mssql_instances):
                    if self.args.browser == "all" or self.args.browser.isdigit() and int(self.args.browser) == index:
                        self.log_instance(index, instance)
            else:
                self.sqlbrowser_enabled = False
        
        self.sqlbrowser_conn.disconnect()

        return self.sqlbrowser_enabled
  1. Instanciate a dedicated TDS object without an actual connection just for the purpose of doing a browser scan on the host
  2. Skip UDP broadcast hosts (.255)
  3. After instances are enumerated, call instance_connect on the chosen instance (or all of them), which triggers a self.__init__ (I have not found a better way). Override args to specify the new port to connect to, disable browser enumeration.
    @reconnect_sqlbrowser
    def instance_connect(self, instance):
        self.sqlbrowser_logger.debug(f'instance_connect to {instance}')
        instance_name = instance.get("InstanceName", None)
        instance_port = instance.get("tcp", None)
        instance_np = instance.get("np", None)
        instance_arguments = self.args
        # Drop instances and list-instances arguments
        instance_arguments.instance = None
        instance_arguments.browser = False
        # Case #1, instance is listening on a port
        if instance_port:
            instance_arguments.port = instance_port
            new_connection = self.__init__(instance_arguments, self.db, self.host)
        # Case #2, instance is listening on a named pipe
        elif instance_np:
            self.sqlbrowser_logger.fail(f"Named pipe connections are not supported yet, cannot connect to {instance_np}")
            pass
  1. create_conn_object has been rolled back to its original implementation, as each instance is handled individually by the protocol (are there any side effects though ?)

At some point I thought about moving all of this to a dedicated module, but the current NXC module lifecycle only triggers upon successful protocol connection, which in our case is not required.

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Apr 9, 2026

Alright so I have been thinking about all of that. My guess is that indeed the UDP timeout is an important factor but not forcing it will make people forget about that sql browser feature and miss huge opportunities.

We can may be combine both worlds adding a nxc configuration option that says whether or not we launch the UDP discovery thing ? That's really something I would like to have as a default behaviour

@NeffIsBack @azoxlpf I need some feedbacks there ahahah

@mrsheepsheep
Copy link
Copy Markdown
Author

I was also thinking about a dirty way to suggest SQL browser existence without forcing it by default : re-add the SQL browser inside the banner, but mark it as Unknown until it's actually scanned. Future scans could ask the DB for the last known browser status and display the number of instances.

It's unusual to interact with the DB this way but it could be a good mix to suggest the ability to enumerate SQL browser while keeping it off by default ?

@mrsheepsheep
Copy link
Copy Markdown
Author

mrsheepsheep commented Apr 9, 2026

Off topic but I'm now thinking about a --cache switch like we have in ldeep, which simulates nxc enumeration from database 🫢

@azoxlpf
Copy link
Copy Markdown
Contributor

azoxlpf commented Apr 9, 2026

I’m broadly aligned with combining both approaches via a nxc.conf option. The ~2s UDP timeout per host is too painful for large-range scans as the default, but it’s a shame to miss instances on targeted runs.

Proposal :

  • Add sqlbrowser = false under [MSSQL] in nxc.conf (default off).
  • --browser on the CLI always overrides the config.
  • Power users can turn it on once in config and keep it.

On the implementation side, re-calling self.__init__() from instance_connect feels fragile. It would be safer to spin up new connection instances for each discovered SQL instance instead

@NeffIsBack
Copy link
Copy Markdown
Member

NeffIsBack commented Apr 10, 2026

Alright so I have been thinking about all of that. My guess is that indeed the UDP timeout is an important factor but not forcing it will make people forget about that sql browser feature and miss huge opportunities.

I think I lean more to this side. Yes, scanning MSSQL with netexec is a thing, but honestly it is not designed to be a fast network scanner, but rather a tool that automates as much as possible with minimum required knowledge/effort about the exact details of what is happening. I agree that having an additional 2s of scanning time per host is bad, but with the trade off of missing instances (which seems like we pretty much all did until now), I would rather take the hit on scanning speed. Imo you should scan with nmap/nessus anyway, and then connect to discovered hosts (yes, that still has the problem of potentially missing the mssql browser, but my assumption would be that in most cases IF there is a browser we also have at least one DB running on the default port right?).

We could still add a config option to disable that discovery if people prefer speed over usability, but imo that should be set to "discover_sql_browser = True" (or however you would call it) per default.

Two technical details:

  • Is there a need to reestablish a connection? From what i can tell getInstances already creates a new socket and does not alter anything of the existing connection, so we could just call that on the existing protocol object without all of the other logic overhead.
  • We should probably use self.args.mssql_timeout for the browser discovery as well and set the default to 2s, so people can further fine tune there scanning speeds.

@mrsheepsheep
Copy link
Copy Markdown
Author

mrsheepsheep commented Apr 10, 2026

IF there is a browser we also have at least one DB running on the default port right?

Actually, SQL browser is most likely to be enabled with SQL servers not running on the default port ! Also consider named pipe servers which may not have any TCP port at all, so the server is simply invisible unless you scan for SQL browser (and maybe RPC). In the end, named pipes are the final target of this NXC feature.
The UDP timeout issue will happen most of the time because of that.

Is there a need to reestablish a connection? From what i can tell getInstances already creates a new socket and does not alter anything of the existing connection, so we could just call that on the existing protocol object without all of the other logic overhead.

The getInstances creates an UDP socket, but it does not need the initial MSSQL connection to be established in the first place, so recreating a TDS object does not really reestablish a connection, but we can pass the connection object directly to discover_sqlbrowser, if that's what you mean.

On the implementation side, re-calling self.init() from instance_connect feels fragile. It would be safer to spin up new connection instances for each discovered SQL instance instead

I strongly agree, I just need to figure out how to properly create a new standalone connection that fits within the NXC loop.

Checklist to myself :

  • Add sqlbrowser_discovery config option under [MSSQL] (I'll leave the default value decision to you !)
  • Refactor instance_connect to recreate a clean connection, not self.__init__()
  • Ensure self.args.mssql_timeout is used during browser discovery, default 2s

I was also thinking about a dirty way to suggest SQL browser existence without forcing it by default : re-add the SQL browser inside the banner, but mark it as Unknown until it's actually scanned. Future scans could ask the DB for the last known browser status and display the number of instances.

Any opinion on that ?

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Apr 10, 2026

I still believe we should enforce the sql browser scanning even tho it means a 2 second timeout. Because I agree, sql browsers will mostly be used by sysadmins that don't have to expose the 1433 port. Considering nxc is multi threaded, I don't care about the timeout.
Also, I have never run nxc over huge ranges. Most of the time, I dump the ldap to retrieve the computers/servers lists and then run nxc mssql on them

About that:

I was also thinking about a dirty way to suggest SQL browser existence without forcing it by default : re-add the SQL browser inside the banner, but mark it as Unknown until it's actually scanned. 

Yes I'd keep the banner but I'm not sure I'd want to rely on the DB to expose the number of instances and their names

@NeffIsBack
Copy link
Copy Markdown
Member

Yes I'd keep the banner but I'm not sure I'd want to rely on the DB to expose the number of instances and their names

Agreed, the DB has some stability issues with multi threading sometimes, so we should probably not rely on that.

The getInstances creates an UDP socket, but it does not need the initial MSSQL connection to be established in the first place, so recreating a TDS object does not really reestablish a connection, but we can pass the connection object directly to discover_sqlbrowser, if that's what you mean.

I think I was confused about the reconnecting part and "connect to instances" code, but I guess that is for just connecting to the DBs after initial recon? Imo it would be less confusing if we would just reuse the existing tds object, but as it doesn't influence anything outside of getInstances() it doesn't really matter at the end of the day.

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Apr 13, 2026

Contributor

Hi @mrsheepsheep, we talked about that PR internally and believe we should revert it to the part where enumeration is done no matter what. Because the information about the sql browser running is too much important to just be left behind in case you don't use the appropriate option.

If that's ok with you I can take care of reverting and then making the PR a little bit cleaner.

Let me know :)

@mrsheepsheep
Copy link
Copy Markdown
Author

@Dfte No problem ! I'll let you handle the rest :)

@Dfte
Copy link
Copy Markdown
Contributor

Dfte commented Apr 14, 2026

Hey! I'll be following this PR there #1200 but I'll make sure you'll get the credits as well because that's a fantastic feature to me so yeah... Thank you :p

@mrsheepsheep
Copy link
Copy Markdown
Author

See you in 1200 !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants