From 29c1747cbe8aa97b4d24c8a158d47fec50c82c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:08:19 -0300 Subject: [PATCH] fix(xcat-server): retry suite 3 on zero RAKP2 HMAC (cherry picked from commit 695dd485cfe5dd4beb9be71ffa1ab4a80c9b2619) --- xCAT-server/lib/perl/xCAT/IPMI.pm | 36 ++- xCAT-test/autotest/testcase/ipmi_unit/cases0 | 8 + xCAT-test/unit/ipmi_rakp2.t | 301 +++++++++++++++++++ 3 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 xCAT-test/autotest/testcase/ipmi_unit/cases0 create mode 100644 xCAT-test/unit/ipmi_rakp2.t diff --git a/xCAT-server/lib/perl/xCAT/IPMI.pm b/xCAT-server/lib/perl/xCAT/IPMI.pm index 08d81fcd7..7afeb85ea 100644 --- a/xCAT-server/lib/perl/xCAT/IPMI.pm +++ b/xCAT-server/lib/perl/xCAT/IPMI.pm @@ -260,6 +260,9 @@ sub login { $self->{onlogon} = $args{callback}; $self->{onlogon_args} = $args{callback_args}; $self->{logontries} = 5; + if (delete $self->{zero_rakp2_fallback}) { + $self->{attempthash} = 256; + } $self->get_channel_auth_cap(); } @@ -446,6 +449,17 @@ sub got_channel_auth_cap { } +sub retry_rmcpplus_after_zero_rakp2 { + my $self = shift; + return 0 if ($self->{attempthash} != 256 or $self->{zero_rakp2_fallback}); + + $self->{zero_rakp2_fallback} = 1; + $self->{attempthash} = 1; + $self->{sessionestablishmentcontext} = 0; + $self->open_rmcpplus_request(); + return 1; +} + sub open_rmcpplus_request { my $self = shift; $self->{'authtype'} = 6; @@ -850,7 +864,7 @@ sub send_rakp1 { sub init { my $self = shift; - $self->{attempthash} = 256; + $self->{attempthash} = $self->{zero_rakp2_fallback} ? 1 : 256; $self->{confalgo} = undef; $self->{integrityalgo} = undef; $self->{sessionestablishmentcontext} = 0; @@ -964,12 +978,12 @@ sub got_rakp2 { #the reason being that if an old rakp1 retry actually made it and we were just too aggressive, then a previous rakp2 is invalidated and invalid session id or the integrity check value is bad return 9; #now's not the time for this response, ignore it } - unless ($byte == $self->{rmcptag}) { + my $tag_matches = $byte == $self->{rmcptag}; + my $sid_matches = scalar(@data) >= 7 && + pack("C4", @data[3..6]) eq pack("C4", @{ $self->{sidm} }); + unless ($tag_matches) { return 9 unless $byte == 0; - my @sid_check = @data[3..6]; - unless (pack("C4", @sid_check) eq pack("C4", @{ $self->{sidm} })) { - return 9; - } + return 9 unless $sid_matches; } $byte = shift @data; unless ($byte == 0x00) { @@ -1016,8 +1030,18 @@ sub got_rakp2 { my $ulength = scalar @user; my $hmacdata = pack("C*", (@{ $self->{sidm} }, @{ $self->{pendingsessionid} }, @{ $self->{randomnumber} }, @{ $self->{remoterandomnumber} }, @{ $self->{remoteguid} }, $self->{rakp_privbyte}, $ulength, @user)); my @expectedhash = (unpack("C*", $self->{hshfn}->($hmacdata, $self->{password}))); + my $zero_authcode = $tag_matches && $sid_matches && + $self->{attempthash} == 256 && scalar(@data) == 32 && + !grep { $_ } @data; foreach (0 .. (scalar(@expectedhash) - 1)) { if ($expectedhash[$_] != $data[$_]) { + # Some BMCs accept suite 17 but return an invalid all-zero + # SHA256 RAKP2 code. Never accept it; retry suite 3 once. + if ($zero_authcode and + $self->{sessionestablishmentcontext} == STATE_EXPECTINGRAKP2 and + $self->retry_rmcpplus_after_zero_rakp2()) { + return 9; + } $self->{sessionestablishmentcontext} = STATE_FAILED; $self->{onlogon}->("ERROR: Incorrect password provided", $self->{onlogon_args}); return 9; diff --git a/xCAT-test/autotest/testcase/ipmi_unit/cases0 b/xCAT-test/autotest/testcase/ipmi_unit/cases0 new file mode 100644 index 000000000..1d3448314 --- /dev/null +++ b/xCAT-test/autotest/testcase/ipmi_unit/cases0 @@ -0,0 +1,8 @@ +start:ipmi_rakp2_unit_tests +description:Run the IPMI RAKP2 Perl unit tests through xcattest +os:Linux +label:mn_only,ci_test,ipmi,unit,ipmi_unit +cmd:prove -I/opt/xcat/lib/perl -I/opt/xcat/lib/perl/xCAT /opt/xcat/share/xcat/tools/autotest/unit/ipmi_*.t +check:rc==0 +check:output=~All tests successful +end diff --git a/xCAT-test/unit/ipmi_rakp2.t b/xCAT-test/unit/ipmi_rakp2.t new file mode 100644 index 000000000..07a6a802a --- /dev/null +++ b/xCAT-test/unit/ipmi_rakp2.t @@ -0,0 +1,301 @@ +#!/usr/bin/env perl + +use strict; +use warnings; +## no critic (Modules::RequireFilenameMatchesPackage, TestingAndDebugging::ProhibitNoStrict, TestingAndDebugging::ProhibitNoWarnings) + +BEGIN { + package xCAT::SvrUtils; + sub import { } + $INC{'xCAT/SvrUtils.pm'} = __FILE__; + + package Crypt::Rijndael; + sub import { } + $INC{'Crypt/Rijndael.pm'} = __FILE__; + + package Crypt::CBC; + sub import { } + $INC{'Crypt/CBC.pm'} = __FILE__; + + package Digest::HMAC_SHA1; + sub import { + my $caller = caller; + no strict 'refs'; + *{ $caller . '::hmac_sha1' } = \&Digest::SHA::hmac_sha1; + } + $INC{'Digest/HMAC_SHA1.pm'} = __FILE__; +} + +package main; + +no warnings qw/once redefine/; +use Digest::SHA (); +use FindBin; +use lib "$FindBin::Bin/../../xCAT-server/lib/perl"; +use Test::More; + +require xCAT::IPMI; + +my @console_sid = (0x15, 0x58, 0x25, 0x7a); +my @managed_sid = (0x7c, 0x5a, 0xfd, 0xc3); + +sub new_session { + my (%overrides) = @_; + my $errors = delete($overrides{errors}) || []; + return bless { + attempthash => 256, + hshfn => \&xCAT::IPMI::hmac_sha256, + hshln => 16, + ipmiversion => '2.0', + localsid => 0x1558257a, + onlogon => sub { push @{$errors}, $_[0]; }, + onlogon_args => undef, + password => 'correct-password', + pendingsessionid => [@managed_sid], + privlevel => 4, + rakp_privbyte => 0x14, + randomnumber => [0x10 .. 0x1f], + rmcptag => 3, + sessionestablishmentcontext => xCAT::IPMI::STATE_EXPECTINGRAKP2(), + sessionid => [0, 0, 0, 0], + sequencenumberbytes => [0, 0, 0, 0], + sidm => [@console_sid], + userid => 'test', + %overrides, + }, 'xCAT::IPMI'; +} + +sub rakp2_payload { + my ($session, $authcode) = @_; + my @remote_random = (0x20 .. 0x2f); + my @remote_guid = (0x30 .. 0x3f); + return ( + $session->{rmcptag}, 0, 0, 0, @{$session->{sidm}}, + @remote_random, @remote_guid, @{$authcode} + ); +} + +sub valid_rakp2_payload { + my ($session) = @_; + my @remote_random = (0x20 .. 0x2f); + my @remote_guid = (0x30 .. 0x3f); + my @user = unpack('C*', $session->{userid}); + my $hmacdata = pack( + 'C*', + @{$session->{sidm}}, @{$session->{pendingsessionid}}, + @{$session->{randomnumber}}, @remote_random, @remote_guid, + $session->{rakp_privbyte}, scalar(@user), @user + ); + my @authcode = unpack( + 'C*', + $session->{hshfn}->($hmacdata, $session->{password}) + ); + return ( + $session->{rmcptag}, 0, 0, 0, @{$session->{sidm}}, + @remote_random, @remote_guid, @authcode + ); +} + +sub valid_rakp4_payload { + my ($session) = @_; + my @authcode = unpack( + 'C*', + $session->{hshfn}->( + pack( + 'C*', + @{$session->{randomnumber}}, + @{$session->{pendingsessionid}}, + @{$session->{remoteguid}} + ), + $session->{sik} + ) + ); + return ( + $session->{rmcptag}, 0, 0, 0, @{$session->{sidm}}, + @authcode[0 .. ($session->{hshln} - 1)] + ); +} + +# Emulate the defective BMC firmware behavior reported in +# https://github.com/xcat2/xcat-core/issues/7512: suite 17 succeeds, but the +# RAKP2 response contains a 32-byte all-zero authentication code. +my @captured_zero_rakp2 = unpack( + 'C*', + pack( + 'H*', + '030000001558257a7fbac96cbf09227865a9cc62745e3d710ce54d903ff5ec11afa30000000000080000000000000000000000000000000000000000000000000000000000000000' + ) +); + +is(scalar(@captured_zero_rakp2), 72, 'captured XCC RAKP2 payload is 72 bytes'); + +subtest 'captured all-zero SHA256 RAKP2 retries once with suite 3' => sub { + my @errors; + my @sent; + my $session = new_session(errors => \@errors); + local *xCAT::IPMI::sendpayload = sub { + my ($self, %args) = @_; + push @sent, \%args; + }; + + is($session->got_rakp2(@captured_zero_rakp2), 9, 'invalid response is not accepted'); + is(scalar(@sent), 1, 'one replacement Open Session request is sent'); + is($session->{attempthash}, 1, 'retry selects SHA1'); + ok($session->{zero_rakp2_fallback}, 'fallback is recorded for this login'); + is( + $session->{sessionestablishmentcontext}, + xCAT::IPMI::STATE_OPENSESSION(), + 'session waits for the replacement Open Session response' + ); + is_deeply( + [@{ $sent[0]->{payload} }[12, 20, 28]], + [1, 1, 1], + 'replacement request proposes suite 3 algorithms' + ); + + my @suite3_response = ( + $session->{rmcptag}, 0, 4, 0, @{$session->{sidm}}, + 0x11, 0x22, 0x33, 0x44, + 0, 0, 0, 8, 1, 0, 0, 0, + 1, 0, 0, 8, 1, 0, 0, 0, + 2, 0, 0, 8, 1, 0, 0, 0, + ); + is($session->got_rmcp_response(@suite3_response), 0, 'suite 3 response is accepted'); + is(scalar(@sent), 2, 'suite 3 response advances to RAKP1'); + is($session->got_rakp2(@captured_zero_rakp2), 9, 'stale SHA256 response is ignored'); + is(scalar(@sent), 2, 'stale response cannot start another fallback'); + + my @valid_sha1_rakp2 = valid_rakp2_payload($session); + is($session->got_rakp2(@valid_sha1_rakp2), 0, 'valid suite 3 RAKP2 is accepted'); + is(scalar(@sent), 3, 'valid suite 3 RAKP2 advances to RAKP3'); + + my $admin_level_set = 0; + local *xCAT::IPMI::set_admin_level = sub { $admin_level_set++; }; + my @valid_sha1_rakp4 = valid_rakp4_payload($session); + is($session->got_rakp4(@valid_sha1_rakp4), 0, 'valid suite 3 RAKP4 is accepted'); + is( + $session->{sessionestablishmentcontext}, + xCAT::IPMI::STATE_ESTABLISHED(), + 'suite 3 session is established' + ); + is($admin_level_set, 1, 'established session advances to privilege setup'); + is_deeply(\@errors, [], 'fallback does not report an incorrect password'); +}; + +subtest 'all-zero SHA1 RAKP2 keeps the existing mismatch path' => sub { + my @errors; + my @sent; + my $session = new_session( + errors => \@errors, + attempthash => 1, + hshfn => \&xCAT::IPMI::hmac_sha1, + hshln => 12, + zero_rakp2_fallback => 1, + ); + my @payload = rakp2_payload($session, [(0) x 20]); + local *xCAT::IPMI::sendpayload = sub { push @sent, 1; }; + + is($session->got_rakp2(@payload), 9, 'second invalid response is rejected'); + is(scalar(@sent), 0, 'no second fallback is attempted'); + is( + $session->{sessionestablishmentcontext}, + xCAT::IPMI::STATE_FAILED(), + 'session fails after the bounded retry' + ); + is_deeply( + \@errors, + ['ERROR: Incorrect password provided'], + 'suite 3 retains the existing mismatch error' + ); +}; + +subtest 'ordinary HMAC mismatch remains a password failure' => sub { + my @errors; + my @sent; + my $session = new_session(errors => \@errors); + my @payload = rakp2_payload($session, [0, (1) x 31]); + local *xCAT::IPMI::sendpayload = sub { push @sent, 1; }; + + is($session->got_rakp2(@payload), 9, 'nonzero bad HMAC is rejected'); + is(scalar(@sent), 0, 'ordinary mismatch does not trigger fallback'); + is_deeply( + \@errors, + ['ERROR: Incorrect password provided'], + 'existing wrong-password contract is preserved' + ); +}; + +subtest 'valid SHA256 RAKP2 continues to RAKP3' => sub { + my @errors; + my $rakp3_sent = 0; + my $session = new_session(errors => \@errors); + my @payload = valid_rakp2_payload($session); + local *xCAT::IPMI::send_rakp3 = sub { $rakp3_sent++; }; + + is($session->got_rakp2(@payload), 0, 'valid response succeeds'); + is($rakp3_sent, 1, 'RAKP3 is sent'); + is( + $session->{sessionestablishmentcontext}, + xCAT::IPMI::STATE_EXPECTINGRAKP4(), + 'session advances to RAKP4' + ); + is_deeply(\@errors, [], 'valid response emits no error'); +}; + +subtest 'malformed zero authentication lengths do not downgrade' => sub { + foreach my $length (31, 33) { + my @errors; + my @sent; + my $session = new_session(errors => \@errors); + my @payload = rakp2_payload($session, [(0) x $length]); + local *xCAT::IPMI::sendpayload = sub { push @sent, 1; }; + + is($session->got_rakp2(@payload), 9, "$length-byte response is rejected"); + is(scalar(@sent), 0, "$length-byte response does not trigger fallback"); + is_deeply(\@errors, ['ERROR: Incorrect password provided'], 'existing error path is preserved'); + } +}; + +subtest 'wrong console session ID cannot trigger a downgrade' => sub { + my @errors; + my @sent; + my $session = new_session(errors => \@errors); + my @payload = @captured_zero_rakp2; + $payload[4] ^= 0xff; + local *xCAT::IPMI::sendpayload = sub { push @sent, 1; }; + + is($session->got_rakp2(@payload), 9, 'invalid response is rejected'); + is(scalar(@sent), 0, 'stale response cannot force fallback'); + is_deeply(\@errors, ['ERROR: Incorrect password provided'], 'legacy mismatch path is preserved'); + is( + $session->{sessionestablishmentcontext}, + xCAT::IPMI::STATE_FAILED(), + 'legacy mismatch path fails the exchange' + ); +}; + +subtest 'legacy tag zero cannot trigger a downgrade' => sub { + my @errors; + my @sent; + my $session = new_session(errors => \@errors); + my @payload = @captured_zero_rakp2; + $payload[0] = 0; + local *xCAT::IPMI::sendpayload = sub { push @sent, 1; }; + + is($session->got_rakp2(@payload), 9, 'tag-zero response is rejected by the HMAC check'); + is(scalar(@sent), 0, 'tag-zero response cannot force fallback'); + is_deeply(\@errors, ['ERROR: Incorrect password provided'], 'legacy mismatch path is preserved'); +}; + +subtest 'suite 3 selection survives internal relog initialization' => sub { + my $session = new_session(zero_rakp2_fallback => 1); + $session->init(); + is($session->{attempthash}, 1, 'relog initialization keeps SHA1 selected'); + + local *xCAT::IPMI::get_channel_auth_cap = sub { }; + $session->login(callback => sub { }, callback_args => undef); + is($session->{attempthash}, 256, 'a new external login starts with SHA256'); + ok(!exists($session->{zero_rakp2_fallback}), 'a new external login clears the fallback flag'); +}; + +done_testing();