Author: Margarita Naumova | Microsoft MVP | MCM SQL Server | Data Engineer | MCT
The Starting Point: A Login With Nothing
Imagine a login just created on your SQL Server instance (especially the one with a weak password). No database access granted. No roles assigned. No permissions of any kind.
Here is what it can already do:
SELECT name, database_id, state_desc
FROM sys.databases
ORDER BY name;
--or even more columns included:
SELECT
name,
create_date,
state_desc,
recovery_model_desc,
is_trustworthy_on,
is_encrypted,
log_reuse_wait_desc
FROM sys.databases
ORDER BY name;

It gets back every database on the instance. HR_Payroll_2024. FinancialReporting. ClinicalRecords. SWIFT_Transactions. The complete map — before the attacker has touched a single row of data. And this is the default.
Why This Happens: VIEW ANY DATABASE
Every SQL Server instance ships with a server-level permission called VIEW ANY DATABASE granted to the public role. Since every login is automatically a member of public — with no way to remove them — this permission is silently inherited by every login on your instance, regardless of what else they have or don’t have.
You can verify it yourself:
SELECT
pr.name AS principal,
pe.permission_name,
pe.state_desc
FROM sys.server_permissions pe
JOIN sys.server_principals pr
ON pe.grantee_principal_id = pr.principal_id
WHERE pr.name = 'public'
AND pe.permission_name = 'VIEW ANY DATABASE';
One row comes back. VIEW ANY DATABASE. GRANT. public.

The permission doesn’t grant access into the databases — only the ability to see they exist. That distinction matters, but less than you might hope. Because database names alone reveal an enormous amount before an attacker has done anything else. And enumeration is always step one.

Two Paths Forward From Here
Once an attacker has the database map, they have two interesting directions to explore — and neither requires any additional privileges to attempt.

Path 1: The Guest Account
The guest account exists in every SQL Server database. In a properly hardened environment it has CONNECT revoked and is effectively dormant. But it only takes one database where a DBA enabled it for convenience — and that database becomes accessible to every login on the instance, including ones that were never explicitly granted anything.
-- Check if it is enabled in a specific database
SELECT
DB_NAME() AS database_name,
dp.name AS principal_name,
pe.permission_name,
pe.state_desc AS permission_state -- GRANT = enabled, DENY/absent = disabled
FROM sys.database_principals dp
JOIN sys.database_permissions pe
ON dp.principal_id = pe.grantee_principal_id
WHERE dp.name = 'guest'
AND pe.permission_name = 'CONNECT';
-- No row returned = guest is effectively disabled
-- Row with state_desc = 'GRANT' = guest is active
--OR run this to check all databases on the instance:
CREATE TABLE #GuestEnabled (database_name SYSNAME);
EXEC sp_MSforeachdb '
USE [?];
IF EXISTS (
SELECT 1
FROM sys.database_principals dp
JOIN sys.database_permissions pe
ON dp.principal_id = pe.grantee_principal_id
WHERE dp.name = ''guest''
AND pe.permission_name = ''CONNECT''
AND pe.state = ''G'' -- G = GRANT; W = GRANT WITH GRANT OPTION
)
INSERT INTO #GuestEnabled VALUES (''?'');
';
SELECT
database_name,
CASE
WHEN database_name IN ('master', 'tempdb', 'msdb')
THEN 'Required — do not revoke'
ELSE 'Review — guest should not be enabled here'
END AS recommendation
FROM #GuestEnabled
ORDER BY database_name;
DROP TABLE #GuestEnabled;
From there, whatever permissions have accumulated on the guest account or the public role inside that database are now available.

What I would do:
Any database returned here — other than master, tempdb, and msdb where guest is required — should have it revoked, I would do this, because users which require permissions to a database must be put in roles and added explicitly:
USE [database_name];
REVOKE CONNECT FROM guest;
Path 2: TRUSTWORTHY Escalation
The second path is more dangerous because the destination isn’t data — it’s the server itself.
Back to the database enumeration result, the attacker adds one filter is_trustworthy_on = 1 on his exploration journey. If a database returns with is_trustworthy_on = 1 and the owner is sa or any sysadmin account, the attacker has found a high-value target that requires no exploit whatsoever.
TRUSTWORTHY tells SQL Server to trust that code executing inside that database can act as the database owner. If the owner is sa, code inside that database can run as sa. To exploit this, the attacker needs db_owner in the database where TRUSTWORTHY is ON — which is exactly the role commonly granted to application accounts. With that, creating and executing one stored procedure is enough:
--
CREATE PROCEDURE dbo.EscalatePrivilege
WITH EXECUTE AS OWNER
AS
BEGIN
EXEC sp_addsrvrolemember 'attacker_login', 'sysadmin';
END;
GO
--and run and check yourself
EXEC dbo.EscalatePrivilege;
Result: 1.
The verification returns 1. The account that entered as a db_owner of one database is now sysadmin on the entire instance.
Path B uses a misconfigured database property to reach full server ownership — but it requires an account that already holds db_owner in that database. And that is less rare than it should be. How many of you have heard this request: “The application needs db_owner privileges.” Exactly. Me too. And it made me uncomfortable every time, because I can count on one hand the legitimate cases where it is actually needed.
The answer to that request should almost always be no. And remember — the attacker already knows exactly which database to target. That information was in the map they got for free at step one.
What to Fix and How
1. Revoke VIEW ANY DATABASE from public
REVOKE VIEW ANY DATABASE FROM public;
Then grant it only to accounts that legitimately need it — monitoring tools, DBA logins, backup agents:
GRANT VIEW ANY DATABASE TO [monitoring_login];
GRANT VIEW ANY DATABASE TO [dba_team];
Be aware that revoking it has some operational side effects, so it need some planning:
SSMS Object Explorer will show an empty Databases folder for low-privilege logins.
Some ORMs do a database existence check on connection and may throw errors.
Third-party monitoring and backup tools that enumerate all databases on connect will need explicit grants instead.
SQL Server’s own internal services and Agent do not require it. These impacts are all manageable — and manageable is a better position than handing every login a full instance map by default.
AND There is always an exception, and that is msdb. Agent jobs frequently need to do things that cross database boundaries — executing SSIS packages, running PowerShell, calling Database Mail, managing backups, running maintenance plans. Disabling TRUSTWORTHY for msdb would very likely break the job subsystem, so we don’t do it. Our option? For msdb — accept TRUSTWORTHY ON as a known platform requirement, but compensate:
1. Do not create user procedures in msdb — there is no legitimate reason for application or custom code to live there
2. Do not grant EXECUTE permissions in msdb to any user login — only SQL Server Agent service account and sysadmin should operate there
3. Audit existing msdb objects — check for any procedures that shouldn’t be there
4. Monitor msdb closely — any DDL activity (new procedures, permission changes) in msdb outside of a maintenance window is a red flag
5. Restrict who can connect to msdb at all — most application logins have no business connecting to it.
Need security checks or assessment and advice? Contact us!
I cover more security cases in Hacking and Securing SQL Server class, which we do together with CQURE. More info and registration at: https://cqureacademy.com/cyber-security-training/lvc-hacking-and-securing-sql-server/
Thanks for reading!
Want to know more?
