231 lines
8.8 KiB
Go
231 lines
8.8 KiB
Go
// 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
|
||
}
|