// Package stego реализует ручной разбор классической LSB-стеганографии // в изображениях — аналог того, что StegSolve/zsteg делают для CTF-задач: // изоляция битовых плоскостей и цветовых каналов, XOR между версиями // картинки, извлечение младших бит в сырые байты. // // Вся логика — чистый Go на стандартной библиотеке (image/png/jpeg/gif), // новых зависимостей не требует. Рендер результата в терминал — отдельный // вопрос, см. kitty.go и честную оговорку там про то, что я не могу // проверить эту часть без реального терминала. package stego import ( "bytes" "fmt" "image" "image/color" "image/draw" _ "image/gif" _ "image/jpeg" "image/png" "os" "strings" ) // Channel — один из цветовых каналов пикселя. type Channel int const ( ChannelRed Channel = iota ChannelGreen ChannelBlue ChannelAlpha ) func (c Channel) String() string { switch c { case ChannelRed: return "R" case ChannelGreen: return "G" case ChannelBlue: return "B" case ChannelAlpha: return "A" default: return "?" } } // ParseChannel разбирает канал из строки — не используется TUI (там // канал выбирается циклическим переключением, не вводом текста), но // оставлен для потенциального headless/CLI-использования этого пакета // в будущем, по аналогии с остальными частями приложения. func ParseChannel(s string) (Channel, error) { switch strings.ToLower(s) { case "r", "red": return ChannelRed, nil case "g", "green": return ChannelGreen, nil case "b", "blue": return ChannelBlue, nil case "a", "alpha": return ChannelAlpha, nil default: return 0, fmt.Errorf("неизвестный канал %q (ожидался r/g/b/a)", s) } } func channelValue(px color.RGBA, c Channel) uint8 { switch c { case ChannelRed: return px.R case ChannelGreen: return px.G case ChannelBlue: return px.B case ChannelAlpha: return px.A default: return 0 } } // Load читает изображение любого формата, поддерживаемого стандартной // библиотекой (PNG/JPEG/GIF — декодеры регистрируются побочными // импортами выше), и приводит его к *image.RGBA — единому формату для // всех дальнейших побитовых манипуляций независимо от исходного формата. // Второе возвращаемое значение — имя формата ("png"/"jpeg"/"gif"), как // его определил image.Decode, для отображения в метаданных. // // Честная оговорка: у JPEG нет альфа-канала — после декодирования и // приведения к RGBA канал A будет равномерно 255 у каждого пикселя, так // что BitPlane/ExtractLSB по каналу Alpha на JPEG-источнике осмысленного // результата не дадут (не баг, а следствие самого формата). func Load(path string) (*image.RGBA, string, error) { f, err := os.Open(path) if err != nil { return nil, "", fmt.Errorf("открытие %s: %w", path, err) } defer f.Close() src, format, err := image.Decode(f) if err != nil { return nil, "", fmt.Errorf("декодирование изображения: %w", err) } bounds := src.Bounds() rgba := image.NewRGBA(bounds) draw.Draw(rgba, bounds, src, bounds.Min, draw.Src) return rgba, format, nil } // EncodePNG сериализует изображение в PNG-байты — общий формат и для // экспорта на диск, и для передачи через Kitty graphics protocol // (который умеет декодировать PNG сам, кодировать в сырой RGBA не нужно). func EncodePNG(img image.Image) ([]byte, error) { var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { return nil, fmt.Errorf("кодирование PNG: %w", err) } return buf.Bytes(), nil } // SaveFile сохраняет изображение в PNG-файл по указанному пути. func SaveFile(img image.Image, path string) error { data, err := EncodePNG(img) if err != nil { return err } if err := os.WriteFile(path, data, 0o644); err != nil { return fmt.Errorf("запись %s: %w", path, err) } return nil } // BitPlane изолирует один бит (0 — младший/LSB, 7 — старший) одного // канала: пиксели, где этот бит равен 1, становятся белыми, где 0 — // чёрными. Классический первый шаг ручного разбора LSB-стеганографии — // спрятанный текст/паттерн в младшем бите почти никогда не заметен на // самом изображении, но полностью выдаёт себя на этой визуализации. func BitPlane(img *image.RGBA, ch Channel, bit int) *image.RGBA { bounds := img.Bounds() out := image.NewRGBA(bounds) for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { v := channelValue(img.RGBAAt(x, y), ch) var out8 uint8 if (v>>uint(bit))&1 == 1 { out8 = 255 } out.SetRGBA(x, y, color.RGBA{out8, out8, out8, 255}) } } return out } // ChannelIsolate обнуляет все каналы, кроме выбранного, оставляя его // как есть (не переводя в градации серого) — полезно увидеть, не // прячется ли что-то, заметное только в одном канале при полной яркости. // Для Alpha, который сам по себе не цветной, результат показывается как // градации серого — иначе показывать было бы просто нечего (R=G=B=0). func ChannelIsolate(img *image.RGBA, ch Channel) *image.RGBA { bounds := img.Bounds() out := image.NewRGBA(bounds) for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { v := channelValue(img.RGBAAt(x, y), ch) var r, g, b uint8 switch ch { case ChannelRed: r = v case ChannelGreen: g = v case ChannelBlue: b = v case ChannelAlpha: r, g, b = v, v, v } out.SetRGBA(x, y, color.RGBA{r, g, b, 255}) } } return out } // Grayscale переводит изображение в градации серого по стандартным // весам восприятия яркости ITU-R BT.601. func Grayscale(img *image.RGBA) *image.RGBA { bounds := img.Bounds() out := image.NewRGBA(bounds) for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { px := img.RGBAAt(x, y) gray := uint8((299*int(px.R) + 587*int(px.G) + 114*int(px.B)) / 1000) out.SetRGBA(x, y, color.RGBA{gray, gray, gray, 255}) } } return out } // Invert инвертирует цвета (не трогая альфа-канал). func Invert(img *image.RGBA) *image.RGBA { bounds := img.Bounds() out := image.NewRGBA(bounds) for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { px := img.RGBAAt(x, y) out.SetRGBA(x, y, color.RGBA{255 - px.R, 255 - px.G, 255 - px.B, px.A}) } } return out } // XOR требует одинаковых размеров у обоих изображений — иначе неясно, // как сопоставлять пиксели. Частый CTF-приём: XOR "чистой" версии // картинки с версией, куда что-то зашито, визуально выделяет именно // то, что отличается, а не всё изображение целиком. func XOR(a, b *image.RGBA) (*image.RGBA, error) { boundsA, boundsB := a.Bounds(), b.Bounds() if boundsA != boundsB { return nil, fmt.Errorf("разные размеры изображений: %v и %v", boundsA.Size(), boundsB.Size()) } out := image.NewRGBA(boundsA) for y := boundsA.Min.Y; y < boundsA.Max.Y; y++ { for x := boundsA.Min.X; x < boundsA.Max.X; x++ { pa := a.RGBAAt(x, y) pb := b.RGBAAt(x, y) out.SetRGBA(x, y, color.RGBA{pa.R ^ pb.R, pa.G ^ pb.G, pa.B ^ pb.B, 255}) } } return out, nil }