Adding support for Sybase into dbwire

This commit is contained in:
Miroslav Štampar
2026-08-13 14:41:00 +02:00
parent 58d6f740b7
commit 92c2ee47ea
10 changed files with 661 additions and 15 deletions
+2 -2
View File
@@ -598,11 +598,11 @@
<blind/>
</dbs>
<tables>
<inband query="SELECT name FROM %s..sysobjects WHERE type IN ('U','V')"/>
<inband query="SELECT %s..sysusers.name+'.'+%s..sysobjects.name AS name FROM %s..sysobjects,%s..sysusers WHERE %s..sysobjects.uid=%s..sysusers.uid AND %s..sysobjects.type IN ('U','V')"/>
<blind/>
</tables>
<columns>
<inband query="SELECT %s..syscolumns.name,%s..syscolumns.usertype FROM %s..syscolumns,%s..sysobjects WHERE %s..syscolumns.id=%s..sysobjects.id AND %s..sysobjects.name='%s'" condition="[DB]..syscolumns.name"/>
<inband query="SELECT %s..syscolumns.name,%s..syscolumns.usertype FROM %s..syscolumns WHERE %s..syscolumns.id=object_id('%s.%s')" condition="[DB]..syscolumns.name"/>
<blind/>
</columns>
<dump_table>
+6 -2
View File
@@ -23,6 +23,7 @@ A wire protocol is shared across a whole family of products, so one client serve
| `postgres.py` | PostgreSQL v3 | PostgreSQL, CockroachDB, CrateDB, Redshift, Greenplum, Vertica |
| `mysql.py` | MySQL client/server | MySQL, MariaDB, TiDB, Aurora (MySQL), Percona |
| `tds.py` | TDS 7.x | Microsoft SQL Server |
| `sybase.py` | TDS 5.0 | Sybase / SAP ASE |
| `firebird.py` | Firebird wire | Firebird 3 / 4 / 5 |
| `cubrid.py` | CUBRID CAS | CUBRID |
| `clickhouse.py` | HTTP (TabSeparated) | ClickHouse and HTTP-compatible forks |
@@ -58,8 +59,11 @@ parameter binding, prepared statements, bulk load/`COPY`, or TLS. Notable per-pr
supported; use a `mysql_native_password` account for the dependency-free path.
- **TDS** - cleartext login only. Servers that force encryption (for example Azure SQL Database)
require TLS and are not supported here; the native driver or SQLAlchemy tier covers those. LOGIN7 is the
Microsoft dialect (TDS 7.x); Sybase ASE speaks TDS 5.0 (LOGINREC + its own token dialect), which is not
implemented yet - ASE `-d` goes through the pymssql/FreeTDS tier for now.
Microsoft dialect (TDS 7.x) and does not reach Sybase - see the next bullet.
- **TDS 5.0 (Sybase)** - cleartext LOGINREC login. The client declares no optional capabilities, so the
server converts anything exotic (`date`/`time`/`bigdatetime`, wide formats) down to the baseline types
before sending it. Packet size is whatever the login negotiates. Sends and expects UTF-8, so a server
running any character set is decoded correctly.
- **Firebird** - SRP-256 (and SRP) authentication with ChaCha20 or RC4 wire encryption, as required by
default on Firebird 3 and later. Legacy (pre-SRP) authentication is not implemented.
- **CUBRID** - cleartext login over the CAS broker protocol. Large objects (BLOB/CLOB) are returned as
+3 -2
View File
@@ -11,8 +11,9 @@ sqlmap's direct ('-d') connection when no native driver (and no SQLAlchemy) is i
Design note: connectors speak a *wire protocol*, not a product, so a single client covers the whole
compatible family - e.g. the PostgreSQL client also serves CockroachDB, CrateDB, Redshift and Greenplum;
a MySQL client serves MariaDB/TiDB/Aurora. Each module exposes a small PEP 249 (DB-API 2.0) subset
(connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()).
a MySQL client serves MariaDB/TiDB/Aurora. Where a family split the protocol, so does the client: tds.py
speaks Microsoft's TDS 7.x and sybase.py the TDS 5.0 that ASE kept. Each module exposes a small PEP 249
(DB-API 2.0) subset (connect(), Connection.cursor()/commit()/close(), Cursor.execute()/fetchall()).
"""
import socket
+429
View File
@@ -0,0 +1,429 @@
#!/usr/bin/env python
"""
Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org)
See the file 'LICENSE' for copying permission
"""
"""
Minimal pure-python Sybase ASE client speaking TDS 5.0 (stdlib only, no pymssql/FreeTDS).
Sybase and Microsoft share the 8-byte TDS packet framing (reused from tds.py) and part company at the
login: ASE speaks TDS 5.0, which means a fixed-layout LOGINREC instead of LOGIN7, a capability
negotiation, SQL sent as a LANGUAGE token in a normal packet, and results described by ROWFMT rather than
COLMETADATA. Cleartext login only. Read-oriented for sqlmap: execute() takes a fully-formed query string,
binary values come back as bytes (sqlmap hex-encodes them).
Two encodings look like their Microsoft namesakes and are not: DECIMAL/NUMERIC carries a big-endian
magnitude with 1 meaning *negative* (Microsoft is little-endian with 1 meaning positive), and DONE reports
a 4-byte row count (Microsoft uses 8).
"""
import os
import re
import socket
import struct
from extra.dbwire import DatabaseError
from extra.dbwire import DataError
from extra.dbwire import IntegrityError
from extra.dbwire import InterfaceError
from extra.dbwire import NotSupportedError
from extra.dbwire import OperationalError
from extra.dbwire import handshake_done
from extra.dbwire import keepalive
from extra.dbwire import ProgrammingError
# the packet framing is byte-identical to the Microsoft dialect - only what travels inside it differs
from extra.dbwire.tds import _decode_datetime
from extra.dbwire.tds import _decode_money
from extra.dbwire.tds import _decode_smalldatetime
from extra.dbwire.tds import _read_message
from extra.dbwire.tds import _send_message
_PKT_LOGIN = 0x02
_PKT_NORMAL = 0x0f
_LOGIN_CHUNK = 504 # the login travels in 512-byte packets: the server's buffer is not negotiated yet
_PACKET_SIZE = 2048 # ASE's default 'max network packet size'; a bigger request is negotiated down
_HEADER_SIZE = 8
# tokens
_TOKEN_LANGUAGE = 0x21
_TOKEN_ROWFMT = 0xee
_TOKEN_ROWFMT2 = 0x61
_TOKEN_ROW = 0xd1
_TOKEN_CAPABILITY = 0xe2
_TOKEN_ENVCHANGE = 0xe3
_TOKEN_EED = 0xe5
_TOKEN_RETURNSTATUS = 0x79
_TOKEN_LOGOUT = 0x71
_TOKEN_DONE = frozenset((0xfd, 0xfe, 0xff)) # DONE / DONEPROC / DONEINPROC
_DONE_COUNT = 0x0010 # DONE status bit: the row count is meaningful
_ENV_PACKET_SIZE = 4 # ENVCHANGE type: the server's answer to the requested size
# SYB* datatype codes. Fixed-length types carry no per-value length; everything else is length-prefixed,
# and a zero length means NULL (TDS 5 cannot tell an empty string from NULL - neither can ASE itself).
_T_INT1, _T_BIT, _T_INT2, _T_INT4, _T_INT8 = 0x30, 0x32, 0x34, 0x38, 0xbf
_T_REAL, _T_FLT8, _T_MONEY, _T_MONEY4 = 0x3b, 0x3e, 0x3c, 0x7a
_T_DATETIME, _T_DATETIME4, _T_DATE, _T_TIME = 0x3d, 0x3a, 0x31, 0x33
_T_INTN, _T_FLTN, _T_MONEYN, _T_DATETIMN, _T_DATEN, _T_TIMEN = 0x26, 0x6d, 0x6e, 0x6f, 0x7b, 0x93
_T_DECIMAL, _T_NUMERIC = 0x6a, 0x6c
_T_CHAR, _T_VARCHAR, _T_BINARY, _T_VARBINARY, _T_NVARCHAR = 0x2f, 0x27, 0x2d, 0x25, 0x67
_T_TEXT, _T_IMAGE, _T_UNITEXT = 0x23, 0x22, 0xae
_T_LONGBINARY, _T_LONGCHAR = 0xe1, 0xaf
_T_BIGDATETIME, _T_BIGTIME = 0xbb, 0xbc
_T_BIGDATETIMEN, _T_BIGTIMEN = 0xbd, 0xbe
_T_VOID = 0x1f
_FIXED_LENGTH = {
_T_INT1: 1, _T_BIT: 1, _T_INT2: 2, _T_INT4: 4, _T_INT8: 8, _T_REAL: 4, _T_FLT8: 8,
_T_MONEY: 8, _T_MONEY4: 4, _T_DATETIME: 8, _T_DATETIME4: 4, _T_DATE: 4, _T_TIME: 4,
_T_BIGDATETIME: 8, _T_BIGTIME: 8, _T_VOID: 0,
}
_BLOB_TYPES = frozenset((_T_TEXT, _T_IMAGE, _T_UNITEXT)) # textptr + timestamp + 4-byte length
_LONG_TYPES = frozenset((_T_LONGBINARY, _T_LONGCHAR)) # 4-byte length
_BINARY_TYPES = frozenset((_T_BINARY, _T_VARBINARY, _T_IMAGE))
_DECIMAL_TYPES = frozenset((_T_DECIMAL, _T_NUMERIC))
# univarchar/unichar/unitext are carried as LONGBINARY/UNITEXT holding UTF-16 - only the user type says so
_UNICODE_USERTYPES = frozenset((34, 35, 36))
# a client that does not claim the date/time/bigdatetime capabilities gets those columns converted to a
# plain datetime, which is lossless but would render a date as '... 00:00:00'. The user type survives the
# conversion, so it is what says how much of the value the column actually holds.
_USERTYPE_DATE = 37
_USERTYPE_TIME = frozenset((38, 49))
_IDENTIFIER = re.compile(r"^[A-Za-z_#][A-Za-z0-9_#$]*$")
def _u8(data, off):
return struct.unpack("<B", data[off:off + 1])[0]
def _u16(data, off):
return struct.unpack("<H", data[off:off + 2])[0]
def _i32(data, off):
return struct.unpack("<i", data[off:off + 4])[0]
def _login_string(value, size):
# fixed-width field: `size` bytes of (truncated, NUL-padded) text followed by the actual length
data = (value or "").encode("utf-8")[:size]
return data + b"\x00" * (size - len(data)) + struct.pack("<B", len(data))
def _loginrec(host, user, password, appname, servername, charset, packetsize):
"""
The TDS 5.0 LOGINREC: one fixed 568-byte record, then a 35-byte CAPABILITY token.
Field order and widths mirror what ASE's own Open Client puts on the wire. The two reserved runs are
spare in every implementation that documents them (Open Client sets one byte inside the second run,
ASE accepts the record without it), so they go out as zeros rather than as an unexplained constant.
"""
out = _login_string(host, 30) # client host name
out += _login_string(user, 30) # user name
out += _login_string(password, 30) # password (cleartext)
out += _login_string("%d" % os.getpid(), 30) # client process id, as text
# number/char/date byte orders, then: notify on USE, refuse dump-load & bulk-copy, interface, network
out += struct.pack("<9B", 3, 1, 6, 10, 9, 1, 1, 0, 0)
out += b"\x00" * 7 # spare
out += _login_string(appname, 30) # application name
out += _login_string(servername, 30) # server name
remote = b"\x00" + struct.pack("<B", len(password or "")) + (password or "").encode("utf-8")
out += remote + b"\x00" * (255 - len(remote)) + struct.pack("<B", len(remote)) # remote passwords
out += struct.pack("<4B", 5, 0, 0, 0) # TDS version 5.0.0.0
out += _login_string("dbwire", 10) # library name
out += struct.pack("<4B", 1, 0, 0, 0) # library version
out += struct.pack("<3B", 0, 0x0d, 0x11) # auto-convert short, float4 & date4 formats
out += b"\x00" # reserved
out += _login_string("", 30) # language (empty -> the server default)
out += b"\x00" * 13 # notify-on-language-change, security & HA fields
out += _login_string(charset, 30) # client character set
out += b"\x00" # notify on character-set change
out += _login_string("%d" % packetsize, 6) # network packet size, as text
out += b"\x00" * 4 # spare
# capability negotiation: request bits say what the client can do, response bits what it wants back.
# All-zero is the baseline dialect - the wide/streaming formats a fallback client has no use for are
# exactly the ones it must not claim, or the server starts answering in them.
capability = struct.pack("<3B", _TOKEN_CAPABILITY, 32, 0)
capability += struct.pack("<2B", 1, 14) + b"\x00" * 14
capability += struct.pack("<2B", 2, 14) + b"\x00" * 14
return out + capability
class _Column(object):
__slots__ = ("name", "type", "size", "scale", "usertype")
def _parse_rowfmt(data, off, length, codec):
end = off + length
count = _u16(data, off); off += 2
columns = []
for _ in range(count):
col = _Column()
namelen = _u8(data, off); off += 1
col.name = data[off:off + namelen].decode(codec, "replace"); off += namelen
off += 1 # status (nullability - the value length says it)
col.usertype = _i32(data, off); off += 4
col.type = _u8(data, off); off += 1
col.size, col.scale = _FIXED_LENGTH.get(col.type, 0), 0
if col.type in _DECIMAL_TYPES:
col.size = _u8(data, off)
col.scale = _u8(data, off + 2) # precision (off + 1) is not needed to decode
off += 3
elif col.type in _BLOB_TYPES:
col.size = _i32(data, off); off += 4
off += 2 + _u16(data, off) # the blob's table name
elif col.type in _LONG_TYPES:
col.size = _i32(data, off); off += 4
elif col.type not in _FIXED_LENGTH:
col.size = _u8(data, off); off += 1
off += 1 + _u8(data, off) # locale
columns.append(col)
if off != end:
raise InterfaceError("malformed ROWFMT token (%d columns did not fill %d bytes)" % (count, length))
return columns, off
def _decode_numeric(raw, scale):
# sign byte then a BIG-endian magnitude, and 1 means NEGATIVE (the Microsoft encoding is the mirror
# image of this: little-endian, 1 == positive)
magnitude = 0
for b in bytearray(raw[1:]):
magnitude = (magnitude << 8) | b
value = -magnitude if bytearray(raw)[0] else magnitude
if scale:
text = "%0*d" % (scale + 1, abs(value))
return ("-" if value < 0 else "") + text[:-scale] + "." + text[-scale:]
return "%d" % value
def _decode_date(raw):
import datetime
return "%s" % (datetime.date(1900, 1, 1) + datetime.timedelta(days=_i32(raw, 0)))
def _decode_time(raw):
import datetime
ticks = struct.unpack("<I", raw)[0] # 300ths of a second since midnight
return "%s" % (datetime.datetime(1900, 1, 1) + datetime.timedelta(milliseconds=ticks * 10.0 / 3.0)).time()
def _decode_bigdatetime(raw, time_only=False):
import datetime
micros = struct.unpack("<Q", raw)[0] # microseconds since 0001-01-01 00:00:00
value = datetime.datetime(1, 1, 1) + datetime.timedelta(microseconds=micros)
return "%s" % (value.time() if time_only else value)
def _decode_value(col, raw, codec):
t = col.type
if t in (_T_INT1, _T_BIT) or (t == _T_INTN and len(raw) == 1):
value = bytearray(raw)[0] # tinyint is unsigned; bit is 0/1
return "%d" % value
if t == _T_INT2 or (t == _T_INTN and len(raw) == 2):
return "%d" % struct.unpack("<h", raw)[0]
if t == _T_INT4 or (t == _T_INTN and len(raw) == 4):
return "%d" % struct.unpack("<i", raw)[0]
if t == _T_INT8 or (t == _T_INTN and len(raw) == 8):
return "%d" % struct.unpack("<q", raw)[0]
if t in (_T_REAL, _T_FLT8, _T_FLTN):
return repr(struct.unpack("<f" if len(raw) == 4 else "<d", raw)[0])
if t in (_T_MONEY, _T_MONEY4, _T_MONEYN):
return _decode_money(raw)
if t in _DECIMAL_TYPES:
return _decode_numeric(raw, col.scale)
if t in (_T_DATETIME, _T_DATETIME4, _T_DATETIMN):
text = _decode_datetime(raw) if len(raw) == 8 else _decode_smalldatetime(raw)
if col.usertype == _USERTYPE_DATE:
return text.split(" ")[0]
if col.usertype in _USERTYPE_TIME:
return text.split(" ")[1]
return text
if t in (_T_DATE, _T_DATEN):
return _decode_date(raw)
if t in (_T_TIME, _T_TIMEN):
return _decode_time(raw)
if t in (_T_BIGDATETIME, _T_BIGDATETIMEN):
return _decode_bigdatetime(raw)
if t in (_T_BIGTIME, _T_BIGTIMEN):
return _decode_bigdatetime(raw, time_only=True)
if t in _BINARY_TYPES:
return raw # sqlmap hex-encodes binary columns
if t == _T_UNITEXT or col.usertype in _UNICODE_USERTYPES:
return raw.decode("utf-16-le", "replace")
if t in (_T_CHAR, _T_VARCHAR, _T_NVARCHAR, _T_LONGCHAR, _T_TEXT):
return raw.decode(codec, "replace")
return raw # LONGBINARY that is not unichar, a locator handle, an unmapped type: bytes, not a guess
def _read_value(col, data, off, codec):
if col.type in _FIXED_LENGTH:
size = _FIXED_LENGTH[col.type]
raw, off = data[off:off + size], off + size
if not size:
return None, off
elif col.type in _BLOB_TYPES:
ptrlen = _u8(data, off); off += 1
if not ptrlen:
return None, off # no text pointer -> NULL
off += ptrlen + 8 # text pointer + timestamp
length = _i32(data, off); off += 4
raw, off = data[off:off + length], off + length
elif col.type in _LONG_TYPES:
length = _i32(data, off); off += 4
if not length:
return None, off
raw, off = data[off:off + length], off + length
else:
length = _u8(data, off); off += 1
if not length:
return None, off
raw, off = data[off:off + length], off + length
return _decode_value(col, raw, codec), off
def _eed(data, off, codec):
# EED: number(4) state(1) class(1) sqlstate, status(1) transtate(2) message, server, procedure, line
number = _i32(data, off)
severity = _u8(data, off + 5)
pos = off + 6
pos += 1 + _u8(data, pos) # SQLSTATE
pos += 1 + 2 # status + transaction state
length = _u16(data, pos); pos += 2
return number, severity, data[pos:pos + length].decode(codec, "replace").strip()
def _raise_server_error(number, message):
if number in (2601, 2615, 2627, 1105, 546, 547): # duplicate key/row, constraint, foreign key
raise IntegrityError(message)
if number in (247, 249, 257, 260, 264, 512, 8115): # conversion, overflow, arithmetic
raise DataError(message)
raise ProgrammingError(message)
class Cursor(object):
def __init__(self, connection):
self.connection = connection
self.description = None
self.rowcount = -1
self._rows = []
self._pos = 0
def execute(self, query, params=None):
if params is not None:
raise NotSupportedError("parameter binding is not supported; pass a fully-formed query string")
self.description, self.rowcount, self._rows, self._pos = None, -1, [], 0
self.description, self._rows, affected = self.connection._query(query)
self.rowcount = len(self._rows) if self.description is not None else (affected if affected is not None else -1)
return self
def fetchall(self):
retVal = self._rows[self._pos:]
self._pos = len(self._rows)
return retVal
def fetchone(self):
if self._pos >= len(self._rows):
return None
retVal = self._rows[self._pos]
self._pos += 1
return retVal
def close(self):
self._rows = []
class Connection(object):
def __init__(self, sock, codec):
self._sock = sock
self._codec = codec
# a packet larger than the size agreed at login is not an error the server reports - it drops the
# connection, so every send has to be chopped to what the login negotiated (ENVCHANGE 4 below)
self._chunk = _PACKET_SIZE - _HEADER_SIZE
def cursor(self):
return Cursor(self)
def commit(self):
pass # sqlmap issues autonomous statements; ASE is in unchained (auto-commit) mode by default
def rollback(self):
pass
def close(self):
try:
_send_message(self._sock, _PKT_NORMAL, struct.pack("<2B", _TOKEN_LOGOUT, 0))
except Exception:
pass
try:
self._sock.close()
except Exception:
pass
def _query(self, query):
# LANGUAGE token: length covers the status byte and the (single-byte encoded) statement text
text = query.encode(self._codec, "replace")
token = struct.pack("<BiB", _TOKEN_LANGUAGE, len(text) + 1, 0) + text
_send_message(self._sock, _PKT_NORMAL, token, self._chunk)
try:
return self._read_response()
except (struct.error, IndexError, ValueError, KeyError) as ex:
raise InterfaceError("malformed server response: %s" % ex)
def _read_response(self, login=False):
data = _read_message(self._sock)
off, columns, rows, description, affected = 0, [], [], None, None
error = None
while off < len(data):
token = _u8(data, off); off += 1
if token == _TOKEN_ROWFMT:
length = _u16(data, off); off += 2
columns, off = _parse_rowfmt(data, off, length, self._codec)
description = [(c.name, c.type, None, None, None, c.scale, None) for c in columns]
rows, affected = [], None
elif token == _TOKEN_ROW:
row = []
for col in columns:
value, off = _read_value(col, data, off, self._codec)
row.append(value)
rows.append(tuple(row))
elif token in _TOKEN_DONE:
status, _transtate, count = struct.unpack("<HHi", data[off:off + 8]); off += 8
if status & _DONE_COUNT:
affected = count if affected is None else affected + count
elif token == _TOKEN_EED:
length = _u16(data, off)
number, severity, message = _eed(data, off + 2, self._codec)
if severity > 10: # 10 and below are informational (e.g. 'Changed database')
error = (number, "(remote) %s" % message)
off += 2 + length
elif token == _TOKEN_ENVCHANGE:
length = _u16(data, off)
if _u8(data, off + 2) == _ENV_PACKET_SIZE:
size = data[off + 4:off + 4 + _u8(data, off + 3)]
self._chunk = max(_LOGIN_CHUNK, int(size) - _HEADER_SIZE)
off += 2 + length
elif token == _TOKEN_RETURNSTATUS:
off += 4
elif token == _TOKEN_ROWFMT2:
raise NotSupportedError("the server answered with the wide ROWFMT2 format")
else:
off += 2 + _u16(data, off) # every other TDS 5 token is 2-byte length prefixed
if error is not None:
if login:
raise OperationalError(error[1])
_raise_server_error(*error)
return description, rows, affected
def connect(host=None, port=5000, user=None, password=None, database=None, connect_timeout=None, **kwargs):
charset, codec = kwargs.get("charset", "utf8"), "utf-8"
try:
sock = socket.create_connection((host or "localhost", int(port or 5000)), timeout=connect_timeout)
keepalive(sock)
except (socket.error, socket.timeout) as ex:
raise OperationalError("could not connect to '%s:%s' (%s)" % (host, port, ex))
connection = Connection(sock, codec)
try:
login = _loginrec("dbwire", user or "", password or "", "dbwire", host or "localhost", charset, _PACKET_SIZE)
_send_message(sock, _PKT_LOGIN, login, _LOGIN_CHUNK)
connection._read_response(login=True)
handshake_done(sock)
if database:
if not _IDENTIFIER.match(database):
raise ProgrammingError("unsupported database name %r" % database)
connection._query("USE %s" % database)
except (DatabaseError, InterfaceError):
connection.close()
raise
except Exception as ex:
connection.close()
raise OperationalError("Sybase login failed (%s)" % ex)
return connection
+4 -4
View File
@@ -9,7 +9,7 @@ See the file 'LICENSE' for copying permission
Minimal pure-python TDS (Tabular Data Stream) client for Microsoft SQL Server (stdlib only).
LOGIN7 / TDS 7.4 is the Microsoft dialect. Sybase ASE speaks TDS 5.0 - same 8-byte packet framing, but a
LOGINREC login and its own token/type dialect - which is not implemented yet, so ASE still needs pymssql.
LOGINREC login and its own token/type dialect - and lives in sybase.py, which reuses the framing below.
Cleartext login only (TDS pre-login encryption negotiated to NOT_SUP); a server that forces encryption
would need TLS-in-TDS which is out of scope for the dependency-free client. Implements PRELOGIN, LOGIN7,
@@ -43,9 +43,9 @@ _STATUS_EOM = 0x01
def _u8(data, off):
return struct.unpack("<B", data[off:off + 1])[0]
def _send_message(sock, mtype, data):
# split into <= 4096-byte packets (8-byte header + <=4088 data); only the last carries the EOM status bit
chunk_size = 4088
def _send_message(sock, mtype, data, chunk_size=4088):
# split into <= 4096-byte packets (8-byte header + <=4088 data); only the last carries the EOM status
# bit. chunk_size is smaller for a TDS 5 login, whose packet size is not negotiated yet (see sybase.py)
packet_id = 0
off = 0
while True:
+23 -1
View File
@@ -124,6 +124,28 @@ SYBASE_TYPES = {
3: "binary",
19: "text",
20: "image",
# the rest of master..systypes on ASE 15/16 - without these, --schema prints the raw user type number
# for columns as ordinary as bigint, date and univarchar
34: "unichar",
35: "univarchar",
36: "unitext",
37: "date",
38: "time",
39: "daten",
40: "timen",
42: "longsysname",
43: "bigint",
44: "usmallint",
45: "uint",
46: "ubigint",
47: "uintn",
48: "bigdatetime",
49: "bigtime",
50: "bigdatetimen",
51: "bigtimen",
53: "text_locator",
54: "image_locator",
55: "unitext_locator",
}
ALTIBASE_TYPES = {
@@ -266,7 +288,7 @@ DBWIRE_MODULES = {
DBMS.CRATEDB: "postgres", # CrateDB speaks the PostgreSQL wire protocol
DBMS.MYSQL: "mysql",
DBMS.MSSQL: "tds",
DBMS.SYBASE: "tds",
DBMS.SYBASE: "sybase", # ASE speaks TDS 5.0, a different login and token dialect than Microsoft's 7.x
DBMS.CLICKHOUSE: "clickhouse",
DBMS.MONETDB: "monetdb",
DBMS.PRESTO: "presto",
+1 -1
View File
@@ -20,7 +20,7 @@ from lib.core.enums import OS
from thirdparty import six
# sqlmap version (<major>.<minor>.<month>.<monthly commit>)
VERSION = "1.10.8.31"
VERSION = "1.10.8.32"
TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable"
TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34}
VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE)
+2 -2
View File
@@ -145,7 +145,7 @@ class Enumeration(GenericEnumeration):
for db in dbs:
for blind in blinds:
query = rootQuery.inband.query % db
query = rootQuery.inband.query % ((db,) * 7)
retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName], blind=blind, alias=kb.aliasName)
if retVal:
@@ -276,7 +276,7 @@ class Enumeration(GenericEnumeration):
logger.info(infoMsg)
for blind in blinds:
query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, conf.db, conf.db, unsafeSQLIdentificatorNaming(tbl))
query = rootQuery.inband.query % (conf.db, conf.db, conf.db, conf.db, conf.db, safeSQLIdentificatorNaming(tbl, True))
retVal = pivotDumpTable("(%s) AS %s" % (query, kb.aliasName), ['%s.name' % kb.aliasName, '%s.usertype' % kb.aliasName], blind=blind, alias=kb.aliasName)
if retVal:
+37
View File
@@ -79,6 +79,43 @@ class TestQueriesXmlCoverage(unittest.TestCase):
% (dbms.get("value"), query, ex))
def test_tds_table_queries_carry_the_owner(self):
# Regression: safeSQLIdentificatorNaming() prepends DEFAULT_MSSQL_SCHEMA ('dbo.') to any MSSQL or
# Sybase table name that has no dot in it. A <tables> query that projects the bare name therefore
# makes every table owned by anyone but dbo unreachable: --columns/--count/--dump all end up asking
# for 'db.dbo.<table>'. Both dialects must join sysusers and project '<owner>.<table>'.
tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml"))
seen = 0
for dbms in tree.findall(".//dbms"):
if dbms.get("value") not in ("Microsoft SQL Server", "Sybase"):
continue
for node in dbms.iter("tables"):
query = (node.find("inband") if node.find("inband") is not None else node).get("query") or ""
if not query:
continue
seen += 1
self.assertIn("sysusers", query, msg="%s <tables> does not resolve the table owner" % dbms.get("value"))
self.assertIn("+'.'+", query, msg="%s <tables> does not qualify the table with its owner" % dbms.get("value"))
self.assertEqual(seen, 2)
def test_sybase_catalog_queries_take_the_argument_count_the_plugin_passes(self):
# plugins/dbms/sybase/enumeration.py formats these with ((db,) * 7) and (db, db, db, db, db, table).
# pivotDumpTable() then selects '<alias>.name' (and '<alias>.usertype'), so those output column
# names have to survive the projection.
tree = ET.parse(os.path.join(ROOT, "data", "xml", "queries.xml"))
sybase = [_ for _ in tree.findall(".//dbms") if _.get("value") == "Sybase"][0]
tables = sybase.find("tables").find("inband").get("query")
columns = sybase.find("columns").find("inband").get("query")
self.assertEqual(tables.count("%s"), 7)
self.assertEqual(columns.count("%s"), 6)
self.assertIn("AS name", tables)
self.assertIn("usertype", columns)
# the table has to be resolved by owner, not by a bare sysobjects.name match: two owners can hold
# a same-named table, and matching on the name alone returned both tables' columns merged
self.assertIn("object_id(", columns)
self.assertNotIn("sysobjects.name='%s'", columns)
class TestErrorsXmlCompile(unittest.TestCase):
def test_all_error_regexes_compile(self):
tree = ET.parse(os.path.join(ROOT, "data", "xml", "errors.xml"))
+154 -1
View File
@@ -40,6 +40,7 @@ from extra.dbwire import mysql as _mysql
from extra.dbwire import postgres as _postgres
from extra.dbwire import presto as _presto
from extra.dbwire import recvn
from extra.dbwire import sybase as _sybase
from extra.dbwire import tds as _tds
@@ -348,6 +349,158 @@ class TdsFramingTest(unittest.TestCase):
self.assertIsNone(_tds._parse_tokens(sock)[2])
class SybaseLoginTest(unittest.TestCase):
"""The TDS 5.0 LOGINREC is one fixed-layout record: every field sits at a byte offset the server counts
on, so a field that changes width silently shifts the credentials into the reserved area."""
def _loginrec(self):
return _sybase._loginrec("dbwire", "tester", "guest1234", "dbwire", "srv", "utf8", 2048)
def test_field_offsets_match_the_fixed_layout(self):
login = self._loginrec()
self.assertEqual(len(login), 568 + 35, "LOGINREC is not the fixed 568 bytes plus a CAPABILITY token")
for offset, size, expected in ((0, 30, b"dbwire"), (31, 30, b"tester"), (62, 30, b"guest1234"),
(140, 30, b"dbwire"), (171, 30, b"srv"), (462, 10, b"dbwire"),
(525, 30, b"utf8"), (557, 6, b"2048")):
self.assertEqual(login[offset:offset + size].rstrip(b"\x00"), expected, offset)
self.assertEqual(login[offset + size:offset + size + 1], struct.pack("<B", len(expected)), offset)
self.assertEqual(login[458:462], b"\x05\x00\x00\x00", "TDS version is not 5.0.0.0")
self.assertEqual(login[202:204], b"\x00\x09", "remote password is not prefixed with its length")
self.assertEqual(login[457:458], struct.pack("<B", 11), "remote password length excludes the prefix")
def test_no_optional_capability_is_claimed(self):
"""A claimed capability makes the server answer in that format. Everything this client cannot parse
- wide ROWFMT2 rows above all - has to stay unclaimed, so the server converts down to the baseline."""
capability = self._loginrec()[568:]
self.assertEqual(bytearray(capability)[0], 0xe2)
self.assertEqual(capability[3:5], b"\x01\x0e") # request capabilities, 14 bytes
self.assertEqual(capability[5:19], b"\x00" * 14)
self.assertEqual(capability[19:21], b"\x02\x0e") # response capabilities, 14 bytes
self.assertEqual(capability[21:35], b"\x00" * 14)
class SybaseTokenTest(unittest.TestCase):
"""Fixtures are bytes recorded off ASE 16's wire."""
def _response(self, blob):
class Replay(object):
def __init__(self, data):
self.data = bytearray(data)
def recv(self, count):
chunk = bytes(self.data[:count]); del self.data[:count]; return chunk
packet = struct.pack(">BBHHBB", 4, 1, len(blob) + 8, 0, 0, 0) + blob
return _sybase.Connection(Replay(packet), "utf-8")._read_response()
def test_rowfmt_and_row(self):
blob = (b"\xee\x19\x00\x02\x00\x03one\x10\x07\x00\x00\x00\x38\x00"
b"\x03txt\x10\x02\x00\x00\x00\x27\x03\x00"
b"\xd1\x01\x00\x00\x00\x03abc"
b"\xfd\x10\x00\x02\x00\x01\x00\x00\x00")
description, rows, affected = self._response(blob)
self.assertEqual([_[0] for _ in description], ["one", "txt"])
self.assertEqual(rows, [("1", "abc")])
self.assertEqual(affected, 1)
def test_done_row_count_is_four_bytes(self):
"""Microsoft widened DoneRowCount to 8 bytes; ASE did not, so reading 8 here eats the next token."""
description, rows, affected = self._response(b"\xfd\x10\x00\x02\x00\x2a\x00\x00\x00")
self.assertIsNone(description)
self.assertEqual(affected, 42)
self.assertIsNone(self._response(b"\xfd\x00\x00\x02\x00\x2a\x00\x00\x00")[2],
"a row count without the DONE_COUNT status bit is meaningless")
def test_server_error_is_raised_and_informational_message_is_not(self):
def eed(number, severity, message):
body = (struct.pack("<i", number) + struct.pack("<3B", 1, severity, 5) + b"ZZZZZ" +
b"\x00" + struct.pack("<H", 1) + struct.pack("<H", len(message)) + message +
struct.pack("<B", 8) + b"MYSYBASE" + b"\x00" + struct.pack("<H", 1))
return b"\xe5" + struct.pack("<H", len(body)) + body
# severity 10 and below is informational - 'Changed database context' arrives on every USE
self._response(eed(5701, 10, b"Changed database context to 'master'.") +
b"\xfd\x00\x00\x02\x00\x00\x00\x00\x00")
fatal = eed(208, 16, b"nope not found. Specify owner.objectname")
try:
self._response(fatal + b"\xfd\x02\x00\x02\x00\x00\x00\x00\x00")
self.fail("a severity 16 server error was swallowed")
except dbwire.ProgrammingError as ex:
self.assertIn("not found", str(ex))
def test_packet_size_negotiation_is_honoured(self):
"""A packet bigger than the login negotiated is not refused, the connection is dropped - so the
server's answer to the requested size has to become the send chunk size."""
envchange = b"\xe3\x07\x00\x04\x04" + b"8192" + b"\x00"
class Replay(object):
def __init__(self, data):
self.data = bytearray(data)
def recv(self, count):
chunk = bytes(self.data[:count]); del self.data[:count]; return chunk
blob = envchange + b"\xfd\x00\x00\x02\x00\x00\x00\x00\x00"
connection = _sybase.Connection(Replay(struct.pack(">BBHHBB", 4, 1, len(blob) + 8, 0, 0, 0) + blob), "utf-8")
self.assertEqual(connection._chunk, _sybase._PACKET_SIZE - 8)
connection._read_response()
self.assertEqual(connection._chunk, 8192 - 8)
class SybaseDecoderTest(unittest.TestCase):
def _column(self, dtype, scale=0, usertype=0):
col = _sybase._Column()
col.name, col.type, col.size, col.scale, col.usertype = "c", dtype, 0, scale, usertype
return col
def test_numeric_is_the_mirror_image_of_the_microsoft_encoding(self):
"""ASE sends a big-endian magnitude with 1 meaning negative; Microsoft sends little-endian with 1
meaning positive. Sharing one decoder between the two silently returns wrong numbers."""
for raw, scale, expected in ((b"\x01\x00\x00\xbcaN", 3, "-12345.678"),
(b"\x00\x00\x00'\x0f", 2, "99.99"),
(b"\x00\x00\x00\x00\x01", 0, "1"),
(b"\x01\x00\x00\x00\x01", 0, "-1")):
self.assertEqual(_sybase._decode_numeric(raw, scale), expected, raw)
self.assertNotEqual(_sybase._decode_numeric(b"\x01\x00\x00\xbcaN", 3),
_tds._decode_numeric(b"\x01\x00\x00\xbcaN", 3))
def test_date_and_time_columns_survive_the_servers_conversion(self):
"""Unclaimed date/time capabilities make ASE send both as a plain datetime; only the user type still
says how much of it the column holds."""
raw = struct.pack("<iI", 46245, 0)
self.assertEqual(_sybase._decode_value(self._column(0x6f, usertype=37), raw, "utf-8"), "2026-08-13")
raw = struct.pack("<iI", 0, 11001600)
self.assertEqual(_sybase._decode_value(self._column(0x6f, usertype=38), raw, "utf-8"), "10:11:12")
raw = struct.pack("<iI", 46245, 11001704)
self.assertTrue(_sybase._decode_value(self._column(0x6f), raw, "utf-8").startswith("2026-08-13 10:11:12"))
def test_unicode_columns_are_told_apart_by_user_type(self):
"""univarchar travels as LONGBINARY: without the user type it would be hex-dumped as binary."""
raw = u"\u017eabac".encode("utf-16-le")
self.assertEqual(_sybase._decode_value(self._column(0xe1, usertype=35), raw, "utf-8"), u"\u017eabac")
self.assertEqual(_sybase._decode_value(self._column(0xe1), raw, "utf-8"), raw)
def test_integers_and_money(self):
self.assertEqual(_sybase._decode_value(self._column(0x26), b"\xc8", "utf-8"), "200") # tinyint
self.assertEqual(_sybase._decode_value(self._column(0x26), b"\xf9\xff", "utf-8"), "-7")
self.assertEqual(_sybase._decode_value(self._column(0x26), b"\xff" * 7 + b"\x7f", "utf-8"),
"9223372036854775807")
self.assertEqual(_sybase._decode_value(self._column(0x6e), b"\x00\x00\x00\x00\x40\xe2\x01\x00", "utf-8"),
"12.3456")
def test_a_zero_length_value_is_null(self):
"""TDS 5 has no separate NULL marker for a length-prefixed value - and neither has ASE."""
col = self._column(0x27)
self.assertIsNone(_sybase._read_value(col, b"\x00", 0, "utf-8")[0])
self.assertEqual(_sybase._read_value(col, b"\x03abc", 0, "utf-8")[0], "abc")
class TrinoSessionStateTest(unittest.TestCase):
"""Trino is stateless on the wire: the server reports each session change as a response header and the
client must echo it back, or USE / SET SESSION silently do nothing on the next statement."""
@@ -534,7 +687,7 @@ class HelperTest(unittest.TestCase):
self.assertRaises(dbwire.InterfaceError, recvn, FakeSocket(b"AB"), 4)
def test_every_module_exposes_the_dbapi_surface(self):
for name in ("postgres", "mysql", "tds", "firebird", "cubrid", "monetdb", "clickhouse", "presto"):
for name in ("postgres", "mysql", "tds", "sybase", "firebird", "cubrid", "monetdb", "clickhouse", "presto"):
module = __import__("extra.dbwire.%s" % name, fromlist=["connect"])
self.assertTrue(callable(getattr(module, "connect", None)), name)