1091 words
5 minutes
TexSAW 2026 - not drawing - Reverse Engineering Writeup

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: data
rabin2 -S /home/rei/Downloads/notdrawing/drawing.nro
0   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- ---- data

That 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.py
loc0 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.py
OFF 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.py
field 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)'
3822

Before 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.py
triangles 1274 kept 1218

Once 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.png

The rendered image displayed the flag clearly, and reading it manually yielded texsaw{2switch1918402350923}.

TexSAW 2026 - not drawing - Reverse Engineering Writeup
https://blog.rei.my.id/posts/138/texsaw-2026-not-drawing-reverse-engineering-writeup/
Author
Reidho Satria
Published at
2026-03-30
License
CC BY-NC-SA 4.0