A 4992-byte bare-metal ELF for 32-bit RISC-V. It uses four instructions from the reserved custom-0 and custom-1 opcode spaces - the "four runes" of the challenge text. No emulator, no Docker instance, no documentation. The task is to find the 16-byte input the stone accepts.
$ ls -la
-rw-r--r--@ 1 feyrer staff 888 Jul 25 15:44 a2524ce9-e743-48ed-bdc4-5a6effffa571-1784743614.zip
$ file a2524ce9-e743-48ed-bdc4-5a6effffa571-1784743614.zip
a2524ce9-e743-48ed-bdc4-5a6effffa571-1784743614.zip: Zip archive data, at least v2.0
to extract, compression method=deflate
$ unzip -l a2524ce9-e743-48ed-bdc4-5a6effffa571-1784743614.zip
Length Date Time Name
--------- ---------- ----- ----
4992 06-04-2026 20:06 first-mark.elf
--------- -------
4992 1 file
$ file first-mark.elf
first-mark.elf: ELF 32-bit LSB executable, UCB RISC-V, soft-float ABI, version 1 (SYSV),
statically linked, stripped
A single stripped binary, and not for the host architecture. Nothing else - no source, no README, no sample input.
Nothing. This challenge ships no Docker container and no nc endpoint -
the ELF is the entire challenge. It is also not runnable as-is: it is bare-metal RISC-V
code with no operating system underneath, linked at 0x20000000, talking to a
memory-mapped UART at 0x10000000.
$ readelf -h first-mark.elf | head -14
ELF Header:
Magic: 7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
Class: ELF32
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x20000000
Start of program headers: 52 (bytes into file)
Start of section headers: 4752 (bytes into file)
Flags: 0x0
$ readelf -S -W first-mark.elf | grep -E 'Nr|text|rodata|bss'
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 1] .text PROGBITS 20000000 001000 000178 00 AX 0 0 4
[ 2] .rodata PROGBITS 20000178 001178 0000bc 00 A 0 0 4
[ 3] .bss NOBITS 80000000 002000 000010 00 WA 0 0 4
376 bytes of code, 188 bytes of read-only data, and a 16-byte .bss buffer.
The banner the program would print sits in .rodata:
"the stone witnesses. it does not bargain. read the four marks or be cast out."
"ACCEPTED: The First Mark was cut in steel."
This is a purely static challenge - the answer is the input, and it never gets typed anywhere.
376 bytes of code is small enough to read completely. Ghidra decompiles it after setting the language to RISC-V 32 little endian.
void entry(void) // crt0
{
puVar1 = &DAT_ram_80000000; // zero the 16 .bss bytes
do { *puVar1 = 0; puVar1 = puVar1 + 1; } while (puVar1 < (undefined4 *)0x80000010);
FUN_ram_20000094_main();
do { } while( true ); // spin forever
}
undefined4 FUN_ram_20000094_main(void)
{
FUN_ram_20000060_print("the stone witnesses. it does not bargain. read the four marks or be cast out.");
FUN_ram_200000d0_challenge(0x80000000); // the 16-byte .bss buffer
FUN_ram_20000060_print("ACCEPTED: The First Mark was cut in steel.");
return 0;
}
longlong FUN_ram_200000d0_challenge(void)
{
iVar2 = 0;
do {
custom0();
pbVar1 = &DAT_ram_20000204 + iVar2;
custom0();
custom1();
custom1();
iVar2 = iVar2 + 1;
} while (iVar2 < 0x10);
}
Conclusion: main ignores the return value of challenge,
so rejection cannot be a return code - it must happen inside one of the four
custom instructions. Ghidra cannot decode those, so they have to be decoded by hand.
Ghidra prints them as bare custom0()/custom1(). The raw
32-bit words carry the register operands, so pull them straight out of the file
(.text vaddr 0x20000000 = file offset 0x1000).
$ for off in 1124 1130 1134 1144; do xxd -s 0x$off -l 4 -e -g4 first-mark.elf; done
00001124: 00b5050b
00001130: 00b5150b
00001134: 00c5052b
00001144: 02d5002b
Decoded as RISC-V R-type:
| Address | Word | Opcode | funct7 | funct3 | rd | rs1 | rs2 |
|---|---|---|---|---|---|---|---|
| 0x20000124 | 00b5050b | custom-0 | 0 | 0 | a0 | a0 | a1 |
| 0x20000130 | 00b5150b | custom-0 | 0 | 1 | a0 | a0 | a1 |
| 0x20000134 | 00c5052b | custom-1 | 0 | 0 | a0 | a0 | a2 |
| 0x20000144 | 02d5002b | custom-1 | 1 | 0 | zero | a0 | a3 |
With the register operands known, the loop reads:
a2 = 0xa5; /* set once, before the loop */
for (i = 0; i < 16; i++) {
a0 = input[i];
a1 = tbl1[i] & 7;
a0 = custom0_f0(a0, a1); /* mark 1, 0x20000124 */
a1 = tbl2[i];
a0 = custom0_f1(a0, a1); /* mark 2, 0x20000130 */
a0 = custom1_f0(a0, a2); /* mark 3, 0x20000134 */
a2 = a0; /* feedback */
a3 = tbl3[i];
custom1_f1(a0, a3); /* mark 4, 0x20000144, rd = zero */
}
Conclusion: Three facts fall out. Mark 4 has rd = zero, so it writes
no result - it is a pure side effect, the witness that halts on a mismatch. Mark 1 gets
an explicit andi a1, a1, 7, so its second operand is a 3-bit amount: a
shift, rotate or exponent. And mv a2, a0 is CBC-style feedback with IV
0xa5.
The loop indexes three tables in .rodata. Dump them
(0x200001f4 = file offset 0x11f4).
$ xxd -s 0x11f4 -l 64 first-mark.elf
000011f4: 0307 0105 0206 0400 0307 0105 0206 0400 ................
00001204: 0302 0302 0507 0203 0507 0203 0507 0203 ................
00001214: 117a 3590 7e88 b059 797f 566a 3a10 e905 .z5.~..Yy.Vj:...
00001224: 206b 6565 7020 796f 7572 2073 7465 656c keep your steel
Conclusion: tbl1 (rotate amounts), tbl2 (multipliers)
and tbl3 (expected outputs) at 0x200001f4/0x20000204/0x20000214.
The fourth block " keep your steel" is referenced by nothing - a decoy,
sized exactly like a real table. If the witness tests equality, then a2 in
round i is exactly tbl3[i-1]: the chain collapses into 16
independent single-byte equations, no 16-byte brute force needed.
Four unknown operations, but they look like a standard cipher toolbox. Search over
families of stateless byte operations and keep whatever maps tbl3 back to
readable text.
Roughly 1010 semantic triples were tried: rotates, shifts, XOR/ADD/SUB,
multiplication mod 256 and mod 257, GF(28) multiplication with all 30
irreducible polynomials, Frobenius x^(2^k), GF exponentiation, the AES
S-box, Gray code, bit-reverse, nibble-swap. Result: noise. Every bijective (reversible)
triple maps tbl3 back to some 16 bytes, so the only discriminator is
"the plaintext reads as text" - and no candidate did.
The reason is visible once the right answer is known. Assuming mark 3 is stateless (plain CBC-XOR, no hidden register) and keeping marks 1 and 2 at their correct semantics still yields garbage:
$ python3 fm_verify.py
...
stateless mark 3 -> 635c99fbd8d3c1ae7568338240831fad
printable : False
Conclusion: Mark 3 is stateful. It carries a hidden register that never appears in the disassembly, so no search over pure functions can reach it. This is what the challenge text means with "learn what those four runes do from the company they keep" - the in-game hint at the end of the official challenge description supplies rune three. That hint was contributed by the author; it is not recoverable from the binary.
With rune 3 known, marks 1 and 2 are pinned down by exhaustive search again - and this time exactly one combination produces printable plaintext.
| Mark | Encoding | Semantics |
|---|---|---|
| 1 | custom-0 funct3=0 |
rd = rotr8(rs1, rs2 & 7) |
| 2 | custom-0 funct3=1 |
rd = gfmul(rs1, rs2) in GF(28), polynomial 0x11b |
| 3 | custom-1 funct3=0 |
rd = rs1 ^ state ^ carry, then carry = rs1 & state,
state = rd; carry starts at 0, state at 0xa5 |
| 4 | custom-1 funct7=1 |
witness: assert rs1 == rs2, otherwise halt |
So one round is:
a0 = rotr8(input[i], tbl1[i] & 7); /* mark 1 */
a0 = gfmul(a0, tbl2[i]); /* mark 2 */
new = a0 ^ state ^ carry; /* mark 3 */
carry = a0 & state; /* hidden inside the stone */
state = new; /* visible in a2 */
assert(new == tbl3[i]); /* mark 4 */
Conclusion: Every step is invertible, so the input can be computed directly
instead of searched. Undo mark 3 first - the witness forces
new == tbl3[i], so state and carry are known in
every round - then divide out the GF multiplication and undo the rotate.
solver.py:
#!/usr/bin/env python3
# Recover the accepted input of first-mark.elf.
TBL1 = bytes.fromhex("03070105020604000307010502060400")
TBL2 = bytes.fromhex("03020302050702030507020305070203")
TBL3 = bytes.fromhex("117a35907e88b059797f566a3a10e905")
def rotl(x, n):
n &= 7
return ((x << n) | (x >> (8 - n))) & 0xff if n else x
def gfmul(a, b): # GF(2^8), AES polynomial 0x11b
r = 0
for _ in range(8):
if b & 1:
r ^= a
hi = a & 0x80
a = (a << 1) & 0xff
if hi:
a ^= 0x1b
b >>= 1
return r
def gfinv(a):
return next(c for c in range(1, 256) if gfmul(a, c) == 1)
state, carry = 0xA5, 0
out = bytearray()
for i, t in enumerate(TBL3):
a0 = t ^ state ^ carry # undo mark 3
carry = a0 & state
state = t
a0 = gfmul(a0, gfinv(TBL2[i])) # undo mark 2
out.append(rotl(a0, TBL1[i] & 7)) # undo mark 1
print(out.decode())
And fm_verify.py, which runs all four marks forward over the
recovered input to prove the witness passes:
state, carry = 0xA5, 0
produced = bytearray()
for i, ch in enumerate(b"cut_f0r_th3_P1NT"):
a0 = rotr(ch, TBL1[i] & 7) # mark 1
a0 = gfmul(a0, TBL2[i]) # mark 2
new = a0 ^ state ^ carry # mark 3
carry = a0 & state
state = new
produced.append(new) # mark 4 asserts new == TBL3[i]
print("witness :", "all 16 rounds pass" if bytes(produced) == TBL3 else "MISMATCH")
$ python3 solver.py
cut_f0r_th3_P1NT
Verifying by simulating the machine forward over that input:
$ python3 fm_verify.py
input : cut_f0r_th3_P1NT
produced : 117a35907e88b059797f566a3a10e905
tbl3 : 117a35907e88b059797f566a3a10e905
witness : all 16 rounds pass
stateless mark 3 -> 635c99fbd8d3c1ae7568338240831fad
printable : False
The produced bytes match tbl3 exactly, so mark 4 passes all 16 rounds and
the stone attests.
HTB{cut_f0r_th3_P1NT}
| # | Stage | Mechanism |
|---|---|---|
| 1 | Static only | The binary never reads input: crt0 zeroes the 16 .bss
bytes and challenge runs over that zero buffer. There is nothing to
execute or fuzz - the accepted input has to be computed. |
| 2 | Decode the runes | Four instructions in the custom-0/custom-1 opcode space. The raw words give the
register operands; rd = zero on mark 4 identifies it as the witness. |
| 3 | Collapse the problem | The witness forces the round output to equal tbl3[i], and mark 3 feeds
that value forward as state. So every round's state is known in advance: 16
independent single-byte equations instead of a 2^128 search. |
| 4 | Invert | All three data marks are bijective. Undo mark 3 (XOR with known state and carry), divide out the GF(2^8) multiplication, rotate back - one pass, no search. |
| 5 | Verify | Forward simulation of all four marks over the recovered input reproduces
tbl3 byte for byte. |
The interesting part is stage 3. What looks like a 16-byte keyspace is really 16 one-byte problems, because the witness leaks the expected value of every round and mark 3 recycles exactly that value as the next round's state. The real difficulty is not the math but mark 3's hidden carry register: it is invisible in the disassembly, so any approach that assumes the runes are pure functions is doomed regardless of how much compute is thrown at it.
" keep your steel" at 0x20000224 are
referenced by nothing - a decoy sized exactly like the three real tables.