Everything here was reverse-engineered against a real iLO2 (firmware 2.22,
web UI copyright strings 2001-2005/2018) by inspecting live traffic and
decompiling the Remote Console applet (rc175p11.jar, class
com.hp.ilo2.remcons.*, banner string Version 20050808154652 --
this codec dates to ~2005 RILOE-era firmware, carried forward unchanged).
None of this is documented anywhere official; treat firmware-version
differences as a real possibility if this stops matching what you see.
Two separate, stacking problems:
- iLO2's HTTPS only speaks TLS 1.0 (confirmed via
openssl s_client). Every current browser refuses TLS below 1.2. This is theSSL_ERROR_UNSUPPORTED_VERSIONFirefox shows. - Even forcing TLS 1.0, the DHE key exchange iLO2 offers by default
hangs modern TLS stacks outright -- the client sends its Finished
message and iLO2 never replies (confirmed with both LibreSSL and
OpenSSL 3.x clients; verified at the record layer with
-msg -state). Forcing a plain RSA-key-exchange cipher (AES128-SHA, no forward secrecy, no DHE) makes the handshake complete normally. This is the one setting that actually matters inilo2/legacy_tls.py::_connect():ssl.TLSVersion.TLSv1min/max plusset_ciphers("AES128-SHA").
Separately, the Remote Console's "video" tab is a Java NPAPI applet. No browser has supported NPAPI since ~2015-2017, so even a hypothetical TLS-1.0-tolerant browser couldn't run it. This is why this project exists as a reimplementation instead of "just fix the TLS".
There's also an "Integrated Remote Console" option in the UI: that's ActiveX, IE-only, and was never in scope here.
GET / returns a login page containing two JS globals:
var sessionkey="<40-char one-time nonce>";
var sessionindex="<8-hex-digit counter>";The login button's JS builds a cookie value (function MakeCookie /
createToken in the page's inline <script>):
token = sessionindex + ":" + b64(username) + ":" + b64(password) + ":" + sessionkey
(standard base64, no LDAP dn component for local accounts) and sets it
as hp-iLO-Login, then navigates to /index.htm. The server validates
that cookie and responds with Set-Cookie: hp-iLO-Session=<idx>:::<key>
(note: three colons, format observed as
sessionindex:::sessionkey). That hp-iLO-Session cookie is what every
subsequent authenticated page load needs.
sessionkey appears to be single-use / short-lived: the fetch-key and
build-and-send-login steps need to happen back-to-back (seconds apart),
not with a long pause in between, or you'll get rejected. See
DEVELOPMENT.md for the "sessionkey="NONEAVAILABLE"" failure mode this is
related to.
Implementation: ilo2/session.py::IloSession.login().
iLO2 predates the HTTP-wrapped RIBCL that iLO3+ use. On iLO2, you connect to port 443 with TLS, and instead of an HTTP request you send raw XML directly on the socket:
<?xml version="1.0"?>\r\n
<RIBCL VERSION="2.0">
<LOGIN USER_LOGIN="..." PASSWORD="...">
<SERVER_INFO MODE="write"><SET_HOST_POWER HOST_POWER="Yes"/></SERVER_INFO>
</LOGIN>
</RIBCL>
Two details that matter and cost real debugging time:
- The XML header and the RIBCL body must land in two separate TCP
writes (two
sendall()calls), not one combined buffer. Python'ssocket.sendall()on a single concatenated buffer usually goes out as one TCP segment/TLS record and the firmware never replies to that. This is whypython-hpilo(a prior Python client for HP iLO, which was useful for cross-checking this behavior) calls it "RAW" mode. - Auth is per-request (
USER_LOGIN/PASSWORDinside the XML), so this is entirely independent of the web UI's session-cookie login -- RIBCL calls keep working even when the web UI is refusing new logins.
Response framing: the read loop just reads until the server closes/times
out; several <RESPONSE STATUS="0x0000".../> blocks typically come back
even for a single command (informational chatter), with the real result
(if any) inside the last one.
Useful commands beyond power control:
<RIB_INFO MODE="write"><RESET_RIB/></RIB_INFO> reboots just the iLO2
management processor (not the host), which is the fix for the web-UI
session exhaustion issue -- see DEVELOPMENT.md.
Implementation: ilo2/legacy_tls.py::ribcl_raw(),
ilo2/session.py's RIBCL methods.
Loading GET /drc2fram.htm?restart=1 (authenticated) does two things:
- Returns the HTML that would embed the Java applet, with its
<PARAM>values inlined as JS variables (see below). - Tells the firmware to open the KVM TCP listener (port given by
INFO6, always23in what we've seen) for a short window. Before this request, port 23 refuses connections; a few seconds after, it accepts one connection and then appears to close the window again. If you seeConnectionRefusedErroron port 23, this is almost always "fetch_console_params() wasn't recent enough" rather than a real problem -- just retry the whole login → fetch_params → connect sequence.
Also on this page: rcseize_rcinuse=1 means another client already has
the console session and you won't get real encryption keys (INFOA/B/
C/D come back empty/None) until it's released -- see
DEVELOPMENT.md.
Relevant PARAMs (all scraped by
IloSession.fetch_console_params()):
| Param | Meaning |
|---|---|
INFO0 |
Base64 login ticket, decodes to 0x<sessionindex-hex>\r<32-hex-char token> (see §5) |
INFO1 |
Present/absent flag only -- its value is unused, just whether the key exists (see §5) |
INFO6 |
KVM TCP port (observed: always 23) |
INFOA |
"1" if the DVC stream is RC4-encrypted, absent/"0" otherwise |
INFOB |
32 hex chars = 16-byte RC4 seed for the decrypt direction (server→client) |
INFOC |
32 hex chars = 16-byte RC4 seed for the encrypt direction (client→server) |
INFOD |
Decimal key index, sent in the encrypted-login header |
The applet's own base64 decode for INFO0 uses the standard alphabet but
post-processes decoded byte 0x3A (:) back into 0x0D (CR) -- the
server substitutes CR with : before base64-encoding, presumably because
raw CR inside an HTML attribute is awkward. ilo2/console.py::_decode_login_ticket()
reverses this.
- TCP connect to
host:INFO6. - Build the login string (
ilo2/console.py::build_login_string()):login = decode_login_ticket(INFO0) # "0x<idx>\r<32-hex-token>\r" if INFO1 is present: login = "\x1b[4" + login login = "\x1b[7\x1b[9" + login - If
INFOA == "1"(encryption on):Thekey_index = int(INFOD) header = bytes([0xFF, 0xC0]) + key_index.to_bytes(4, "big") send(header + RC4(seed=INFOC).xor(login))0xFF 0xC0+ 4-byte key index go out unencrypted; the RC4 keystream starts exactly at the login string's first byte. This is one continuous keystream for the rest of the connection -- every later keyboard/mouse/control message is XORed with the next bytes of the same stream, there's no per-message IV or re-sync. If encryption is off, the login string is sent as plain bytes with no header at all. - Read the socket, scanning byte-by-byte (not decrypting yet) for the
3-byte sequence
ESC [ R(switch to DVC mode, RC4-encrypted) orESC [ r(DVC mode, plaintext). Before this trigger you'll typically see plain Telnet option negotiation (IAC WILL/DO ..., i.e.0xFF 0xFB/0xFD ...) -- this class inherits fromcom.hp.ilo2.remcons.telnet, hence the leftover Telnet framing. - From the byte after the trigger, every byte belongs to the DVC video
stream (§6). If the trigger was
R, XOR each byte with the next keystream byte from a separateRC4(seed=INFOB)instance first -- this is the decrypt direction, independent from the encrypt one.
Not textbook RC4-with-seed-as-key. The applet's RC4 class:
key = MD5(seed || key) # key starts as 16 zero bytes
KSA using this 16-byte MD5 digest, cycling key[i % 16]
standard RC4 PRGA from there
RC4.update_key() re-derives key = MD5(seed || current_key) and re-runs
the KSA -- this is what a "change key" firmware command (§6, CORP
command 9) triggers on both directions' RC4 objects simultaneously
(IloConsole._on_change_key()).
All sent via IloConsole.transmit() (i.e. RC4-XORed with the ongoing
encrypt keystream, same as the login):
| Action | Bytes |
|---|---|
| Refresh screen | ESC [ ~ |
| Keepalive | ESC [ ( |
| Auto-alive | ESC [ & |
| Ctrl+Alt+Del | ESC [ 2 ESC [ 0x7F |
| Mouse relative move | 0xFF 0xD0 <dx:i8> <dy:i8> |
| Mouse button press | 0xFF 0xD1 <button> |
| Mouse button release | 0xFF 0xD2 <button> |
| Mouse click | 0xFF 0xD3 <button> <count> |
Mouse button codes: left=4, center=2, right=1 (yes, that ordering).
This project only implements the relative mouse protocol
(mouse_protocol == 0 in the original applet); the applet also supports
an absolute/high-performance mode with server-side cursor sync
(MouseSync.class) that wasn't reimplemented.
Keyboard bytes for plain printable characters are just their US-ASCII
value. Special keys use ESC [ <letter> sequences (see the SPECIAL
table in web/index.html for the ones implemented: arrows,
Home/End/PgUp/PgDn/Insert, F1-F12,
Tab/Enter/Backspace/Delete/Escape). The original applet has a much larger
table covering Shift/Ctrl/Alt-modified variants of every special key
(cim.translate_special_key() in the decompiled source) that wasn't fully
ported -- see DEVELOPMENT.md's keyboard-layout note for why this mostly
doesn't matter for BIOS/OS console use, and grep the decompiled sources
(§7) if you need a specific missing combo.
"DVC" (the name comes from internal debug strings; likely "Digital Video
Compression" or similar) is a proprietary differential/run-length pixel
codec, implemented as a 48-state bit-oriented state machine. Ported
almost line-by-line into ilo2/dvc.py::DvcDecoder from the decompiled
cim.process_bits() -- deliberately not cleaned up into something more
"idiomatic", because the state graph has zero documentation anywhere and
preserving the original shape is what makes it possible to diff against
the decompiled Java when something doesn't decode right.
Bits are consumed LSB-first out of an accumulator fed one byte at a
time (add_bits), but multi-bit fields are bit-reversed on the way
out (get_bits, via a precomputed 256-entry reversal table) -- a classic
trick for making an LSB-first shift register emit fields as if it were
MSB-first. add_bits also tracks a running count of leading/trailing zero
bits per byte (via dvc_left/dvc_right lookup tables) to detect a long
run of zero bits, which is used as an explicit resync/reset marker
(HUNT state) -- every DVC stream we've seen starts with one, and it
recurs periodically (looked like roughly once per full frame in testing).
Seeing "reset sequence detected" / "unexpected hit" messages in the log
during this resync is normal, not a decode error -- it happens before
the state machine has re-synced, by design.
Each of the 48 states has a fixed number of bits it consumes
(BITS_TO_READ[state]); the value of those bits picks the next state via
NEXT_0[state]/NEXT_1[state] (a literal 1-bit-of-context Huffman-ish
dispatch, not a real Huffman table). Broad groups of states:
- Pixel color, LRU-cached (
PIXLRU0/1,PIXCODE1-4,PIXGREY,PIXRGBR/G/B): a 17-entry LRU cache of recently-used colors (dvc_cc_*arrays) lets a repeated color cost ~log2(17) bits instead of a full RGB444 (12-bit) literal.PIXRGBR→PIXRGBG→PIXGREY/PIXRGBBis the path for an actual new 12-bit color literal, expanded to 8-bit per channel via nibble replication (n * 17) incolor_remap_table. - Run-length / repeat (
PIXRPT,PIXRPTSTD1/2,PIXRPTNSTD,PIXDUP): repeat the last-emitted color N times within the current 16x16 block. - Block-level (
BLKRPT,BLKRPTSTD,BLKRPTNSTD,BLKDUP): repeat/duplicate a whole completed 16x16 block N times, advancingdvc_lastx/dvc_lasty-- this is how large solid-color regions get compressed cheaply. - Positioning (
MOVEXY0/1,MOVESHORTX,MOVELONGX): set the current block cursor (in 16px units). - Frame setup (
MODE0/1/2):MODE1setsdvc_size_x/dvc_size_y(in 16px block units),MODE2finalizes actual pixel dimensions (screen_x = size_x*16,screen_y = size_y*16 + clip) and is wherevideo_detectedgets computed -- zero width/height here means the iLO2 hardware genuinely isn't capturing a video signal right now, not a decode bug (see DEVELOPMENT.md, "No Video"). - Firmware/control channel (
FIRMWARE,CORP): a side-channel command protocol.CORPdispatches on an accumulated command byte (cmd_last):1=graceful DVC exit,2=start of a print/status string (PRINT0/PRINT1, terminated by a0byte, routed toon_status(field, text)-- this is where things like refresh rate/resolution status text comes from),3=set framerate,6="video suspended",7/8=start/stop an RDP terminal-services launch (not applicable here, ignored),9=rotate both RC4 keys (on_change_key),10=seize: another client has taken over the console and this one should disconnect (on_seize). - Error/resync (
LATCHED,HUNT,TIMEOUT,EXIT):LATCHEDis the generic "something didn't decode as expected" state; it has a self-healing counter that requests a fresh full-frame refresh (refresh_screen()) after enough bytes pass through it without recovering on its own.
DvcDecoder.on_block(x, y, pixels) fires once a 16x16 block is complete,
with pixels a flat list of up to 256 packed 0xRRGGBB ints (row-major).
on_resize(w, h) fires when MODE2 establishes real dimensions.
The consumer (FrameBuffer) just pastes each block into an image at
(x, y).
If a firmware update changes something and this stops matching:
- Log into the iLO2 web UI, grab
/drc2fram.htm?restart=1and/<applet-archive>.jar(the archive filename is in the<APPLET ARCHIVE=...>tag on that page -- wasrc175p11.jarhere) the same wayilo2/session.pydoes. - Decompile with CFR (a single jar,
java -jar cfr.jar the.jar --outputdir out/) -- worked cleanly on this firmware's class files with no manual fixups needed. com.hp.ilo2.remcons.remconsis the applet entry point (param parsing, UI);cimis the protocol/codec (extendstelnet, which owns the raw socket and the plaintext-preamble scanning);RC4/VMD5are the crypto (VMD5is plain unmodified MD5 -- verified against the RFC 1321 constants -- sohashlib.md5is a safe substitute).