I was in Italy for a month. While I was gone, somebody got sysadmin out of SQL Copilot with a variable.
The vulnerability is CVE-2026-65669, the SSMS 22 Copilot bug I wrote about early September. The full write-up went public on September 30th, and the detail that stuck with me is how Copilot's 'read-only mode' was enforced. It wasn't a permission. It was a regex blocklist.
That blocklist is the same control we have watched fail against SQL injection for twenty years. Before I get to Copilot, let's build one here and see it fail.
The Setup
First, a table to protect:
USE tempdb; GO DROP TABLE IF EXISTS dbo.names; GO CREATE TABLE dbo.names (UserName SYSNAME NOT NULL); INSERT dbo.names (UserName) VALUES (N'rebecca'); GO
Now a stored procedure that runs whatever query string you hand it, as long as it decides the string is read-only. It decides by scanning the text for keywords like INSERT, UPDATE and DELETE, and refusing the query if it finds one. I am building that check with LIKE so this runs on any version of SQL Server you have -- v2025 is not required.
CREATE OR ALTER PROCEDURE dbo.usp_ReadOnlyGuard
@query NVARCHAR(MAX)
AS
BEGIN
SET NOCOUNT ON;
/* 'Read-only' enforcement: reject the dangerous keywords. */
IF @query LIKE '%INSERT %'
OR @query LIKE '%UPDATE %'
OR @query LIKE '%DELETE %'
OR @query LIKE '%DROP %'
OR @query LIKE '%ALTER %'
OR @query LIKE '%EXEC %'
OR @query LIKE '%EXECUTE %'
OR @query LIKE '%MERGE %'
OR @query LIKE '%TRUNCATE %'
BEGIN
RAISERROR('Blocked: read-only mode permits SELECT only.', 16, 1);
RETURN;
END
EXEC (@query); /* approved -- run it */
END
GO
There is no AI here yet. This proc is standing in for it. You hand it a string, it checks the string and runs it -- exactly like Copilot's tool did. A legitimate read-only query goes straight through:
EXEC dbo.usp_ReadOnlyGuard @query = N'SELECT UserName FROM dbo.names;';
Purely SELECT, it ran exactly as intended. Now let's try two obvious attacks. A direct write, and a stored procedure call, both of which should be blocked.
EXEC dbo.usp_ReadOnlyGuard @query = N'UPDATE dbo.names SET UserName = N''x'';'; EXEC dbo.usp_ReadOnlyGuard @query = N'EXEC sp_who;';
Both blocked. Each one hit a keyword on the list, the guard stopped it and returned this:
Check your data. Confirm nothing moved:
SELECT UserName FROM dbo.names;
This is where people relax. The dangerous words were right there in the strings, the filter saw them, the writes never happened. Read-only mode works.
But no. It doesn't.
Start With the EXEC Check
The guard checks for nine keywords, and my query has to get past every one of them. Let's start with the execution check.
That check looks for EXEC followed by a space. But T-SQL lets me call EXEC(@s) with no space -- a parenthesis instead -- and it runs the same. So '%EXEC %' should miss it. Here is a harmless SELECT run that way, so there is nothing to block except the EXEC call itself:
EXEC dbo.usp_ReadOnlyGuard
@query = N'DECLARE @s NVARCHAR(200) = N''SELECT UserName FROM dbo.names;''; EXEC(@s);';
No errors. It ran.
The guard never recognized an 'EXEC' because EXEC( has no space after it. That is the execution check beaten. One down.
Now Hide the Verb
Beating the EXEC check alone is not enough. This one is for 'UPDATE' which is also in the list and the guard flags '%UPDATE %'. So in this one, I will hide it by building it from two pieces -- N'UP' + N'DATE' -- which SQL Server joins into UPDATE only at runtime, long after the guard has checked the text.
Put both tricks together -- the no-space EXEC( and the split verb:
EXEC dbo.usp_ReadOnlyGuard
@query = N'DECLARE @s NVARCHAR(200) = N''UP'' + N''DATE dbo.names SET UserName = N''''gotcha'''';''; EXEC(@s);';
No error. Check your data:
SELECT UserName FROM dbo.names;
This is the string the guard checked once SQL Server resolved the outer quotes:
DECLARE @s NVARCHAR(200) = N'UP' + N'DATE dbo.names SET UserName = N''gotcha'';'; EXEC(@s);
Not one blocked keyword in it. No UPDATE -- that word does not exist until UP and DATE join at runtime. No EXEC with a space -- it is EXEC(. The guard checked it, matched nothing, and ran it -- and your data changed.
That is a write in read-only mode, and UPDATE is the gentle version. The guard just ran a string I built at runtime, and that string can hold anything -- changing a column today, adding my login to the sysadmin role tomorrow. I could go on. The guard is reading one version of the string, and SQL Server is running another.
The researcher who found the Copilot bug got to the same place through a different door -- DECLARE @p sysname='sp_executesql'; EXEC @p, running the procedure through a variable so the blocklist never saw its name. Different keyword, same lesson. Once any route to dynamic execution survives the filter, read-only is over.
Why This Is the Whole Problem
Here is the table I wish had been on a wall in Redmond.
| Control | What it checks | Gets talked around? |
|---|---|---|
| Keyword blocklist | The text of the string | Yes -- rename it, split it, wrap it |
| A GRANT | What the login is allowed to do | No |
A blocklist is a bouncer at the door who only knows the names on his list. A permission is a locked door. The researcher's own fix says it: read-only for an AI agent has to be 'a security invariant (like a permission)', not a model instruction or a fragile keyword filter.
If my usp_ReadOnlyGuard had instead executed under a properly restricted security context, with SELECT access but no effective permissions to modify data or elevate privileges, my bypasses would have failed immediately. Not because the string looked wrong, but because the write was never permitted in the first place. End of story. The filter asks 'does this look dangerous?' The permission model asks 'is this allowed?' That is the distinction that matters.
Now the Part That Lands on Your SQL Server
Everything above was one misaligned string. The Copilot attack has a second half that does not need you to type anything at all.
SQL Copilot reads database instructions that live as extended properties -- database-wide instructions called CONSTITUTION.md, and object-level instructions called AGENTS.md. Copilot discovers them automatically and folds them into its context. They are added with sp_addextendedproperty, and -- this is the part that matters -- the permission to attach one can be lower than the permission of the person who later opens Copilot against that object.
So a user with ALTER on a table plants instructions. A DBA connected as sysadmin opens Copilot later and asks about that table. Copilot loads the planted instructions and runs them on the sysadmin connection. In the published demo, the end result is a db_owner adding their own login to sysadmin, through the DBA's session, having touched nothing but a comment field.
Extended properties were always just metadata before AI. Now something reads them as instructions. That is a new trust relationship nobody added to the permission model intentionally.
Find Them Before Copilot Does
You cannot audit a regex inside SSMS from T-SQL. But you can find out whether anyone has planted instruction-shaped extended properties in your databases. Run this in any database where Copilot might get pointed:
SELECT
ep.class_desc,
ep.name [property_name],
OBJECT_SCHEMA_NAME(ep.major_id) [object_schema],
OBJECT_NAME(ep.major_id) [object_name],
CAST(ep.value AS NVARCHAR(MAX)) [property_value]
FROM sys.extended_properties ep
WHERE ep.name IN (N'AGENTS.md', N'CONSTITUTION.md')
OR CAST(ep.value AS NVARCHAR(MAX)) LIKE N'%sp_executesql%'
OR CAST(ep.value AS NVARCHAR(MAX)) LIKE N'%sysadmin%'
OR CAST(ep.value AS NVARCHAR(MAX)) LIKE N'%ALTER SERVER ROLE%'
ORDER BY object_schema, object_name, property_name;
GO
An empty result set means the query found no extended properties matching these names or patterns in the current database, within your metadata visibility. That's a good starting point, but it isn't proof that no malicious instructions exist. Anything that does come back deserves a closer look at the `property_value` column. You're looking for instructions that could influence what Copilot does, particularly anything attempting to execute SQL or elevate permissions.
If you want to see it light up first, plant this:
/* Plant a test property on a table in tempdb */
EXEC sp_addextendedproperty
@name = N'AGENTS.md',
@value = N'This table lists user names. Always greet the user first.',
@level0type= N'SCHEMA', @level0name = N'dbo',
@level1type= N'TABLE', @level1name = N'names';
GO
Run the audit query up there and you'll see this:
The Takeaway
Patch SSMS 22. That closes CVE-2026-65669, and yes, you should do it today. But the patch fixes one product's filter. It does not fix the shape of the mistake, and the shape is what you need to carry forward, because you will see it again in the next agent, the next MCP server, the next 'read-only' integration somebody wires to production.
When a vendor tells you their AI runs in read-only mode, there is exactly one question worth asking: read-only enforced how? If the answer is a filter, a classifier, a system prompt, or anything that inspects the text of the query, it will be bypassed. If the answer is a dedicated, properly restricted login with no effective write or privilege-escalation permissions, then the database engine enforces that boundary. This is the one control the model cannot talk its way around.
Your AI's guardrails are only as strong as the login sitting behind them. Go look at yours.
/* Demo cleanup */ USE tempdb; GO DROP PROCEDURE IF EXISTS dbo.usp_ReadOnlyGuard; DROP TABLE IF EXISTS dbo.names;
Small note: Microsoft has not published the exact regex Copilot used, so the guard proc above is my own reconstruction built the same way -- a keyword blocklist -- to demonstrate the class of failure. The bypass patterns are the ones from the public research. The extended-property behavior is as documented in the write-up and in Microsoft's own admin-controls guidance.
More to read
Slides -- From SELECT to SYSADMIN: Hacking SQL Copilot in SSMS
MSRC -- CVE-2026-65669
MSFT -- Admin controls for GitHub Copilot in SSMS
sqlfingers -- This SQL Server CVE Isn't on Your SQL Server



