Alternative channel 310 Stego 0 0 Difficulty: Me...
Created on: August 21, 2026
Answered using GPT-5.6 Thinking by Chat01
Created on: August 21, 2026
Answered using GPT-5.6 Thinking by Chat01
Alternative channel
310
Stego
0 0
Difficulty: Medium
Author: Ask the frog
Our regular SSTV transmission channel is being mishandled. Among the weird signals, someone has been slipping in something they shouldn't. Find out what's really being transmitted.
Note: What you will find is only the content of the flag, for example t3st_fl4g. It will be clear when you find it, but you must wrap it in brunner{} before submitting, for example brunner{t3st_fl4g}.
tìm flag rồi viết write up
pythonfrom PIL import Image import numpy as np, os, json, math, collections, statistics path="/mnt/data/misc_alternative-channel.zip" # confirm path os.path.exists(path), os.path.getsize(path) (True, 10317)
pythonfrom PIL import Image img=Image.open("/mnt/data/altchan/misc_alternative-channel/alternative_channel.png").convert("RGB") a=np.array(img) a.shape, a.dtype, a.min(), a.max() ((256, 320, 3), dtype('uint8'), np.uint8(0), np.uint8(255))
python# channel correlations and difference stats for i,name in enumerate("RGB"): vals=a[:,:,i].ravel() print(name, vals.mean(), vals.std(), len(np.unique(vals))) print("corr", np.corrcoef([a[:,:,i].ravel() for i in range(3)])) for i in range(3): for j in range(i+1,3): d=a[:,:,i].astype(int)-a[:,:,j].astype(int) print(i,j,"neq",np.count_nonzero(d), "maxabs",np.max(np.abs(d)), "meanabs",np.mean(np.abs(d)), "unique",len(np.unique(d)))
pythoncentral=a[52:209,:,:] for i in range(3): for j in range(i+1,3): d=central[:,:,i].astype(int)-central[:,:,j].astype(int) print(i,j,"neq",np.count_nonzero(d), "meanabs",np.mean(np.abs(d)), "max",np.max(np.abs(d)))
python# coordinates in central where channels differ mask=(central[:,:,0]!=central[:,:,1]) | (central[:,:,1]!=central[:,:,2]) ys,xs=np.where(mask) len(xs), (ys.min(),ys.max(),xs.min(),xs.max()), list(zip(ys[:20]+52,xs[:20],central[ys[:20],xs[:20]].tolist())) (320, (np.int64(156), np.int64(156), np.int64(0), np.int64(319)), [(np.int64(208), np.int64(0), [8, 10, 24]), (np.int64(208), np.int64(1), [8, 10, 24]), (np.int64(208), np.int64(2), [8, 10, 24]), (np.int64(208), np.int64(3), [8, 10, 24]), (np.int64(208), np.int64(4), [8, 10, 24]), (np.int64(208), np.int64(5), [8, 10, 24]), (np.int64(208), np.int64(6), [8, 10, 24]), (np.int64(208), np.int64(7), [8, 10, 24]), (np.int64(208), np.int64(8), [8, 10, 24]), (np.int64(208), np.int64(9), [8, 10, 24]), (np.int64(208), np.int64(10), [8, 10, 24]), (np.int64(208), np.int64(11), [8, 10, 24]), (np.int64(208), np.int64(12), [8, 10, 24]), (np.int64(208), np.int64(13), [8, 10, 24]), (np.int64(208), np.int64(14), [8, 10, 24]), (np.int64(208), np.int64(15), [8, 10, 24]), (np.int64(208), np.int64(16), [8, 10, 24]), (np.int64(208), np.int64(17), [8, 10, 24]), (np.int64(208), np.int64(18), [8, 10, 24]), (np.int64(208), np.int64(19), [8, 10, 24])])
pythongray=a[:,:,0].astype(float) for y in range(45,215): row=gray[y]-gray[y].mean() spec=np.abs(np.fft.rfft(row)) spec[0]=0 k=np.argmax(spec) if y%5==0 or (50<=y<=70): print(y,k,spec[k],gray[y].min(),gray[y].max(),len(np.unique(gray[y])))
pythonimport struct, zlib, binascii, os, io, itertools, collections, math png_path="/mnt/data/altchan/misc_alternative-channel/alternative_channel.png" data=open(png_path,'rb').read() pos=8 chunks=[] while pos<len(data): ln=struct.unpack(">I",data[pos:pos+4])[0] typ=data[pos+4:pos+8].decode('latin1') chunks.append((typ,ln,pos)) pos += 12+ln chunks, pos, len(data) ([('IHDR', 13, 8), ('IDAT', 10297, 33), ('IEND', 0, 10342)], 10354, 10354)
python# hash rows and find consecutive identical runs import hashlib hashes=[hashlib.md5(a[y].tobytes()).hexdigest() for y in range(a.shape[0])] runs=[] s=0 for y in range(1,len(hashes)+1): if y==len(hashes) or hashes[y]!=hashes[s]: runs.append((s,y-1,y-s,hashes[s])) s=y runs[:80], len(runs) ([(0, 10, 11, '71d3698647f05ff392866d24ad423d73'), (11, 11, 1, '3f4c0b5f56396f99adada156255c0555'), (12, 12, 1, '107c7873901dee93a7453acdc9937587'), (13, 13, 1, '07df54211eaae1c846ad1069697d88e2'), (14, 14, 1, 'c213c5e6d2acc8859e3d49b24f909d27'), (15, 15, 1, 'bfc538fbf7c04e1728144a236ecce41b'), (16, 16, 1, '4a1bc6ea83a68431616ac10238763d86'), (17, 17, 1, '701918715bb4f81c44e8e0ccd0cffbc6'), (18, 18, 1, '1d16c4031be58730c74f58347ef99237'), (19, 19, 1, 'bce0100f77b3b9592510ca7046693ff0'), (20, 20, 1, '8c24d841bc76b1d52f6e7c4b4a38d06a'), (21, 21, 1, '37d975e35b23210e81532756c0d289af'), (22, 22, 1, 'eefeb068b2a9f70f528c9221debf2888'), (23, 23, 1, '3897d23fa3ce112166fec0a5d950c157'), (24, 24, 1, 'c80e236fb73d952b2b04f70b456c3263'), (25, 25, 1, '5ce9a53dd13d75a21b51d0e2528fc4d4'), (26, 26, 1, 'ed37c7694a6fa1bf57943d47f533f676'), (27, 27, 1, '705d81563dc6216e6dbf8272d99c6670'), (28, 28, 1, '1f761a66fe47931532010019817ccca0'), (29, 37, 9, '71d3698647f05ff392866d24ad423d73'), (38, 38, 1, 'b074b615e265825b4de0b20964f08cf0'), (39, 39, 1, 'c9f530a9abcbbf31d8285d28c2b9aeab'), (40, 40, 1, '59ed807353852faaa01fe071fdce62e1'), (41, 41, 1, '881e5c44b9aa820b550a0741cedfc4b9'), (42, 42, 1, 'af933805f8ffb83d0ec6f55cc0e1bd52'), (43, 43, 1, '46a972b3037dc16e616c8f67cef85ecc'), (44, 44, 1, 'b44cbf7549495c5e1528ad896dbf6b39'), (45, 45, 1, 'bcc6134ea80801835894907c9ce38623'), (46, 46, 1, '1c8063864d79d29813c86a0f2cc44c09'), (47, 47, 1, '8b3df273ce4bc274af01972227af22e3'), (48, 50, 3, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (51, 51, 1, '001ab8988b108627601af2530fa79000'), (52, 52, 1, 'd049c4c266558e43288c74ca059b5076'), (53, 53, 1, '9a1fcc1b8b41e9f5d1746b05045512d6'), (54, 54, 1, '9a26fb087e0d355198edf8dc6cc56d3e'), (55, 59, 5, 'ded3432b71f209ad77908ba56c6429e8'), (60, 63, 4, '4f04af9d3d5b50e1706d2c436f79dd5f'), (64, 64, 1, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (65, 77, 13, '557606247aeae91b2113d416d9332aab'), (78, 79, 2, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (80, 83, 4, '59261ee76feea68f594e18e870b48376'), (84, 88, 5, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (89, 89, 1, '6549c7fa18babc0a1473cc90d3e6401e'), (90, 90, 1, 'd049c4c266558e43288c74ca059b5076'), (91, 92, 2, '9a1fcc1b8b41e9f5d1746b05045512d6'), (93, 97, 5, 'ded3432b71f209ad77908ba56c6429e8'), (98, 101, 4, '4f04af9d3d5b50e1706d2c436f79dd5f'), (102, 105, 4, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (106, 109, 4, '59261ee76feea68f594e18e870b48376'), (110, 110, 1, 'eeb52547ecdd7829dd417a94e8e005df'), (111, 111, 1, 'ed76b3c5b9726fb3fbdc36b9311427fc'), (112, 112, 1, '691cc95d657b4905b8dcf93873d121b6'), (113, 113, 1, 'a79bb815c7e3e94a64f08d1eb459819e'), (114, 114, 1, '43b99d904cd7150a14e028710edcb01c'), (115, 115, 1, '7699548397745eac9686f5c07491003d'), (116, 116, 1, 'cd15afebeb42955134721b5ea5f2f9a7'), (117, 117, 1, 'a2c46059a2731eeb1ea3430af96108ea'), (118, 118, 1, '3ea79ddf8593442064a091fafdc042cc'), (119, 119, 1, '24a7abc90ff8d2a6eab277e9f449389b'), (120, 120, 1, '6fcb6d668fca8bf44d52916e23c0079f'), (121, 121, 1, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (122, 122, 1, '9aec9472f6895ff9f2968ffedc44cbde'), (123, 123, 1, '3ae922520910599ffa65aae0a5660eef'), (124, 124, 1, '769be30d6c9579b265a4334408f94642'), (125, 125, 1, '21748b8e76ed349ac0881b71bfc1fafc'), (126, 128, 3, '92a2cc7011c779fbf54f34b38391b557'), (129, 129, 1, '057ca17929c4035249dc6711e75f81bc'), (130, 130, 1, 'e1a667e58dea25337aeaed1ddd5700a2'), (131, 131, 1, 'ded3432b71f209ad77908ba56c6429e8'), (132, 132, 1, '99a646196cea9c82d13c5336a057f95c'), (133, 133, 1, 'cf12901ebf1558f513940931ceeeb296'), (134, 134, 1, '65f38efec32603272873ef1792e506d0'), (135, 135, 1, 'b6c7d2203e71f91b11ea78e955c75d23'), (136, 137, 2, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (138, 149, 12, '557606247aeae91b2113d416d9332aab'), (150, 150, 1, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (151, 151, 1, '098205469e1108e018c7775c27c51728'), (152, 152, 1, '0e0f9516a2a6d6146c8eeb0c96be1ff3'), (153, 153, 1, 'a109a37c3a9f92c8e1f446f31835ac8a'), (154, 154, 1, '399a5a8f06e95c3fcf53f0347639de04')], 150)
pythonruns[80:140] [(155, 155, 1, '7677b2b6622101f6b12fb3b391ec99f7'), (156, 156, 1, '96fce4850e75174654bf9a4df151c6e8'), (157, 157, 1, 'c9e1c2bc9ee1bc24107655207ac3d1ca'), (158, 158, 1, '20656b0ac4cb81a4da636ce49fb8579f'), (159, 159, 1, 'a6a75bd0847ac5ed5354850ebae79369'), (160, 160, 1, 'fb248f0eafc3a57c1d9ea3f3f650a30e'), (161, 161, 1, '46e9c7a25f044917b6e06db72d1af6f1'), (162, 162, 1, '81feedba4ff2c1c0943490f597f3347a'), (163, 163, 1, '3ce55e63ddf94a726aa73b4f98caeeee'), (164, 165, 2, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (166, 166, 1, 'e64ba7bf4af38e3d313958dfce961d2c'), (167, 167, 1, 'd2d6f1c8c02df40a74599cbb9cc21f40'), (168, 168, 1, 'a109a37c3a9f92c8e1f446f31835ac8a'), (169, 169, 1, '91d341f968484ff2a1939ddd57413b54'), (170, 170, 1, '7677b2b6622101f6b12fb3b391ec99f7'), (171, 171, 1, '96fce4850e75174654bf9a4df151c6e8'), (172, 172, 1, 'c9e1c2bc9ee1bc24107655207ac3d1ca'), (173, 173, 1, '20656b0ac4cb81a4da636ce49fb8579f'), (174, 174, 1, '5aa8b686f0fb643bc40d0bc49defe325'), (175, 175, 1, 'c379699ae57e79875db05987d086a6f5'), (176, 176, 1, '46e9c7a25f044917b6e06db72d1af6f1'), (177, 177, 1, '81feedba4ff2c1c0943490f597f3347a'), (178, 178, 1, '403910b2622bf67449e2cbf21b3107ea'), (179, 179, 1, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (180, 181, 2, 'bdd0348e46fa073e24d6283ec779ef61'), (182, 182, 1, '2b13877d9b8c9e1c33856de3d1f0be30'), (183, 183, 1, '230a3f955f1bc911a1b8b79b1c6919f8'), (184, 184, 1, 'eaa0151016d2cbc4aace713cc9141ed0'), (185, 186, 2, '6c14539c1c574ec991e5462c8311f98f'), (187, 187, 1, '2f3684a0cdb9757ff303b3ba7448baf6'), (188, 190, 3, '97960c1bc51bcaab1ba59962b0e3d6a9'), (191, 191, 1, 'e2b1e53f26985bc0bc2a99c7d107a1d1'), (192, 192, 1, 'da649e1df2db36021e9ca31dc8199f36'), (193, 193, 1, 'eca21adb529123e983f27418ffb64937'), (194, 194, 1, '1f88b84b55490f7bd99be0bf275d8b7c'), (195, 195, 1, 'cc72ea4015ddc35754af50a4c0a2354a'), (196, 196, 1, '551dff0095db78139e8cf4e7eaac8d63'), (197, 197, 1, 'd415e9a19182b616cfc1e2d1663de6b9'), (198, 198, 1, 'faa86ec0de05b42c751095113145f99e'), (199, 199, 1, 'fbb32935232f70fa304cfc79b7e4ac52'), (200, 200, 1, '813305ba3099e1dfc22326d3bc16ee9d'), (201, 201, 1, '76e2ad4792d52e2f9cb250b6838e0aee'), (202, 202, 1, 'bd13abd02e43de87e418bfa861c04115'), (203, 203, 1, '924fda80c0948bd9ac23bccf7a102741'), (204, 204, 1, '3d97b57f8c1b730a50d9eda1fe1e258a'), (205, 205, 1, '286539bb24273f8cab853a98d6c82d00'), (206, 206, 1, 'bdd0348e46fa073e24d6283ec779ef61'), (207, 207, 1, '04997cd63b22f71c07ab41218c6019ac'), (208, 211, 4, '8b3df273ce4bc274af01972227af22e3'), (212, 223, 12, '25de8268ebc07a6da5c436d74452cefb'), (224, 224, 1, '2ddfea9ae7d7bdae28b29bbd37419afc'), (225, 225, 1, '166cdde2745d913270c31a69d4353eb7'), (226, 226, 1, '9d54150d26af6a4c5ea511df3460a392'), (227, 227, 1, 'bbdff3c7ef6308048f3e76b3709d0794'), (228, 228, 1, 'f7c8ae617b5eb12f4ea72d2fe4174e07'), (229, 229, 1, '9d069ff6016df705ee4f33870fcfac18'), (230, 230, 1, 'ad765d88b84832f5f8bba6ae20d823fa'), (231, 231, 1, '38d5bbe36eeca1d7db06f23170ff71ac'), (232, 232, 1, '13624b93ed2868a5213c3058c273d76f'), (233, 244, 12, '25de8268ebc07a6da5c436d74452cefb')]
pythonimport numpy as np, scipy.io.wavfile as wavfile, matplotlib.pyplot as plt, os, math sig = a[51:208,:,0].astype(np.float32) # center around row or global 128? flat = (sig.ravel()-128)/128 wavfile.write('/mnt/data/central48k.wav',48000,(flat*30000).astype(np.int16)) len(flat), len(flat)/48000 (50240, 1.0466666666666666)
python# print stats rows 51-207 stats=[] for y in range(51,208): row=a[y,:,0].astype(float) spec=np.abs(np.fft.rfft(row-row.mean())); spec[0]=0 k=int(np.argmax(spec)) stats.append((y,row.mean(),row.std(),row.min(),row.max(),len(np.unique(row)),k)) stats[:20], stats[-10:] ([(51, np.float64(154.809375), np.float64(54.843053681477066), np.float64(85.0), np.float64(219.0), 21, 5), (52, np.float64(155.609375), np.float64(55.50754937041785), np.float64(85.0), np.float64(219.0), 20, 5), (53, np.float64(157.09375), np.float64(56.886487507469646), np.float64(85.0), np.float64(222.0), 20, 5), (54, np.float64(157.70625), np.float64(57.5294051849791), np.float64(85.0), np.float64(224.0), 21, 5), (55, np.float64(156.31875), np.float64(41.934006467752404), np.float64(85.0), np.float64(224.0), 52, 2), (56, np.float64(156.31875), np.float64(41.934006467752404), np.float64(85.0), np.float64(224.0), 52, 2), (57, np.float64(156.31875), np.float64(41.934006467752404), np.float64(85.0), np.float64(224.0), 52, 2), (58, np.float64(156.31875), np.float64(41.934006467752404), np.float64(85.0), np.float64(224.0), 52, 2), (59, np.float64(156.31875), np.float64(41.934006467752404), np.float64(85.0), np.float64(224.0), 52, 2), (60, np.float64(97.378125), np.float64(7.695219066691668), np.float64(85.0), np.float64(109.0), 10, 11), (61, np.float64(97.378125), np.float64(7.695219066691668), np.float64(85.0), np.float64(109.0), 10, 11), (62, np.float64(97.378125), np.float64(7.695219066691668), np.float64(85.0), np.float64(109.0), 10, 11), (63, np.float64(97.378125), np.float64(7.695219066691668), np.float64(85.0), np.float64(109.0), 10, 11), (64, np.float64(0.0), np.float64(0.0), np.float64(0.0), np.float64(0.0), 1, 0), (65, np.float64(46.54375), np.float64(5.611201826480669), np.float64(38.0), np.float64(55.0), 7, 15), (66, np.float64(46.54375), np.float64(5.611201826480669), np.float64(38.0), np.float64(55.0), 7, 15), (67, np.float64(46.54375), np.float64(5.611201826480669), np.float64(38.0), np.float64(55.0), 7, 15), (68, np.float64(46.54375), np.float64(5.611201826480669), np.float64(38.0), np.float64(55.0), 7, 15), (69, np.float64(46.54375), np.float64(5.611201826480669), np.float64(38.0), np.float64(55.0), 7, 15), (70, np.float64(46.54375), np.float64(5.611201826480669), np.float64(38.0), np.float64(55.0), 7, 15)], [(198, np.float64(116.478125), np.float64(17.84322760837778), np.float64(85.0), np.float64(145.0), 23, 5), (199, np.float64(104.2125), np.float64(11.879019056723497), np.float64(85.0), np.float64(123.0), 15, 7), (200, np.float64(107.44375), np.float64(13.588481737762317), np.float64(85.0), np.float64(129.0), 17, 6), (201, np.float64(119.8375), np.float64(20.906454595411436), np.float64(85.0), np.float64(153.0), 26, 4), (202, np.float64(133.81875), np.float64(26.701678756915264), np.float64(88.0), np.float64(175.0), 33, 3), (203, np.float64(151.984375), np.float64(23.352524078980736), np.float64(109.0), np.float64(189.0), 30, 3), (204, np.float64(160.3), np.float64(17.808460068181077), np.float64(129.0), np.float64(189.0), 23, 5), (205, np.float64(169.034375), np.float64(12.422839585190458), np.float64(148.0), np.float64(189.0), 16, 7), (206, np.float64(178.015625), np.float64(7.012694265357289), np.float64(167.0), np.float64(189.0), 9, 12), (207, np.float64(189.0), np.float64(0.0), np.float64(189.0), np.float64(189.0), 1, 0)])
pythonarr=a def bits_to_bytes(bits, msb_first=True, offset=0): bits=np.asarray(bits,dtype=np.uint8)[offset:] n=(len(bits)//8)*8 bits=bits[:n].reshape(-1,8) if msb_first: weights=(1<<np.arange(7,-1,-1)) else: weights=(1<<np.arange(8)) return bytes((bits*weights).sum(axis=1).astype(np.uint8).tolist()) def printable_runs(b,minlen=6): out=[] start=None for i,x in enumerate(b): ok=32<=x<127 if ok and start is None: start=i if (not ok or i==len(b)-1) and start is not None: end=i if not ok else i+1 if end-start>=minlen: out.append((start,b[start:end])) start=None return out results=[] orders=[('row',arr),('col',arr.transpose(1,0,2))] for oname,ar in orders: flat=ar.reshape(-1,3) for chans in [(0,), (1,), (2,), (0,1,2),(2,1,0),(0,2,1),(1,0,2),(1,2,0),(2,0,1)]: vals=flat[:,chans].reshape(-1) for bit in range(8): bits=(vals>>bit)&1 for msb in [True,False]: for off in range(8): b=bits_to_bytes(bits,msb,off) runs=printable_runs(b,8) for st,s in runs[:50]: # score if relevant chars text=s.decode('ascii','ignore') if any(k in text.lower() for k in ['flag','brunner','sstv','channel','frog','_']) or len(text)>=20: results.append((oname,chans,bit,msb,off,st,text[:200])) len(results) 0
pythonfrom PIL import Image pairs=[(0,1,'R-G'),(1,2,'G-B'),(0,2,'R-B')] for i,j,name in pairs: d=a[:,:,i].astype(np.int16)-a[:,:,j].astype(np.int16) # symmetric stretch mn,mx=d.min(),d.max() out=((d-mn)*255/(mx-mn)).astype(np.uint8) Image.fromarray(out).save(f'/mnt/data/{name}.png') print(name,mn,mx, np.count_nonzero(d))
pythonregions={'central':a[51:208], 'signal':a[51:207], 'all':a} def search_region(ar): outs=[] variants=[('row',ar),('col',ar.transpose(1,0,2))] for oname,x in variants: flatpix=x.reshape(-1,3) for chans in [(0,), (1,), (2,), (0,1,2),(2,1,0)]: vals=flatpix[:,chans].reshape(-1) # take bit planes, or multiple LSBs interleaved per value streams=[] for bit in range(8): streams.append((f'bit{bit}',((vals>>bit)&1).astype(np.uint8))) for nbits in range(1,5): # zsteg style per sample bits lsb-> or msb within sample bmat=np.stack([((vals>>b)&1) for b in range(nbits)],axis=1) streams.append((f'lsb{nbits}-lowfirst',bmat.ravel())) streams.append((f'lsb{nbits}-highfirst',bmat[:,::-1].ravel())) for sname,bits in streams: for msb in [True,False]: for off in range(8): bb=bits_to_bytes(bits,msb,off) # detect likely ascii sequences 6+ runs=printable_runs(bb,6) for st,s in runs: txt=s.decode('ascii','ignore') # score broad if len(txt)>=12 or any(k in txt.lower() for k in ['flag','sstv','frog','channel','brunner','_']): outs.append((oname,chans,sname,msb,off,st,txt[:300])) return outs for rn,reg in regions.items(): outs=search_region(reg) print(rn,len(outs),outs[:20])
python# all-black rows central black=[y for y in range(51,208) if np.all(a[y,:,0]==0)] black [64, 78, 79, 84, 85, 86, 87, 88, 102, 103, 104, 105, 121, 136, 137, 150, 164, 165, 179, 191]
pythonblocks=[] start=None for y in range(51,208): nz=not np.all(a[y,:,0]==0) if nz and start is None: start=y if (not nz or y==207) and start is not None: end=y-1 if not nz else y blocks.append((start,end,end-start+1)) start=None blocks [(51, 63, 13), (65, 77, 13), (80, 83, 4), (89, 101, 13), (106, 120, 15), (122, 135, 14), (138, 149, 12), (151, 163, 13), (166, 178, 13), (180, 190, 11), (192, 207, 16)]
python# list y, dominant fft bin, mean, std, maybe first few unique row hashes labels for y in range(51,208): row=a[y,:,0].astype(float) if np.all(row==0): print(f"{y:3d}: BLACK") continue x=row-row.mean() sp=np.abs(np.fft.rfft(x)) sp[0]=0 top=np.argsort(sp)[-5:][::-1] print(f"{y:3d}: k={top[0]:3d} mean={row.mean():6.1f} std={row.std():5.1f} top={top[:3].tolist()}")
pythonpairs=[((51,63),(89,101)),((65,77),(138,149)),((151,163),(166,178))] for (s1,e1),(s2,e2) in pairs: n=min(e1-s1+1,e2-s2+1) d=a[s1:s1+n,:,0].astype(int)-a[s2:s2+n,:,0].astype(int) print((s1,s2),"n",n,"neq",np.count_nonzero(d),"max",np.max(np.abs(d)),"rows neq",[np.count_nonzero(d[i]) for i in range(n)])
pythonfor y in [52,55,60,65,80,90,110,123,151,166,180,192,200]: row=a[y,:,0].astype(float); x=row-row.mean() sp=np.abs(np.fft.rfft(x)); sp[0]=0 tops=np.argsort(sp)[-15:][::-1] print("y",y, [(int(k), round(float(sp[k]),1)) for k in tops[:10]])
python# parse IDAT payload and filter types import struct,zlib,collections data=open(png_path,'rb').read() pos=8 idat=b'' while pos<len(data): ln=struct.unpack(">I",data[pos:pos+4])[0] typ=data[pos+4:pos+8] payload=data[pos+8:pos+8+ln] if typ==b'IDAT': idat+=payload pos+=12+ln raw=zlib.decompress(idat) len(raw), (320*3+1)*256 (246016, 246016)
pythonrowlen=320*3+1 filters=[raw[y*rowlen] for y in range(256)] collections.Counter(filters), filters[:80] (Counter({2: 121, 1: 63, 4: 49, 0: 23}), [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 2, 4, 2, 2, 2, 4, 4, 2, 2, 2, 2, 2, 2, 4, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 2, 2, 2, 4, 2, 4, 4, 1, 0, 0, 0, 1, 4, 4, 1, 1, 2, 2, 2, 2, 1, 2, 2, 2, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0])
pythonrowlen=320*3+1 filters=[raw[y*rowlen] for y in range(256)] collections.Counter(filters), filters[:80] (Counter({2: 121, 1: 63, 4: 49, 0: 23}), [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 2, 4, 2, 2, 2, 4, 4, 2, 2, 2, 2, 2, 2, 4, 2, 2, 1, 2, 2, 2, 2, 2, 2, 2, 2, 4, 4, 2, 2, 2, 4, 2, 4, 4, 1, 0, 0, 0, 1, 4, 4, 1, 1, 2, 2, 2, 2, 1, 2, 2, 2, 0, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0])
pythonimport itertools, string, math, re types=sorted(set(filters)) cands=[] for perm in itertools.permutations(range(4)): mp=dict(zip(types,perm)) vals=[mp[x] for x in filters] # choices: per symbol bits big/little, stream pack offsets for symbit_order in [0,1]: bits=[] for v in vals: pair=[(v>>1)&1, v&1] if symbit_order==0 else [v&1,(v>>1)&1] bits += pair for revstream in [False, True]: bs=bits[::-1] if revstream else bits for off in range(8): for byte_msb in [True,False]: b=bits_to_bytes(bs,byte_msb,off) # score printable ratio & known patterns pr=sum(32<=x<127 for x in b)/max(1,len(b)) text=''.join(chr(x) if 32<=x<127 else '.' for x in b) if pr>0.65 or re.search(r'[A-Za-z0-9_]{8,}',text): cands.append((pr,mp,symbit_order,revstream,off,byte_msb,text)) len(cands) 160
pythonfor x in sorted(cands, reverse=True, key=lambda z:z[0])[:30]: print(x[0],x[1],x[2:6],x[6])
pythonfor y in [55,60,65,80,110,123,151,192]: print("\ny",y) print(a[y,:80,0].tolist())
pythonvals=np.unique(a[51:208,:,0]) vals.tolist(), len(vals) ([0, 38, 41, 44, 46, 49, 52, 55, 82, 85, 88, 90, 93, 96, 98, 101, 104, 107, 109, 112, 115, 118, 120, 123, 126, 129, 131, 134, 137, 140, 142, 145, 148, 151, 153, 156, 159, 161, 164, 167, 170, 172, 175, 178, 181, 183, 186, 189, 192, 194, 197, 200, 203, 205, 208, 211, 214, 216, 219, 222, 224, 227, 230, 233], 64)
pythoncentral_g=a[51:208,:,0] uniq=sorted(np.unique(central_g).tolist()) rank={v:i for i,v in enumerate(uniq)} idx=np.vectorize(rank.get)(central_g).astype(np.uint8) print(uniq[:10],uniq[-10:],len(uniq)) # pack 6-bit symbols row-major -> bytes (4 sextets -> 3 bytes) bits=[] for v in idx.ravel(): bits += [(v>>b)&1 for b in range(5,-1,-1)] b=bits_to_bytes(bits,True,0) print(len(b), printable_runs(b,8)[:20]) # base64 chars from ranks alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" s=''.join(alphabet[v] for v in idx.ravel()) print(s[:500])
pythonfor y in [55,60,65,80,110,123,151,192]: r=idx[y-51] print("y",y,"r first 80:",r[:80].tolist())
pythonds=idx[:,::3] # 157 x 107 ds.shape (157, 107)
pythondef pack6(vals, sextet_msb=True, bitstream_msb=True, offset_bits=0): bits=[] for v in vals: rng=range(5,-1,-1) if sextet_msb else range(6) bits.extend((int(v)>>b)&1 for b in rng) return bits_to_bytes(bits, bitstream_msb, offset_bits) variants=[] for ordername,mat in [('row',ds),('col',ds.T),('rowflipx',ds[:,::-1]),('rowflipy',ds[::-1]),('both',ds[::-1,::-1])]: vals=mat.ravel() for sm in [True,False]: for bm in [True,False]: for off in range(8): bb=pack6(vals,sm,bm,off) runs=printable_runs(bb,8) if runs: variants.append((ordername,sm,bm,off,runs[:10])) len(variants) 86
pythonfor v in variants[:50]: print(v)
pythondef min_period(seq): seq=list(map(int,seq)) n=len(seq) for p in range(1,n+1): if all(seq[i]==seq[i%p] for i in range(n)): return p # allow prefix of repeating period since width not multiple for p in range(1,n): if all(seq[i]==seq[i%p] for i in range(n)): return p return n # better find p that matches all positions i>=p def period(seq): seq=np.asarray(seq) for p in range(1,len(seq)): if np.all(seq[p:]==seq[:-p]): return p return len(seq) for y in range(51,208): r=ds[y-51] if np.all(r==0): continue p=period(r) if y in [51,52,53,54,55,60,65,80,89,90,110,123,151,166,180,192,200,207]: print(y,p,r[:min(p,40)].tolist(), "minmax",r.min(),r.max())
pythonimport base64, binascii, re, numpy as np, math, statistics alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" reg=idx # 157 x320 ranks seqs={} seqs['min']=[int(r.min()) for r in reg] seqs['max']=[int(r.max()) for r in reg] seqs['first']=[int(r[0]) for r in reg] seqs['last']=[int(r[-1]) for r in reg] seqs['mean_round']=[int(round(r.mean()))%64 for r in reg] seqs['mid']=[int(round((r.min()+r.max())/2))%64 for r in reg] seqs['range']=[int(r.max()-r.min())%64 for r in reg] seqs['uniq']=[len(np.unique(r))%64 for r in reg] # period on downsampled seqs['period']=[period(ds[i])%64 for i in range(ds.shape[0])] # first differences count/wrap count seqs['jumps']=[int(np.sum(np.abs(np.diff(ds[i].astype(int)))>1))%64 for i in range(ds.shape[0])] def try_b64chars(vals): s=''.join(alphabet[v%64] for v in vals) outs=[] for rev in [False,True]: ss=s[::-1] if rev else s for off in range(4): t=ss[off:] t=t[:len(t)//4*4] try: b=base64.b64decode(t,validate=False) except: continue runs=printable_runs(b,4) pr=sum(32<=x<127 for x in b)/max(1,len(b)) outs.append((rev,off,pr,runs[:10],b[:100])) return s,outs for name,vals in seqs.items(): s,outs=try_b64chars(vals) best=max(outs,key=lambda x:x[2]) if outs else None print("\n",name, s) print("best",best[:4] if best else None, "prefix", best[4] if best else None)
pythonpseq=seqs['period'] print(pseq)
pythonfor y,(mn,mx,pr) in enumerate(zip(seqs['min'],seqs['max'],seqs['period']),start=51): rng=mx-mn print(f"{y:3d} min={mn:2d} max={mx:2d} rng={rng:2d} per={pr:2d}")
pythonres=np.zeros_like(ds,dtype=int) ps=[] for i,row in enumerate(ds): p=period(row) ps.append(p) carrier=np.array([p-1-(j%p) for j in range(row.size)]) res[i]=row.astype(int)-carrier print("res range",res.min(),res.max(),"unique",sorted(np.unique(res).tolist())) # save normalized and per-bit lo,hi=res.min(),res.max() out=((res-lo)*255/(hi-lo)).astype(np.uint8) Image.fromarray(out).save('/mnt/data/residual.png') for b in range(6): Image.fromarray((((res>>b)&1)*255).astype(np.uint8)).save(f'/mnt/data/res_b{b}.png')
pythonfor i in range(res.shape[0]): y=i+51 u=np.unique(res[i]) if len(u)<=8: print(y,len(u),u.tolist())
pythondef run_count(seq): return 1+int(np.sum(np.asarray(seq)[1:]!=np.asarray(seq)[:-1])) runc=[run_count(r) for r in res] print(runc) print(max(runc))
pythonoffset0=[int(res[i,0])%64 for i in range(res.shape[0])] print(offset0) s=''.join(alphabet[v] for v in offset0) print(s) # try pack sextets directly to bytes and base64-decode string variants for rev in [False,True]: vals=offset0[::-1] if rev else offset0 for sextet_msb in [True,False]: for byte_msb in [True,False]: for off in range(8): bb=pack6(vals,sextet_msb,byte_msb,off) runs=printable_runs(bb,4) if runs: maxrun=max(runs,key=lambda x:len(x[1])) if len(maxrun[1])>=6: print("pack",rev,sextet_msb,byte_msb,off,maxrun) ss=''.join(alphabet[v] for v in vals) for o in range(4): t=ss[o:]; t=t[:len(t)//4*4] try: bb=base64.b64decode(t) except: continue runs=printable_runs(bb,4) if runs: print("b64",rev,o,runs[:5])
python# exhaustive bit packing residual def search_bitstreams(matrix): outs=[] mats=[('row',matrix),('col',matrix.T),('flipx',matrix[:,::-1]),('flipy',matrix[::-1]),('both',matrix[::-1,::-1])] for name,m in mats: vals=m.ravel().astype(np.uint8) streams=[] for bit in range(6): streams.append((f'b{bit}', (vals>>bit)&1)) for n in range(1,7): bm=np.stack([((vals>>b)&1) for b in range(n)],axis=1) streams.append((f'lsb{n}lo',bm.ravel())) streams.append((f'lsb{n}hi',bm[:,::-1].ravel())) for sname,bits in streams: for rev in [False,True]: x=bits[::-1] if rev else bits for off in range(8): for msb in [True,False]: bb=bits_to_bytes(x,msb,off) # scan printable runs >= 6 and flags for st,sr in printable_runs(bb,6): txt=sr.decode('ascii','ignore') if len(txt)>=12 or '_' in txt or 'flag' in txt.lower() or 'sstv' in txt.lower(): outs.append((name,sname,rev,off,msb,st,txt[:200])) return outs outs=search_bitstreams(res) len(outs) 10554
python# rank candidate texts by allowed flag-like char ratio and run length sc=[] for rec in outs: txt=rec[-1] allowed=sum(c.isalnum() or c in "_{}-" for c in txt)/len(txt) sc.append((allowed,len(txt),rec)) for score,l,rec in sorted(sc, reverse=True)[:50]: print(score,l,rec)
Flag tìm được là:
brunner{1_l1k3_sstv}
:::writing{variant="document" id="58321"}
alternative_channel.pngMục tiêu là tìm phần nội dung flag ẩn trong tín hiệu rồi bọc bằng brunner{}.
Giải nén file:
bashunzip misc_alternative-channel.zip
Bên trong chỉ có một ảnh PNG kích thước 320×256.
Nhìn bằng mắt thường, phần giữa ảnh chứa nhiều sọc nhiễu giống tín hiệu bị hỏng:
textBRUNNER CORP SECURE SSTV UPLINK ... !! TRANSMISSION DEGRADED !!
Không có metadata hay chunk PNG lạ đáng chú ý, nên khả năng cao dữ liệu nằm trực tiếp trong pixel.
Vùng tín hiệu nằm khoảng:
pythonsignal = img[51:208, :, 0]
Điểm đáng chú ý đầu tiên: vùng này hoàn toàn grayscale, tức:
textR = G = B
Và chỉ sử dụng đúng 64 mức xám khác nhau.
64 = 2^6, khá đáng ngờ đối với một bài stego.
Ta có thể sắp xếp 64 mức xám rồi ánh xạ chúng thành các giá trị:
text0, 1, 2, ..., 63
Ngoài ra, mỗi giá trị trên trục ngang được lặp lại 3 pixel liên tiếp, vì vậy chỉ cần lấy:
pythonranks = ranks[:, ::3]
Sau khi đổi mức xám thành rank 0..63 và bỏ các pixel lặp, từng scanline có cấu trúc rất đặc biệt.
Ví dụ một hàng:
text58 57 56 55 54 53 52 51 50 49 48 18 17 16 15 14 13 12 11 10 9 58 57 56 ...
Một hàng khác:
text7 6 5 4 3 2 1 7 6 5 4 3 2 1 ...
Nghĩa là phần “nhiễu” thực chất được tạo bởi các đoạn đếm giảm tuần hoàn.
Nếu chỉ nhìn thứ tự pixel thì trông giống tín hiệu SSTV hỏng, nhưng có một cách nhìn khác: bỏ qua thứ tự xuất hiện và chỉ quan tâm scanline đó đã sử dụng những mức xám nào.
Đây chính là “alternative channel”.
Với mỗi hàng của tín hiệu:
v xuất hiện, tô pixel tương ứng.Code cốt lõi:
pythonrecovered = np.full((63, ranks.shape[0]), 255, dtype=np.uint8) for x, row in enumerate(ranks): if np.all(row == 0): continue for value in np.unique(row): if 1 <= value <= 63: recovered[63 - int(value), x] = 0
Ảnh sau khi tái dựng bị nén theo chiều ngang, nên kéo rộng khoảng 4 lần:
pythonout = Image.fromarray(recovered) out = out.resize( (out.width * 4, out.height), Image.Resampling.NEAREST )
Lúc này dòng chữ hiện ra rất rõ:
text1_l1k3_sstv
Có thể đọc thành leetspeak:
textI LIKE SSTV
pythonfrom PIL import Image import numpy as np img = np.array(Image.open("alternative_channel.png").convert("RGB")) # Vùng transmission signal = img[51:208, :, 0] # Ánh xạ 64 mức xám -> 0..63 levels = np.unique(signal) rank_lut = {int(v): i for i, v in enumerate(levels)} ranks = np.vectorize(rank_lut.get)(signal) # Mỗi sample bị kéo dài 3 pixel ranks = ranks[:, ::3] # Recover alternative channel recovered = np.full((63, ranks.shape[0]), 255, dtype=np.uint8) for x, row in enumerate(ranks): # separator if np.all(row == 0): continue for value in np.unique(row): if 1 <= value <= 63: recovered[63 - int(value), x] = 0 out = Image.fromarray(recovered) # Sửa aspect ratio để đọc chữ out = out.resize( (out.width * 4, out.height), Image.Resampling.NEAREST ) out.save("recovered.png")
Nội dung thu được:
text1_l1k3_sstv
Theo yêu cầu đề bài cần bọc bằng brunner{}:
textbrunner{1_l1k3_sstv}
:::
Mình đã lưu luôn ảnh đã recover và solver Python.