Logo
Overview
[SECCON CTF 14] aeppel writeup

[SECCON CTF 14] aeppel writeup

December 14, 2025
4 min read

✨ 바이너리 공짜 다운로드 ✨

TL;DR

Script embeds a second compiled AppleScript blob; the inner script starts at offset 190. The inner script validates a full flag string candidate

  • | candidate | = 0x18 = 24
  • prefix is SECCON{ and suffix is }
  • The 16 char middle payload must satisfy Shimbashi, Ginza, Kanda and Sugamo

Shimbashi is an invertible per-character encoding with embedded target outputs

  • oi=ord(pi)+13+δio_i=ord(p_i)+13+\delta_i
  • δi=(13((imod3)+1)mod11)\delta_i=(13((i mod 3)+1) mod 11) for i1,...,16i\in{1, ... ,16}
  • pi=chr(oi13i)p_i=chr(o_i-13-i)

Analysis

AppleScript compiled/run-only binaries commonly use the FasdUAS container

1 2

The interesting part in this challenge is that 1.scpt contains another compiled script inside it.

Extract the embedded inner compiled script Search for the second header by looking for scptFasdUAS

This file contains scptFasdUAS 1.101.10 The scpt prefix is 4 bytes, and the FasdUAS header begins immediately after it.

In this challenge the embedded header begins at offset 190, so carving from there yields a valid compiled script

dd if=1.scpt of=inner2.scpt bs=1 skip=190 status=none
file inner2.scpt

Dissassemble the AppleScript VM bytecode

Function name : b'Iidabashi'
Function arguments: [b'candidate']

This is the main predicate, it returns true/false and drives the Correct / Try Again High-level structure of Iidabashi (the checker) Reading Iidabashi top-to-bottom, you can reconstruct a clean high-level validator.

Normalize the input

The candidate is converted to an NSString and trimmed

  1. Jimbocho(t) is essentially NSString.stringWithString_(t).
  2. trimNSString(ns) uses NSCharacterSet.whitespaceAndNewlineCharacterSet and stringByTrimmingCharactersInset_

So we can treat the input as a normal trimmed string.

The script then checks

T via four independent mechanisms:

  1. Shimbashi(T, …) must exactly equal an embedded 16-element integer list.
  2. Ginza(T) must equal 0x5f.
  3. Kanda(T, …) must produce a record matching an embedded expected record.
  4. Sugamo(T, …) must produce another record matching another embedded expected record.

The crucial observation is that (1) is directly invertible, so the payload can be recovered without brute force. Shimbashi: recover the payload by inversion Shimbashi is where the challenge leaks the payload in an invertible way.

Constants inside Shimbashi

Iidabashi defines two constants (seen as globals in the disassembly) idaho = 0x1abb kansas = 0x1ac8 Inside Shimbashi, a value is computed:

  • COLORADO=(kansas-idaho) mod 256

Numerically:

  • COLORADO=(0x1ac8-0x1abb) mod256 = 0x0d =13 So COLORADO = 13.

The embedded target output list Iidabashi also embeds the 16 outputs that Shimbashi must produce. As hex

O = [0x72, 0x83, 0x7F, 0x7D, 0x78, 0x82, 0x74, 0x85,
0x78, 0x81, 0x87, 0x75, 0x86, 0x81, 0x4B, 0x44]

Let O=(o1,...,o16)O=(o_1, ... , o_{16}) be these values.

The per-index delta item

δi=(13((imod3)+1)mod11)\delta_i=(13((i mod 3)+1) mod 11)

Encoding equation

ord(ti)=oi13δi)ord(t_i)=o_i-13-\delta_i)

So

ti=chr(oi13δi)t_i=chr(o_i-13-\delta_i)

For i=1

o1=0x72=114o_1= 0x72 = 114

δ1=4\delta_1 = 4

ord(t1)=114134=97ord(t_1) = 114-13-4 = 97

Applying this to all 16 positions yields \rightarrow applescriptfun<3

Ginza checksum mod 256

Ginza(T)=(cTord(c))mod256Ginza(T)=(\sum\limits_{c \in T} ord(c)) mod 256

The expected result in bytecode is 0x5f.

Kanda and Sugamo are more complex stateful transforms that output a record (dictionary-like object) with emoji-codepoint keys such as U1F41D, U1F99C, etc.

The checker compares these fields against records embedded in the bytecode.

Both functions use Quotient and Remainder on values that can become negative.

Python’s // and % use floor division; many runtimes (including typical VM implementations) use truncation toward zero.

To match the bytecode behavior, implement

q=trunc(a/b),r=abqq=trunc(a/b), r=a-bq

From the bytecode literals, the expected record for Kanda(T, …) is

U1F41D = 0x07C3
U1F99C = 64104
U1F11D = 0x1523
U1F41C = 56473
len = 0x10
U1F41B = 0x4D16

Likewise Sugamo(T, …) is

U1F41D = 62601
U1F99C = 65475
U1F11D = 65339
len = 6

Exploit

Reimplementing these transform is not required to recover T (cuz Shimbashi already gives it)

EXPECTED_SHIMBASHI = [
0x72, 0x83, 0x7F, 0x7D, 0x78, 0x82, 0x74, 0x85,
0x78, 0x81, 0x87, 0x75, 0x86, 0x81, 0x4B, 0x44,
]
COLORADO = 0x0D
EXPECTED_GINZA = 0x5F
NEW_JERSEY = [0x37, 0xA9, 0x5D]
U1F9E7 = 0x9D
WESTCHESTER = 0x1ABB
EXPECTED_KANDA = {
"U1F41D": 0x07C3,
"U1F99C": 64104,
"U1F11D": 0x1523,
"U1F41C": 56473,
"len": 0x10,
"U1F41B": 0x4D16,
}
EXPECTED_SUGAMO = {
"U1F41D": 62601,
"U1F99C": 65475,
"U1F11D": 65339,
"len": 6,
}
def quot(a: int, b: int) -> int:
return int(a / b)
def rem(a: int, b: int) -> int:
return a - b * quot(a, b)
def norm16(x: int) -> int:
return rem(rem(x, 65536) + 65536, 65536)
def invert_shimbashi(outputs: list[int]) -> str:
chars: list[str] = []
for i, out in enumerate(outputs, start=1):
v11 = rem(13 * (rem(i, 3) + 1), 11)
c = out - COLORADO - v11
if not (0 <= c <= 0x10FFFF):
raise ValueError("decoded codepoint out of range")
chars.append(chr(c))
return "".join(chars)
def kanda(ns_payload: str) -> dict[str, int]:
t = ns_payload
n = len(t)
v0, v1, v2 = NEW_JERSEY
rec_41d = 0
rec_99c = 0
rec_11d = WESTCHESTER
rec_41c = 0
rec_41b = WESTCHESTER
for i in range(1, n + 1):
code = ord(t[i - 1])
var11 = code - 0x80
var12 = rem(i * U1F9E7 + v0, 0x101)
var13 = var11 * var12 + v1
var14 = v2 - i
if var14 == 0:
var14 = -1
var15 = quot(var13, var14)
var16 = var12 or 1
var17 = rem(var13, var16)
rec_99c = rem(rec_99c + var15 + var17, 65536)
rec_41d = rec_41d + var13
rec_11d = rem(rec_11d + var15 + var17, 65536)
rec_41c = rec_41c + v0 - v2
v0 = v0 + var17
v1 = v1 - var15
v2 = -v2 + rem(code, 5)
var18 = rem(var15 + var17 + U1F9E7, 65536)
rec_41b = rem(rec_41b + var18 * (i + 3), 65536)
return {
"U1F41D": norm16(rec_41d),
"U1F99C": norm16(rec_99c),
"U1F11D": norm16(rec_11d),
"U1F41C": norm16(rec_41c),
"len": norm16(n),
"U1F41B": norm16(rec_41b),
}
def sugamo(ns_payload: str) -> dict[str, int]:
t = ns_payload
n = min(len(t), 6)
v0, v1, v2 = NEW_JERSEY
rec_41d = 0
rec_99c = 0
rec_11d = 0
for i in range(1, n + 1):
code = ord(t[i - 1])
var9 = (code - 0x80) * v0 + v1
var10 = v2 - i
if var10 == 0:
var10 = -1
var11 = quot(var9, var10)
var12 = v0 - rem(code, 9)
if var12 == 0:
var12 = 1
var13 = rem(var9, var12)
rec_99c = rem(rec_99c + var11 + var13, 65536)
rec_41d = rem(rec_41d + var9, 65536)
rec_11d = rem(rec_11d + var11 * var13, 65536)
v0 = v0 + var13
v1 = v1 - var11
v2 = -v2 + rem(code, 7)
return {
"U1F41D": norm16(rec_41d),
"U1F99C": norm16(rec_99c),
"U1F11D": norm16(rec_11d),
"len": n,
}
def main() -> None:
inner = invert_shimbashi(EXPECTED_SHIMBASHI)
if rem(sum(ord(c) for c in inner), 256) != EXPECTED_GINZA:
raise SystemExit("ginza check failed")
if kanda(inner) != EXPECTED_KANDA:
raise SystemExit("kanda check failed")
if sugamo(inner) != EXPECTED_SUGAMO:
raise SystemExit("sugamo check failed")
print(f"SECCON{{{inner}}}")
if __name__ == "__main__":
main()

flag