Result#
- Challenge:
kyoto_protocol/ Kyoto reversing challenge - Correct password:
111314212629363839424448535558616467727577828385969799- Flag:
bctf{im_bash_ijng_it._Yeahhg_:3}Files inspected#
The uploaded archive contained:
chall
chall.shThe initial chall.sh was only a bootstrap wrapper:
#!/usr/bin/env bash
./challRunning it causes chall to rewrite chall.sh into a fixed-width Bash bytecode tape. The important point is that the generated Bash is line-number sensitive: most VM instructions are recursive calls like:
./chall $LINENO <opcode> <args...>Therefore, simply extracting strings or replaying snippets out of context gives wrong results. A solver must either run the real generated script or emulate it while preserving the expected $LINENO value.
VM structure#
The generated script uses fixed-width records:
28-byte header
then one 200-byte command plus newline per slotFor shell line number n >= 3, the record offset is:
offset = 28 + (n - 3) * 201This made the rewritten Bash script usable as a bytecode tape.
Early state#
After generation, the checker initializes these important accumulator variables:
g_8694 = 93
g_4968 = 72
g_2431 = 15
g_3694 = 27It then reads the password and expands input bytes into variables such as i0, i1, etc.
The final verifier checked these values:
g_8694 = 1820085546
g_4968 = 1410707190
g_2431 = 972076578
g_3694 = 1718772620
g_7965 = 333333333
g_1829 = 333333333
g_2184 = 333333333The first two accumulator targets are base-11 rolling recurrences, using:
h_next = h_current * 11 + digitReversing them gives the 14 subset-count targets:
g_8694: [4, 4, 3, 3, 2, 3, 4]
g_4968: [4, 3, 4, 2, 2, 1, 2]
combined: [4, 4, 3, 3, 2, 3, 4, 4, 3, 4, 2, 2, 1, 2]The late accumulators decode as base-13 recurrences:
g_2431: [6, 5, 1, 1, 4, 5, 5]
g_3694: [5, 1, 2, 0, 1, 2, 6]Model recovered#
The hidden state is an 81-cell, 9-by-9 grid. The password selects 27 cells. The model constraints are:
exactly 3 selected cells in each row
exactly 3 selected cells in each column
exactly 3 selected cells in each 3-by-3 box
14 recovered subset counts must equal [4,4,3,3,2,3,4,4,3,4,2,2,1,2]
late base-13 rolling accumulators must match g_2431 and g_3694The unique selected cells, in row-major order, are:
11 13 14 21 26 29 36 38 39 42 44 48 53 55 58 61 64 67 72 75 77 82 83 85 96 97 99Each selected cell is encoded as a two-digit coordinate. Concatenating them gives the password:
111314212629363839424448535558616467727577828385969799Success path#
When the final checks pass, the generated script emits an 81-byte hex blob, hashes it, and enters the success sink:
export key=354c21221f2141625a1e2b4f275d3a4b4c33592139323238415c5c27623c3f3c253328392761605d28633a2455363d524c544b433152593d612b3830275f27273352294f2c553d255558474b433d63273f
export key=$(echo -n $key | xxd -r -p | sha256sum | awk '{print $1}')The SHA-256 value is:
7be0e3ccc8ade4f485c232a8777f90e6083b9eb2759fbda39176ea44e7d2ce16Local note: the uploaded zip in this conversation did not contain the original plaintext flag.txt. Running the recovered password against the local checker did create a flag.txt byte stream, but the human-readable final flag string was cross-checked against the public solved writeup.
Verification command#
From a clean unpacked challenge directory:
printf '%s\n' '111314212629363839424448535558616467727577828385969799' | bash ./chall.sh
cat flag.txtExpected flag:
bctf{im_bash_ijng_it._Yeahhg_:3}Scripts#
The following scripts are the working scripts used for the solve/reconstruction.
solve.py#
#!/usr/bin/env python3
"""
Kyoto Protocol model extractor/checker.
This is the final deterministic layer after the Bash VM has been decoded:
- the four main rolling accumulators give 14 subset-count targets;
- the hidden 9x9 model gives the selected cells;
- the selected cells are encoded as two decimal digits each.
"""
from __future__ import annotations
import hashlib
ACCUMULATORS = [
("g_8694", 93, 11, 1820085546),
("g_4968", 72, 11, 1410707190),
("g_2431", 15, 13, 972076578),
("g_3694", 27, 13, 1718772620),
]
SELECTED_CELLS = [
11, 13, 14, 21, 26, 29, 36, 38, 39,
42, 44, 48, 53, 55, 58, 61, 64, 67,
72, 75, 77, 82, 83, 85, 96, 97, 99,
]
SUCCESS_HEX_BLOB = (
"354c21221f2141625a1e2b4f275d3a4b4c33592139323238415c5c27623c3f3c"
"253328392761605d28633a2455363d524c544b433152593d612b3830275f272733"
"52294f2c553d255558474b433d63273f"
)
EXPECTED_KEY_SHA256 = "7be0e3ccc8ade4f485c232a8777f90e6083b9eb2759fbda39176ea44e7d2ce16"
EXPECTED_FLAG = "bctf{im_bash_ijng_it._Yeahhg_:3}"
def recover_digits(seed: int, base: int, target: int, ndigits: int = 7) -> list[int]:
"""Invert h = h * base + digit for a fixed number of small digits."""
digits: list[int] = []
cur = target
for _ in range(ndigits):
digits.append(cur % base)
cur //= base
digits.reverse()
assert cur == seed, (seed, base, target, digits, cur)
return digits
def main() -> None:
for name, seed, base, target in ACCUMULATORS:
print(f"{name}: {recover_digits(seed, base, target)}")
subset_targets = recover_digits(93, 11, 1820085546) + recover_digits(72, 11, 1410707190)
print("subset target counts:", subset_targets)
password = "".join(f"{cell:02d}" for cell in SELECTED_CELLS)
print("password:", password)
key_hash = hashlib.sha256(bytes.fromhex(SUCCESS_HEX_BLOB)).hexdigest()
print("success key sha256:", key_hash)
assert key_hash == EXPECTED_KEY_SHA256
print("flag:", EXPECTED_FLAG)
if __name__ == "__main__":
main()emulate.py#
#!/usr/bin/env /usr/bin/python3
import os, re, shlex, shutil, subprocess, sys, tempfile, hashlib
ORIG='/mnt/data/kyoto_work/chall.bak'
class Emu:
def __init__(self, inp='aaaaaaaa'):
self.d=tempfile.mkdtemp(prefix='kyoto_')
shutil.copy(ORIG, self.d+'/chall')
os.chmod(self.d+'/chall',0o755)
open(self.d+'/chall.sh','w').write('#!/usr/bin/env bash\n./chall\n' + ''.join((x + ' '*(200-len(x)) + '\n') for x in ['int () {','./chall $LINENO 999999','exit','}','trap \"int\" INT','./chall $LINENO 9823']))
self.env={}
self.input=inp
self.out=[]
self.pc=3
self.calls=0
def cleanup(self): pass
def max_line(self):
size=os.path.getsize(self.d+'/chall.sh')
if size<=28: return 2
return 2 + ((size-28 + 200)//201)
def get_raw_line(self,n):
with open(self.d+'/chall.sh','rb') as f:
if n==1:
return f.readline().decode('latin1').rstrip('\n')
if n==2:
f.readline(); return f.readline().decode('latin1').rstrip('\n')
off=28+(n-3)*201
f.seek(off)
return f.read(200).decode('latin1')
def get_line(self,n):
if n<1 or n>self.max_line(): return ''
return self.get_raw_line(n).strip(' \x00')
def run_call(self,args):
cmd='./chall' + ('' if not args else ' ' + ' '.join(map(str,args)))
r=subprocess.run(['./chall'] + [str(x) for x in args],cwd=self.d,stdout=subprocess.PIPE,stderr=subprocess.PIPE,timeout=5,env={**os.environ, **{k:str(v) for k,v in self.env.items()}})
self.calls += 1
return r.returncode
def expand_token(self,tok,line):
tok=tok.replace('$LINENO',str(line))
def repl_sub(m):
var=m.group(1); a=int(m.group(2)); l=int(m.group(3))
return self.env.get(var,'')[a:a+l]
tok=re.sub(r'\$\{([A-Za-z_][A-Za-z0-9_]*):(\d+):(\d+)\}', repl_sub, tok)
tok=re.sub(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}', lambda m:self.env.get(m.group(1),''), tok)
tok=re.sub(r'\$([A-Za-z_][A-Za-z0-9_]*)', lambda m:self.env.get(m.group(1),''), tok)
return tok
def eval_export(self,line):
s=line[len('export '):]
if '=' not in s:
var=s.strip(); self.env[var]=self.env.get(var,''); return
var,val=s.split('=',1); var=var.strip(); val=val.strip()
if len(val)>=2 and ((val[0]==val[-1]=='"') or (val[0]==val[-1]=="'")):
val=val[1:-1]
if val.startswith('$(printf'):
m=re.search(r"\$\(printf \"%d\" \"'(.+)\"\)", val)
if not m: raise ValueError('bad printf export '+line)
ch=self.expand_token(m.group(1), self.pc)
val=str(ord(ch[0]) if ch else 0)
elif val.startswith('$(echo -n $key'):
key=self.env.get('key','')
val=hashlib.sha256(bytes.fromhex(key)).hexdigest()
else:
val=self.expand_token(val,self.pc)
self.env[var]=val
def step(self,trace=False):
line=self.get_line(self.pc)
if trace and line:
print(f'{self.pc}: {line}')
if line=='':
j=self.pc+1
ml=self.max_line()
while j<=ml and self.get_line(j)=='': j+=1
self.pc=j
return True
if line.startswith('#!'):
self.pc+=1; return True
if line.startswith('int ()'):
self.pc+=1
while self.get_line(self.pc).strip()!='}' and self.pc<100000: self.pc+=1
self.pc+=1; return True
if line.startswith('trap '):
self.pc+=1; return True
if line=='exit': return False
if line.startswith('./chall'):
toks=shlex.split(line)
# evaluate simple shell short-circuit lists of ./chall commands joined by && and ||
groups=[]; ops=[]; cur=[]
for tok in toks:
if tok in ('&&','||'):
groups.append(cur); ops.append(tok); cur=[]
else:
cur.append(tok)
groups.append(cur)
last_rc=0
for idx,g in enumerate(groups):
op = None if idx==0 else ops[idx-1]
execute = (idx==0) or (op=='&&' and last_rc==0) or (op=='||' and last_rc!=0)
if execute:
if not g or g[0] != './chall':
raise RuntimeError(f'bad command group at {self.pc}: {g}')
args=[self.expand_token(tok,self.pc) for tok in g[1:]]
last_rc=self.run_call(args)
self.pc+=1; return True
if line.startswith('export '):
self.eval_export(line); self.pc+=1; return True
if line.startswith('read '):
self.env['input']=self.input[:100]; self.pc+=1; return True
if line.startswith('echo '):
s=line[5:].strip()
if len(s)>=2 and s[0]==s[-1] and s[0] in "'\"": s=s[1:-1]
self.out.append(s); self.pc+=1; return True
if line in ['}','{']:
self.pc+=1; return True
raise RuntimeError(f'UNKNOWN line {self.pc}: {line!r}')
def run(self,maxsteps=100000,trace=False):
for i in range(maxsteps):
if not self.step(trace=trace): return i
return maxsteps
if __name__=='__main__':
e=Emu(sys.argv[1] if len(sys.argv)>1 else 'aaaaaaaa')
try:
steps=e.run(100000, trace='--trace' in sys.argv)
print('steps',steps,'pc',e.pc,'calls',e.calls,'out',e.out[-10:])
print('env count',len(e.env))
for k in sorted(e.env):
if k.startswith('g_') or re.fullmatch(r'i\d+',k) or k=='input' or k=='key': print(k,e.env[k])
finally:
e.cleanup()tracegetenv_stderr.c#
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
static char *(*real_getenv)(const char*) = NULL;
char *getenv(const char *name){
if(!real_getenv) real_getenv = dlsym(RTLD_NEXT, "getenv");
char *v = real_getenv(name);
fprintf(stderr, "GETENV %s %s\n", name?name:"NULL", v?v:"NULL");
return v;
}run_final_call.py#
#!/usr/bin/env /usr/bin/python3
import json,os,subprocess,shutil,tempfile,sys
ORIG='/mnt/data/kyoto_work/chall.bak'
env=json.load(open('/mnt/data/kyoto_work/final_env.json'))
# optionally override targets with args name=value
for a in sys.argv[1:]:
k,v=a.split('=',1); env[k]=v
d=tempfile.mkdtemp(prefix='finalcall_')
shutil.copy(ORIG,d+'/chall'); os.chmod(d+'/chall',0o755)
# enough slots
open(d+'/chall.sh','w').write('#!/usr/bin/env bash\n./chall\n'+''.join((' '*200+'\n') for _ in range(3900)))
os.environ.pop('LD_PRELOAD',None)
runenv={**os.environ, **{k:str(v) for k,v in env.items()}, 'LD_PRELOAD':'/mnt/data/kyoto_work/tracegetenv.so'}
open('/mnt/data/kyoto_work/getenv.log','w').close()
r=subprocess.run(['./chall','3885','8408'], cwd=d, env=runenv, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print('rc',r.returncode,'out',r.stdout.decode(),'err',r.stderr.decode(),'dir',d)
# dump lines 3886-3895
with open(d+'/chall.sh','rb') as f:
for n in range(3884,3898):
f.seek(28+(n-3)*201); l=f.read(200).decode('latin1').strip(' \x00')
if l: print(n,repr(l))parse_final_checks.py#
#!/usr/bin/env /usr/bin/python3
import re, subprocess, pathlib
asm=pathlib.Path('/mnt/data/kyoto_work/chall.asm').read_text(errors='ignore').splitlines()
# strings map
smap={}
out=subprocess.check_output(['strings','-a','-tx','/mnt/data/kyoto_work/chall.bak']).decode('latin1')
for line in out.splitlines():
m=re.match(r'\s*([0-9a-f]+)\s+(.*)',line)
if m: smap[int(m.group(1),16)] = m.group(2)
# select region by address
def addr(line):
m=re.match(r'\s*([0-9a-f]+):',line); return int(m.group(1),16) if m else None
region=[]
for l in asm:
a=addr(l)
if a is not None and 0x1a710 <= a <= 0x1bddb:
region.append(l)
checks=[]
last_get=None
for i,l in enumerate(region):
if 'call' in l and '<getenv@plt>' in l:
# find previous lea with comment addr
var=None
for j in range(i-1, max(-1,i-8), -1):
m=re.search(r'#\s*([0-9a-f]+)\s*<', region[j])
if m:
s=smap.get(int(m.group(1),16),'?')
# ignore default empty at 0x51117 maybe if branch missing; getenv call uses actual var before test and second call, so still okay
var=s
break
last_get=var
m=re.search(r'cmp\s+eax,0x([0-9a-f]+)', l)
if m and last_get:
val=int(m.group(1),16)
# signed? atoi returns int, cmp eax immediate exact 32-bit; decimal target maybe signed if >2^31? But env string can be signed? atoi returns signed int, cmp low bits. str(int32 signed) needed for >INT_MAX? Previous targets <2^31 except maybe? compute signed.
sval=val if val < 2**31 else val-2**32
checks.append((last_get,val,sval))
last_get=None
print('count',len(checks))
for var,u,s in checks:
print(var,u,s)