Category: Reverse Engineering
Flag: texsaw{2switch1918402350923}
Challenge Description
my drawing broke :(
Analysis
The file was not a normal desktop executable at all. It identified as generic data, but the section layout made it clear that this was a Nintendo Switch NRO with a large read-only region that could plausibly contain embedded assets.
file /home/rei/Downloads/notdrawing/drawing.nro/home/rei/Downloads/notdrawing/drawing.nro: datarabin2 -S /home/rei/Downloads/notdrawing/drawing.nro0 0x00000000 0x80 0x00000000 0x80 -r-- ---- header
3 0x00000000 0x40f000 0x000000f0 0x40f000 -r-x ---- text
4 0x0040f000 0x157000 0x0040f0f0 0x157000 -r-- ---- ro
5 0x00566000 0x45000 0x005660f0 0x45000 -rw- ---- dataThat pushed the analysis away from trying to execute the file and toward looking for static rendering data. The key clue came from the embedded GLSL shader text, which showed that the renderer expected vec3 aPos and vec3 aColor. That strongly suggests a packed vertex format of six floats per vertex.
from pathlib import Path
p = Path('/home/rei/Downloads/notdrawing/drawing.nro').read_bytes()
needle = b'layout (location = 0)'
idx = p.find(needle)
print('loc0', hex(idx) if idx != -1 else idx)
if idx != -1:
print(p[idx:idx + 400].decode('utf-8', 'ignore'))python dump_shader.pyloc0 0x4100db
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
out vec3 ourColor;
void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor;
}
#version 330 core
in vec3 ourColor;
out vec4 fragColor;
void main()
{
fragColor = vec4(ourColor, 1.0f);
}With that structure in mind, the next step was to scan the .ro range for long runs of plausible 24-byte records. The filter kept only finite floats in the normalized OpenGL-style range [-1.1, 1.1], which is a practical way to spot vertex buffers while ignoring unrelated data.
from pathlib import Path
import math
import struct
p = Path('/home/rei/Downloads/notdrawing/drawing.nro').read_bytes()
runs = []
for off in range(0x410000, 0x567000, 8):
cur = off
count = 0
while cur + 24 <= len(p):
vals = struct.unpack_from('<6f', p, cur)
if all(math.isfinite(x) for x in vals) and all(-1.1 <= v <= 1.1 for v in vals):
count += 1
cur += 24
else:
break
if count >= 3000:
runs.append((off, count, cur - off))
print([(hex(o), c, hex(sz)) for o, c, sz in runs[:20]])python scan_float_runs.py[('0x44bb80', 3826, '0x166b0'), ('0x44bb88', 3825, '0x16698'), ('0x44bb90', 3825, '0x16698'), ('0x44bb98', 3825, '0x16698'), ('0x44bba0', 3824, '0x16680'), ('0x44bba8', 3824, '0x16680'), ('0x44bbb0', 3824, '0x16680'), ('0x44bbb8', 3823, '0x16668'), ('0x44bbc0', 3823, '0x16668'), ('0x44bbc8', 3823, '0x16668'), ('0x44bbd0', 3822, '0x16650'), ...]The interesting blob was around 0x44bbd0, but that was not yet the first semantically clean vertex. Comparing a few candidate alignments showed that 0x44cbe0 was the point where the records started looking like consistent six-float vertices rather than partially shifted garbage.
from pathlib import Path
import struct
p = Path('/home/rei/Downloads/notdrawing/drawing.nro').read_bytes()
for off in [0x44bbd0, 0x44cbe0]:
print('OFF', hex(off))
for i in range(3):
print(i, struct.unpack_from('<6f', p, off + i * 24))python inspect_alignment.pyOFF 0x44bbd0
0 (1.757088144416888e-41, 4.203895392974451e-45, 1.7297628243625542e-41, 0.0, -0.8781269788742065, 0.02832699939608574)
1 (0.0, 1.0, 1.0, 1.0, -0.8781269788742065, 0.021872999146580696)
2 (0.0, 1.0, 1.0, 1.0, -0.8716729879379272, 0.021872999146580696)
OFF 0x44cbe0
0 (1.0, 1.0, -0.8135859966278076, -0.0003589999978430569, 0.0, 1.0)
1 (1.0, 1.0, -0.8071309924125671, -0.006812999956309795, 0.0, 1.0)
2 (1.0, 1.0, -0.8071309924125671, -0.0003589999978430569, 0.0, 1.0)At that aligned start, the first two fields were mostly constant while fields 2 and 3 varied smoothly, which made them the obvious projection axes for a long thin drawing. Field 5 also stood out as a clean binary-like selector, so it could be used as a color mask to decide which triangles should be filled.
from pathlib import Path
import collections
import struct
p = Path('/home/rei/Downloads/notdrawing/drawing.nro').read_bytes()
off = 0x44cbe0
count = 0x16650 // 24
vals = struct.unpack('<' + 'f' * (count * 6), p[off:off + count * 24])
verts = [vals[i * 6:(i + 1) * 6] for i in range(count)]
for j in range(6):
c = collections.Counter(round(v[j], 6) for v in verts[:500])
print('field', j, 'top', c.most_common(8))python inspect_fields.pyfield 0 top [(1.0, 500)]
field 1 top [(1.0, 500)]
field 2 top [(-0.763386, 12), (-0.756932, 12), ...]
field 3 top [(-0.000359, 65), (-0.006813, 64), ...]
field 4 top [(0.0, 500)]
field 5 top [(1.0, 500)]The size of the blob also fit perfectly: 0x16650 / 24 = 3822 vertices, and that count is divisible by three. That makes a triangle list the simplest explanation, so grouping the recovered vertices in triples was the natural rendering model.
python -c 'print(0x16650 // 24)'3822Before rendering, one more quick check showed that the exact filter used in the final script was sensible. Restricting the projected coordinates to a small finite range removed tail garbage, and keeping triangles where at least two vertices had v[5] > 0.5 isolated the visible dark shape.
from pathlib import Path
import math
import struct
p = Path('/home/rei/Downloads/notdrawing/drawing.nro').read_bytes()
off = 0x44cbe0
count = 0x16650 // 24
vals = struct.unpack('<' + 'f' * (count * 6), p[off:off + count * 24])
verts = [vals[i * 6:(i + 1) * 6] for i in range(count)]
alltris = 0
kept = 0
for t in range(0, count, 3):
tri = verts[t:t + 3]
if len(tri) < 3:
break
alltris += 1
if all(all(math.isfinite(x) for x in v) and abs(v[2]) < 2 and abs(v[3]) < 2 for v in tri):
if sum(1 for v in tri if v[5] > 0.5) >= 2:
kept += 1
print('triangles', alltris, 'kept', kept)python check_triangle_filter.pytriangles 1274 kept 1218Once those values were pinned down, the final renderer was straightforward: unpack the aligned vertex buffer, project fields 2 and 3 to image coordinates, fill only the selected triangles, and save the result. The output image contained the flag, and the flag was read manually from that rendered picture.
Solution
from pathlib import Path
import math
import struct
from PIL import Image, ImageDraw
p = Path('/home/rei/Downloads/notdrawing/drawing.nro').read_bytes()
off = 0x44cbe0
n = 0x16650 // 24
vals = struct.unpack('<' + 'f' * (n * 6), p[off:off + n * 24])
verts = [vals[i * 6:(i + 1) * 6] for i in range(n)]
tris = []
for t in range(0, n, 3):
tri = verts[t:t + 3]
if len(tri) < 3:
break
if all(all(math.isfinite(x) for x in v) and abs(v[2]) < 2 and abs(v[3]) < 2 for v in tri):
tris.append(tri)
xs = [v[2] for tri in tris for v in tri]
ys = [v[3] for tri in tris for v in tri]
xmin, xmax = min(xs), max(xs)
ymin, ymax = min(ys), max(ys)
W = 5000
H = 300
img = Image.new('L', (W, H), 255)
d = ImageDraw.Draw(img)
for tri in tris:
pts = [
((v[2] - xmin) / (xmax - xmin) * (W - 1), H - 1 - (v[3] - ymin) / (ymax - ymin) * (H - 1))
for v in tri
]
on = sum(1 for v in tri if v[5] > 0.5) >= 2
if on:
d.polygon(pts, fill=0)
out = '/tmp/notdrawing_zr_bw.png'
img.save(out)
print(out)python solve.py/tmp/notdrawing_zr_bw.pngThe rendered image displayed the flag clearly, and reading it manually yielded texsaw{2switch1918402350923}.