Skip to main content

SAP RFC Over WebSockets: A Detective Story With a Twist

· 16 min read
Joachim Rosskopf
Co-Founder & CEO

In Part 1 we recovered the classic RFC wire format from packet captures — the NI frame, the APPC header, the EBCDIC fossil, the self-terminating TLV fields — and validated a pure-Rust implementation byte-for-byte against SAP's own library and a live server. That protocol has been the way to talk to an ABAP system for two decades.

But it is no longer the only way. Recent NetWeaver kernels added a second transport for the same RFC protocol: wsRFC, RFC carried over a TLS WebSocket. Same payload, entirely different envelope. It is the transport SAP itself reaches for in cloud and reverse-proxy scenarios, where a raw gateway socket on port 33NN is awkward and an HTTPS port everyone already trusts is not.

So we taught erpl to speak it too. This post is the story of that work — and it is a detective story with a twist. The transport was the easy part. The sign-on was not, and getting to the truth of it meant being willing to prove ourselves wrong twice.

What changed, and what didn't

The first thing to understand about wsRFC is that it is additive. erpl-proto now speaks classic CPI-C and wsRFC, and the two share almost everything above the wire. wsRFC carries the exact same RFC_PRO payload — the same TLV codec from Part 1, the same ProReader/ProWriter, the same value and metadata and structure machinery. What differs is only the box it travels in.

Here is the classic onion from Part 1, and what survives the move to WebSockets:

Classic layerwsRFC
Gateway 02 03 routing handshakegone — HTTP upgrade instead
4-byte NI length framegone — the WebSocket message is the frame
80-byte APPC record headergone
12-byte EBCDIC RFC000000000 headergone
RFC_PRO field sequence (the TLV codec)unchanged
8-byte trailer restating the lengthgone

Five of the six layers evaporate. The record boundary that classic RFC computes from an NI length field is now simply a WebSocket message boundary. A wsRFC message begins directly with the first RFC_PRO field — a Start tag 0x0101 — with no preamble at all. Everything the earlier post said about tags, lengths, the repeated trailing tag, the 0xffff end marker, UTF-16LE strings, the one-character language code — all of it still holds, unchanged, inside the WebSocket message body.

That is a good position to be in. Most of the hard-won codec from Part 1 is reused verbatim; only the outermost wrapper is new.

The new wrapper

wsRFC is a standard [RFC 6455] WebSocket over TLS to the ICM's HTTPS port (on our a4h trial, 50001). There is no plaintext ws:// — TLS is intrinsic. The connection opens with an ordinary HTTP/1.1 upgrade, and this is where two details matter.

First, HTTP/1.1 is required. The ICM offers HTTP/2 by default, but the RFC client does not use HTTP/2's (RFC 8441) WebSocket upgrade; it wants the classic Connection: Upgrade dance. Second, version negotiation happens in HTTP headers, not in the RFC_PRO payload. The upgrade request looks like this:

GET /sap/bc/rfc?sap-apc-stateful=true HTTP/1.1
host: <wshost>:<wsport>
sec-websocket-protocol: rfc.sap.com
sap-client: 001
sap-language: E
sap-rfc-subtype: sync
sap-rfcpro-suppvers: 5-5
sap-rfcser-suppvers: 2-3
upgrade: websocket
connection: Upgrade
sec-websocket-version: 13
sec-websocket-key: <base64>

The subprotocol is rfc.sap.com. The sap-rfcpro-suppvers and sap-rfcser-suppvers headers advertise the RFC_PRO protocol versions and the serialization versions the client supports, as inclusive ranges. The server picks one of each and answers:

HTTP/1.1 101 Switching Protocols
sap-rfcprot-vers: 5
sap-rfcser-vers: 3
sec-websocket-accept: <base64>

From 101 onward the socket carries binary WebSocket messages (opcode 0x2), client frames masked per RFC 6455, and each message body is an RFC_PRO field sequence decoded by the same ProReader from Part 1. The ?sap-apc-stateful=true query parameter keeps the ABAP session pinned across messages — RFC is stateful, and the ICM's connection pooling would otherwise hand each message to a different work process.

One more note worth recording: the upgrade itself needs no HTTP authentication. An unauthenticated GET returns 101. Hold that thought — it becomes the whole plot.

The transport was the easy part

Building the client came together quickly. We wrote a sans-IO RFC 6455 codec in erpl-proto-wire::ws — frame parsing, masking, continuation reassembly, and the upgrade build/parse — and a WsTransport (behind a wsrfc feature) that stacks rustls TLS under it and speaks the HTTP/1.1 upgrade. No new crypto stack, no ICU, same pure-Rust posture as the classic transport.

Then we pointed it at the live a4h trial and it worked end to end:

  1. rustls opens the TLS connection to localhost:50001.
  2. The upgrade request goes out; the server answers 101, negotiating sap-rfcprot-vers: 5 and sap-rfcser-vers: 3.
  3. We send an RFC_PRO logon record as a single masked binary message.
  4. The server's reply is decoded with the classic ProReader — 18 fields, opening with Start, carrying SystemId=A4H, the kernel version, the system codepage, and the program name.

That last step is the proof that the reuse is real: bytes off a TLS WebSocket, handed to the exact decoder written for a raw gateway socket in Part 1, parse cleanly. TLS + WebSocket + upgrade + RFC_PRO framing + decode, validated live.

And then we tried to actually log on, and everything stopped.

The wall

Our model, carried straight over from classic RFC, was simple: the credential is an in-band Password field inside the RFC_PRO logon record — seventeen bytes, a 4-byte seed plus a scrambled password, exactly as in Part 1. So we built that record, sent it over the WebSocket, and the server answered with an RFC_PRO AbapErrorMessage (0x0402, the same failure channel from Part 1):

Name or password is incorrect (repeat logon)

Every variant of the scrambled password we could construct was rejected the same way. Our first theory was that our bundled CommonCryptoLib and SDK generation (7.50 / 753) was simply too old to match the a4h kernel (7.58), and that the password scrambling had changed underneath us. It was a plausible story. It was also wrong.

The trouble with that theory is that Part 1's whole method depends on a working reference capture: you cannot specify what you cannot observe, and you validate against something known-good. We had no working wsRFC sign-on to capture. We had no ≥7.58 SDK to produce one. Without a reference, we were guessing at bytes — and guessing is exactly what the method forbids.

Borrowing the kernel as an oracle

The insight that broke it open is the same move Part 1 made with SAP's SDK, turned one level inward: the a4h kernel is itself a working wsRFC client.

ABAP can build a transient WebSocket destination and make the kernel dial out and sign on, all in a few lines:

DATA(dest) = cl_dynamic_destination=>create_wsrfc_destination(
server = '172.17.0.1'
service_number = '<proxy port>'
logon_user = 'DEVELOPER'
logon_password = '<password>' ).

So the kernel — which unquestionably knows how to produce a valid 7.58 wsRFC sign-on — can be made to produce one on demand. The only problem is that it does so over TLS, and we need to read it in cleartext.

That is where the capture trick comes in. We stood up a local TLS-terminating proxy and pointed the kernel's destination at it (server = 172.17.0.1, the Docker host). For the kernel to talk to the proxy, the kernel has to trust the proxy's certificate — so we added the proxy cert to the ICM's SSL client PSE:

sapgenpse maintain_pk -a proxy.crt -p SAPSSLC.pse

and then hot-reloaded it with the function module ICM_SSL_PSE_CHANGED, so no restart was needed. Now the kernel dials the proxy, the proxy terminates the kernel's TLS with a certificate the kernel trusts, records the plaintext, and forwards it on. The kernel signs on; we read every byte.

There was one sharp gotcha, and it is worth writing down so nobody rediscovers it: the proxy certificate needs an IP: subjectAltName. The SAP client enforces RFC 2818 hostname matching, and because the destination's server is a bare IP, the cert must carry subjectAltName = IP:172.17.0.1. Without it the client aborts before sending anything, with SSSLERR_SERVER_CERT_MISMATCH — a message that reads like a trust-store problem but is really a name-matching one.

This is the same spirit as Part 1's use of libsapnwrfc as a differential oracle, one turn deeper. There the reference was SAP's SDK. Here it is SAP's kernel. In both cases we treat SAP's own software as a black box that emits correct bytes, and specify from those bytes — never from anyone's source.

What the capture actually showed

The capture demolished our model. The credential is not in the RFC_PRO record at all. The logon record the working kernel sent carries no Password field — just Start, ProtocolVersion, Capabilities, the session nonces, User, Client, Language (E), a connection type of W, and the call itself (Function = STFC_CONNECT…). We had been staring at the wrong layer the entire time.

The credential is an HTTP request header on the upgrade: sap-r3auth. And it is wrapped in three layers:

sap-r3auth = hex( base64( "v=1U," + <263-byte credential blob> ) )

Peeled from the outside in: the header value is hex; decode the hex and you get base64; decode the base64 and you get an ASCII prefix v=1U, (version 1, type U for user/password) followed by a 263-byte binary blob. That blob is the credential. The RFC_PRO logon record is, as far as authentication goes, empty.

So the unauthenticated 101 upgrade we noted earlier was not the whole story: the sign-on is authenticated, but on the HTTP upgrade, in a header we had not been sending, in a format we could not have guessed.

One door was locked

Part 1 ended with a section called "How we know it's right." This one needs its mirror image — but with a twist Part 1 never had. Finding the sap-r3auth header was not the end. The real question was whether an external, clean-room client could produce one. We proved, several ways and all live against a4h, that for password sign-on it cannot. That proof still stands, and it is the honest boundary around one of the two doors into wsRFC.

The blob is per-session bound. Two sign-ons with byte-identical credentials produce entirely different 263-byte blobs; only the v=1U, prefix is stable, and the length is constant. So it is not a static encrypted password you could capture and replay — it is minted fresh, bound to per-session material.

It is not RSA-encrypted to the server's certificate. This one we could test cleanly, because the proxy held the private key for the certificate the client actually saw. Decrypting the blob with that key fails under OAEP and yields garbage under PKCS#1 v1.5. Whatever the kernel is doing, it is not "encrypt the password to the server's TLS public key" — the obvious hypothesis, ruled out with the one key that would have proven it.

The standards-based password alternatives are refused. We tried every password-style path the ICF layer exposes, to see whether any bypassed the token:

AttemptUpgrade resultRFC sign-on result
HTTP Basic on the upgrade101 (Basic authenticates ADT/ping nodes)rejected — sign-on layer ignores it
MYSAPSSO2 logon ticket as a Cookie101 (system issues & accepts SSO2)rejected — sign-on reads only sap-r3auth

HTTP Basic works for the ADT and ping ICF nodes over the same HTTPS port — but the RFC sign-on layer does not consult it. A MYSAPSSO2 ticket authenticates ADT too, and the system is configured to issue and accept them — but the sign-on layer reads only sap-r3auth, never the cookie jar.

The clincher: the token isn't even an encrypted password. We built a transient destination with x509 = 'X' and no password at all, and captured what it sent. It emitted the same sap-r3auth: v=1U,<263-byte> header. So the password-path token is not an encrypted password — there was no password to encrypt. It is a kernel-generated, session-bound assertion, proprietary CommonCryptoLib output that an external client cannot synthesize.

That is a real wall, and we can name every brick in it. The password credential is neither an encrypted password nor a standards-based token; there is no byte sequence we can observe and re-derive, because the bytes are minted from secrets we do not hold and change every session. One door into wsRFC is locked, and locked on purpose.

We very nearly ended the story there. In an earlier draft we did — we had also tried an X.509 client certificate, watched it get rejected at sign-on, and written it into that same table as a third bar on the same cage. That conclusion was wrong. Being willing to re-run the test behind it is what turned "blocked" into "solved."

The second door: X.509 cert-logon

What reopened the case was a mode of SAP's own SDK we had not exercised: tls_client_certificate_logon=1. Run directly against a4h, it gets past sign-on. The only error left is a downstream UCON call-allowlist check — authorization, not authentication. The identity is accepted; the call simply is not on the allowlist yet.

So we captured what that mode sends, and the difference from the password path is total. Its upgrade request carries no sap-r3auth header at all. Its RFC_PRO logon record carries no Password and no User field — just Start, ProtocolVersion, Capabilities, a client session nonce, Client, Language, and the call. Identity comes entirely from the TLS client certificate. This is a second, independent sign-on mode, and it touches none of the proprietary crypto that gates the first.

Which exposed our mistake. Our failed X.509 attempt had reused the password logon record — the one with a Password field — over a mutual-TLS connection. That record forces the password sign-on path, sap-r3auth and all, no matter what certificate sits underneath it. We had proven that "password sign-on plus a client cert" still needs sap-r3auth, and mislabeled it "X.509 doesn't help." The certificate was never the credential in that test; the record was.

With the correct, password-free cert-logon record, X.509 sign-on works — and it works in pure Rust. The reproduction needs only three things, none of them proprietary:

  1. rustls with a client certificate. WsTransport::connect_client_cert stacks a mutual-TLS rustls config under the same WebSocket codec — the client presents its cert during the TLS handshake.
  2. A password-free cert-logon record that erpl builds itself. build_cert_logon emits the Start / ProtocolVersion / Capabilities / nonce / Client / Language / call sequence directly — no Password, no User, and crucially not replayed SDK bytes. erpl generates it from its own codec.
  3. A one-time cert mapping on a4h — an ordinary, documented SAP cert-logon setup, not a workaround. Map the client certificate to the user with SUSR_CERT_ASSIGN (self-service: the DER cert assigned to the calling user), and trust it in the ICM server PSE:
sapgenpse maintain_pk -a client.crt -p SAPSSLS.pse

then reload it with ICM_SSL_PSE_CHANGED. The HTTPS port already runs verify_client = 1, so the ICM asks for the cert.

With that in place, the result is the one we had been chasing the whole post — live against a4h, from a pure-Rust rustls client:

rustls signs on as DEVELOPER; RFC_PING returns subrc 0; the reply carries SessionUser = DEVELOPER.

No sap-r3auth. No SDK. No ICU. No CommonCryptoLib. An X.509 certificate and a record erpl wrote itself, and the ABAP system signs us on and answers a function call.

Why the wrong turns matter

It is worth being blunt about how close this came to being filed as a defeat, because the near-miss is the point. Our first theory was that our CommonCryptoLib was too old — wrong. Our second, after the capture, was that wsRFC sign-on is uniformly gated by a proprietary token — wrong again, and we had a table to "prove" it. The method that saved us is the same one from Part 1: capture, verify, and be willing to re-run the test behind a conclusion you have already written down.

The sap-r3auth password path really is proprietary; that finding is true and it stands. What was false was the leap from "the password token is unreproducible" to "wsRFC is unreproducible." Two independent doors, and we had only rattled the locked one. Being willing to walk back a wrong conclusion — twice — is exactly what turned a wall into a working sign-on.

Where this leaves erpl

erpl can talk RFC over WebSockets today, in pure Rust, authenticated by an X.509 client certificate — no SDK, no ICU, no CommonCryptoLib anywhere in the path. The transport (TLS, WebSocket, HTTP/1.1 upgrade, version negotiation, RFC_PRO round-trip) and now the sign-on are both validated live against a real ABAP system. For password authentication, the classic CPI-C transport from Part 1 is still there, still in production — so users have both.

And the engineering is done. A single Transport trait puts classic NI and the WebSocket wire behind one interface, so the same invoke / read_table / describe / describe_structure code runs over either — the classic live suite passes through it unchanged, and the wsRFC suite exercises the full surface (typed integers, strings, xstrings, non-ASCII, metadata, DDIC structures, table reads) over TLS. The one server-side step is releasing the called function modules for external RFC in UCON ("callable from other systems") — an administrator setting, not a protocol question. The open item WS-9 — how does wsRFC sign-on work? — is solved: X.509 client-certificate logon, reproduced in a clean-room Rust client.

Performance is what you'd expect from adding TLS: on a repeated-call loop wsRFC runs about a quarter slower than raw CPI-C (encryption and WebSocket framing per message), and on a bulk table read the gap narrows to ~15%. Memory is the part we care about most — the reader streams: folding a table row-by-row holds one page, not the whole result, so peak memory stays flat at tens of megabytes whether you read fifty thousand rows or a million, on either transport. A 27-million-row D010TAB never has to fit in RAM.

Two transports now share one codec, and both can authenticate. The envelope changed; the protocol did not. And the one door that stays locked, we can point to by name — while walking through the one beside it.