SAP RFC Over WebSockets: A Detective Story With a Twist
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 layer | wsRFC |
|---|---|
Gateway 02 03 routing handshake | gone — HTTP upgrade instead |
| 4-byte NI length frame | gone — the WebSocket message is the frame |
| 80-byte APPC record header | gone |
12-byte EBCDIC RFC000000000 header | gone |
| RFC_PRO field sequence (the TLV codec) | unchanged |
| 8-byte trailer restating the length | gone |
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:
- rustls opens the TLS connection to
localhost:50001. - The upgrade request goes out; the server answers
101, negotiatingsap-rfcprot-vers: 5andsap-rfcser-vers: 3. - We send an RFC_PRO logon record as a single masked binary message.
- The server's reply is decoded with the classic
ProReader— 18 fields, opening withStart, carryingSystemId=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:
| Attempt | Upgrade result | RFC sign-on result |
|---|---|---|
| HTTP Basic on the upgrade | 101 (Basic authenticates ADT/ping nodes) | rejected — sign-on layer ignores it |
MYSAPSSO2 logon ticket as a Cookie | 101 (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:
- rustls with a client certificate.
WsTransport::connect_client_certstacks a mutual-TLS rustls config under the same WebSocket codec — the client presents its cert during the TLS handshake. - A password-free cert-logon record that erpl builds itself.
build_cert_logonemits theStart/ProtocolVersion/Capabilities/ nonce /Client/Language/ call sequence directly — noPassword, noUser, and crucially not replayed SDK bytes. erpl generates it from its own codec. - 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:
rustlssigns on asDEVELOPER;RFC_PINGreturnssubrc 0; the reply carriesSessionUser = 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.
